From 5c3bea602fb8147a77e956abbd895a2617daf919 Mon Sep 17 00:00:00 2001 From: Nathan LeRoy Date: Fri, 28 Jun 2024 14:32:57 -0400 Subject: [PATCH 01/16] add skeleton for creating user developer keys --- pephub/dependencies.py | 4 +- pephub/developer_keys.py | 67 +++++++++++++++++++ pephub/routers/auth/base.py | 45 +++++++++++++ .../developer-settings/api-key-view.tsx | 59 ++++++++++++++++ web/src/components/modals/add-pep.tsx | 4 +- .../modals/developer-settings-modal.tsx | 60 +++++++++++++++++ web/src/pages/Namespace.tsx | 13 +++- 7 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 pephub/developer_keys.py create mode 100644 web/src/components/developer-settings/api-key-view.tsx create mode 100644 web/src/components/modals/developer-settings-modal.tsx diff --git a/pephub/dependencies.py b/pephub/dependencies.py index 77082ef0..1c479e1e 100644 --- a/pephub/dependencies.py +++ b/pephub/dependencies.py @@ -82,8 +82,8 @@ def _request_user_data_from_github(access_token: str) -> UserData: ) @staticmethod - def jwt_encode_user_data(user_data: dict) -> str: - exp = datetime.utcnow() + timedelta(minutes=JWT_EXPIRATION) + def jwt_encode_user_data(user_data: dict, exp: datetime = None) -> str: + exp = exp or datetime.utcnow() + timedelta(minutes=JWT_EXPIRATION) encoded_user_data = jwt.encode( {**user_data, "exp": exp}, JWT_SECRET, algorithm="HS256" ) diff --git a/pephub/developer_keys.py b/pephub/developer_keys.py new file mode 100644 index 00000000..6f172b9e --- /dev/null +++ b/pephub/developer_keys.py @@ -0,0 +1,67 @@ +from datetime import datetime, timedelta +from typing import Dict, List + +import jwt +from pydantic import BaseModel + +from .const import JWT_SECRET +from .dependencies import CLIAuthSystem + + +class DeveloperKey(BaseModel): + key: str + created_at: str + expires: str + + +class DeveloperKeyHandler: + def __init__(self, default_exp: int = 30 * 24 * 60 * 60): + self._keys: Dict[str, List[DeveloperKey]] = {} + self._default_exp = default_exp + + def add_key(self, namespace: str, key: DeveloperKey): + """ + Add a key to the handler for a given namespace/user + + :param namespace: namespace for the key + :param key: DeveloperKey object + """ + if namespace not in self._keys: + self._keys[namespace] = [] + self._keys[namespace].append(key) + + def get_keys_for_namespace(self, namespace: str) -> List[DeveloperKey]: + """ + Get all the keys for a given namespace + + namespace: str + """ + return self._keys.get(namespace) + + def remove_key(self, namespace: str, key: str): + """ + Remove a key from the handler for a given namespace/user + + :param namespace: namespace for the key + :param key: key to remove + """ + if namespace in self._keys: + self._keys[namespace] = [k for k in self._keys[namespace] if k.key != key] + + def mint_key_for_namespace( + self, namespace: str, session_info: dict + ) -> DeveloperKey: + """ + Mint a new key for a given namespace + + :param namespace: namespace for the key + """ + expiry = datetime.utcnow() + timedelta(seconds=self._default_exp) + new_key = CLIAuthSystem.jwt_encode_user_data(session_info, exp=expiry) + key = DeveloperKey( + key=new_key, + created_at=datetime.utcnow().isoformat(), + expires=expiry.isoformat(), + ) + self.add_key(namespace, key) + return key diff --git a/pephub/routers/auth/base.py b/pephub/routers/auth/base.py index 22380dd0..1863d452 100644 --- a/pephub/routers/auth/base.py +++ b/pephub/routers/auth/base.py @@ -30,11 +30,13 @@ JWTDeviceTokenResponse, TokenExchange, ) +from ...developer_keys import DeveloperKeyHandler load_dotenv() CODE_EXCHANGE = {} DEVICE_CODES = {} +dev_key_handler = DeveloperKeyHandler() templates = Jinja2Templates(directory=BASE_TEMPLATES_PATH) je = jinja2.Environment(loader=jinja2.FileSystemLoader(BASE_TEMPLATES_PATH)) @@ -66,6 +68,45 @@ def delete_device_code_after(code: str, expiration: int = AUTH_CODE_EXPIRATION): DEVICE_CODES.pop(code, None) +@auth.get("/user/keys") +def get_user_keys(session_info: Union[dict, None] = Depends(read_authorization_header)): + if session_info: + keys = dev_key_handler.get_keys_for_namespace(session_info["login"]) + + # obfuscate the keys -- we never want to show the full key + for key in keys: + key.key = key.key[:5] + "*" * 10 + key.key[-5:] + + return { + "keys": keys, + } + else: + raise HTTPException(status_code=401, detail="Invalid token") + + +@auth.post("/user/keys") +def mint_user_key( + session_info: Union[dict, None] = Depends(read_authorization_header), +): + if session_info: + key = dev_key_handler.mint_key_for_namespace(session_info["login"]) + return key + else: + raise HTTPException(status_code=401, detail="Invalid token") + + +@auth.delete("/user/keys") +def delete_user_key( + key: str, + session_info: Union[dict, None] = Depends(read_authorization_header), +): + if session_info: + dev_key_handler.remove_key(session_info["login"], key) + return {"message": "Key deleted successfully."} + else: + raise HTTPException(status_code=401, detail="Invalid token") + + @auth.get("/login", response_class=RedirectResponse) def login( client_redirect_uri: Union[str, None] = None, @@ -128,6 +169,10 @@ def callback( headers={"Authorization": f"Bearer {x['access_token']}"}, ).json() + # append the github access_token to the user data + u["gh_access_token"] = x["access_token"] + u["gh_refresh_token"] = x["refresh_token"] + # encode the token token = CLIAuthSystem.jwt_encode_user_data( dict(orgs=[org["login"] for org in organizations], **u) diff --git a/web/src/components/developer-settings/api-key-view.tsx b/web/src/components/developer-settings/api-key-view.tsx new file mode 100644 index 00000000..16717f32 --- /dev/null +++ b/web/src/components/developer-settings/api-key-view.tsx @@ -0,0 +1,59 @@ +import { dateStringToDateTime } from '../../utils/dates'; + +export const ApiKeyView = () => { + const testKeys = [ + { + key_obfuscated: '*********_3456', + created_at: '2021-09-01T00:00:00Z', + expires: '2021-09-01T00:00:00Z', + }, + { + key_obfuscated: '*********_3456', + created_at: '2021-09-01T00:00:00Z', + expires: '2021-09-01T00:00:00Z', + }, + ]; + return ( +
+
+
Active keys:
+ {testKeys?.length > 0 ? ( + testKeys.map((key, index) => ( +
+
+
+ + + {key.key_obfuscated} + +
+
+ Created at: {dateStringToDateTime(key.created_at)} + Expires: {dateStringToDateTime(key.expires)} +
+
+
+ +
+
+ )) + ) : ( +
No active keys. You can create a new API key below.
+ )} +
+
+
+ {/*
Create new:
*/} +
+ +
+
+
+ ); +}; diff --git a/web/src/components/modals/add-pep.tsx b/web/src/components/modals/add-pep.tsx index a8c0219a..53301dfb 100644 --- a/web/src/components/modals/add-pep.tsx +++ b/web/src/components/modals/add-pep.tsx @@ -1,4 +1,3 @@ -import { FC } from 'react'; import { Modal, Tab, Tabs } from 'react-bootstrap'; import { BlankProjectForm } from '../forms/blank-project-form'; @@ -11,7 +10,8 @@ interface Props { onHide: () => void; } -export const AddPEPModal: FC = ({ show, onHide, defaultNamespace }) => { +export const AddPEPModal = (props: Props) => { + const { show, onHide, defaultNamespace } = props; return ( diff --git a/web/src/components/modals/developer-settings-modal.tsx b/web/src/components/modals/developer-settings-modal.tsx new file mode 100644 index 00000000..be6499c1 --- /dev/null +++ b/web/src/components/modals/developer-settings-modal.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Col, ListGroup, Modal, Row } from 'react-bootstrap'; + +import { ApiKeyView } from '../developer-settings/api-key-view'; + +type Props = { + show: boolean; + onHide: () => void; +}; + +type View = 'api-keys' | 'account' | 'settings'; + +export const DeveloperSettingsModal = (props: Props) => { + const { show, onHide } = props; + + const [view, setView] = useState('api-keys'); + + return ( + + +

Developer Settings

+
+ + + + + setView('api-keys')}> + + API Keys + + setView('account')}> + + Account + + setView('settings')}> + + Settings + + + + +
+ {view === 'api-keys' ? ( + + ) : view === 'account' ? ( +
+

Account View

+
+ ) : view === 'settings' ? ( +
+

Settings View

+
+ ) : null} +
+ +
+
+
+ ); +}; diff --git a/web/src/pages/Namespace.tsx b/web/src/pages/Namespace.tsx index 82c59953..4e73d713 100644 --- a/web/src/pages/Namespace.tsx +++ b/web/src/pages/Namespace.tsx @@ -6,6 +6,7 @@ import { GitHubAvatar } from '../components/badges/github-avatar'; import { PageLayout } from '../components/layout/page-layout'; import { Pagination } from '../components/layout/pagination'; import { AddPEPModal } from '../components/modals/add-pep'; +import { DeveloperSettingsModal } from '../components/modals/developer-settings-modal'; import { DownloadGeo } from '../components/modals/download-geo'; import { NamespaceAPIEndpointsModal } from '../components/modals/namespace-api-endpoints'; import { NamespaceBadge } from '../components/namespace/namespace-badge'; @@ -45,6 +46,7 @@ export const NamespacePage = () => { const [showAddPEPModal, setShowAddPEPModal] = useState(false); const [showEndpointsModal, setShowEndpointsModal] = useState(false); const [showGeoDownloadModal, setShowGeoDownloadModal] = useState(false); + const [showSettingsModal, setShowSettingsModal] = useState(false); const [view, setView] = useState(viewFromUrl === 'stars' ? 'stars' : 'peps'); const [starSearch, setStarSearch] = useState(searchParams.get('starSearch') || ''); @@ -113,8 +115,8 @@ export const NamespacePage = () => {

{namespace}

-
- @@ -129,6 +131,12 @@ export const NamespacePage = () => { Download )} + {user?.login === namespace && ( + + )} {user?.login === namespace || user?.orgs.includes(namespace || '') ? (
)) ) : ( -
No active keys. You can create a new API key below.
+
No active keys. You can create a new API key below.
)}
{/*
Create new:
*/}
-
+ setNewKeyModalOpen(false)} + newKey={newkey} + setNewKey={setNewKey} + /> ); }; diff --git a/web/src/components/modals/new-api-key-modal.tsx b/web/src/components/modals/new-api-key-modal.tsx new file mode 100644 index 00000000..ecc2c479 --- /dev/null +++ b/web/src/components/modals/new-api-key-modal.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { Modal } from 'react-bootstrap'; + +interface Props { + show: boolean; + onHide: () => void; + newKey: string; + setNewKey: (key: string) => void; +} + +export const NewApiKeyModal = (props: Props) => { + const { show, onHide, newKey, setNewKey } = props; + + const [copied, setCopied] = useState(false); + + return ( + { + setNewKey(''); + onHide(); + }} + > + +

Success! Here is your new API key.

+
+ +
About your new key:
+

+ This is your new API key. Please copy it now, as it will not be shown again. You can use this key to access + the API from your scripts or other applications. +

+
+
+            {newKey}
+          
+
+
+ +
+
+ {/* + + */} +
+ ); +}; diff --git a/web/src/hooks/mutations/useCreateNewApiKey.ts b/web/src/hooks/mutations/useCreateNewApiKey.ts new file mode 100644 index 00000000..94123a96 --- /dev/null +++ b/web/src/hooks/mutations/useCreateNewApiKey.ts @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { toast } from 'react-hot-toast'; + +import { createNewApiKey } from '../../api/auth'; +import { extractErrorMessage } from '../../utils/etc'; +import { useSession } from '../useSession'; + +type CreateNewApiKeyMutationProps = { + onKeyCreated?: (newKey: string) => void; +}; + +export const useCreateNewApiKey = (createNewKeyProps: CreateNewApiKeyMutationProps) => { + const { user, jwt } = useSession(); + const queryClient = useQueryClient(); + + if (!user || !jwt) { + throw new Error('No user or jwt found'); + } + + const mutation = useMutation({ + mutationFn: () => { + return createNewApiKey(jwt); + }, + onSuccess: (data) => { + if (createNewKeyProps.onKeyCreated) { + createNewKeyProps.onKeyCreated(data.key.key); + } + + toast.success('API key successfully created.'); + + queryClient.invalidateQueries({ + queryKey: [user.login, 'api-keys'], + }); + }, + onError: (err: AxiosError) => { + // extract out error message if it exists, else unknown + const errorMessage = extractErrorMessage(err); + toast.error(`${errorMessage}`, { + duration: 5000, + }); + }, + }); + + return mutation; +}; diff --git a/web/src/hooks/mutations/useRevokeApiKey.ts b/web/src/hooks/mutations/useRevokeApiKey.ts new file mode 100644 index 00000000..d65d710f --- /dev/null +++ b/web/src/hooks/mutations/useRevokeApiKey.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { toast } from 'react-hot-toast'; + +import { revokeApiKey } from '../../api/auth'; +import { extractErrorMessage } from '../../utils/etc'; +import { useSession } from '../useSession'; + +type RevokeRequest = { + lastFiveChars: string; +}; + +export const useRevokeApiKey = () => { + const { user, jwt } = useSession(); + const queryClient = useQueryClient(); + + if (!user || !jwt) { + throw new Error('No user or jwt found'); + } + + const mutation = useMutation({ + mutationFn: (rq: RevokeRequest) => { + return revokeApiKey(jwt, rq.lastFiveChars); + }, + onSuccess: () => { + toast.success('API key successfully revoked.'); + queryClient.invalidateQueries({ + queryKey: [user.login, 'api-keys'], + }); + }, + onError: (err: AxiosError) => { + // extract out error message if it exists, else unknown + const errorMessage = extractErrorMessage(err); + toast.error(`${errorMessage}`, { + duration: 5000, + }); + }, + }); + + return mutation; +}; diff --git a/web/src/hooks/queries/useUserApiKeys.ts b/web/src/hooks/queries/useUserApiKeys.ts new file mode 100644 index 00000000..e7d1467a --- /dev/null +++ b/web/src/hooks/queries/useUserApiKeys.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getUserApiKeys } from '../../api/auth'; +import { useSession } from '../useSession'; + +export const useUserApiKeys = () => { + const { user, jwt } = useSession(); + + if (!user || !jwt) { + throw new Error('No user or jwt found'); + } + + const query = useQuery({ + queryKey: [user.login, 'api-keys'], + queryFn: () => getUserApiKeys(jwt), + enabled: !!user, + }); + return query; +}; From 7e443b40f232c50210981d4097395f30f48094af Mon Sep 17 00:00:00 2001 From: Nathan LeRoy Date: Fri, 28 Jun 2024 16:37:07 -0400 Subject: [PATCH 03/16] styling --- web/src/components/layout/nav/nav.tsx | 2 +- web/src/components/project/project-card.tsx | 4 +- web/src/components/tables/sample-table.tsx | 192 +++++++++++--------- 3 files changed, 105 insertions(+), 93 deletions(-) diff --git a/web/src/components/layout/nav/nav.tsx b/web/src/components/layout/nav/nav.tsx index d809a96b..6029c68f 100644 --- a/web/src/components/layout/nav/nav.tsx +++ b/web/src/components/layout/nav/nav.tsx @@ -11,7 +11,7 @@ export const Nav: FC = () => {