diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index f6c067e75..5ef001fc4 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -34,7 +34,10 @@ export const apiKeysResolver = authorized( const apiKeys = await getRepository(ApiKey).find({ select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'], where: { user: { id: uid } }, - order: { usedAt: 'DESC', createdAt: 'DESC' }, + order: { + usedAt: { direction: 'DESC', nulls: 'last' }, + createdAt: 'DESC', + }, }) return { diff --git a/packages/web/components/elements/FormElements.tsx b/packages/web/components/elements/FormElements.tsx index f7896089f..03f501e7d 100644 --- a/packages/web/components/elements/FormElements.tsx +++ b/packages/web/components/elements/FormElements.tsx @@ -16,6 +16,7 @@ export interface FormInputProps { required?: boolean css?: any options?: string[] + min?: any } export const FormInput = styled('input', { @@ -96,6 +97,7 @@ export function GeneralFormInput(props: FormInputProps): JSX.Element { }, }} name={input.name} + min={input.min} /> ) } diff --git a/packages/web/lib/networking/mutations/generateApiKeyMutation.ts b/packages/web/lib/networking/mutations/generateApiKeyMutation.ts new file mode 100644 index 000000000..d8c531001 --- /dev/null +++ b/packages/web/lib/networking/mutations/generateApiKeyMutation.ts @@ -0,0 +1,47 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { ApiKey } from '../queries/useGetApiKeysQuery' + +export interface GenerateApiKeyInput { + name: string + scopes?: string[] + expiresAt: Date +} + +interface GenerateApiKeyResult { + generateApiKey: GenerateApiKey + errorCodes?: unknown[] +} + +type GenerateApiKey = { + apiKey: ApiKey +} + +export async function generateApiKeyMutation( + input: GenerateApiKeyInput +): Promise { + const mutation = gql` + mutation GenerateApiKey($input: GenerateApiKeyInput!) { + generateApiKey(input: $input) { + ... on GenerateApiKeySuccess { + apiKey { + key + } + } + ... on GenerateApiKeyError { + errorCodes + } + } + } + ` + + try { + const data = (await gqlFetcher(mutation, { + input, + })) as GenerateApiKeyResult + return data.errorCodes ? undefined : data.generateApiKey.apiKey.key + } catch (error) { + console.log('generateApiKeyMutation error', error) + return undefined + } +} diff --git a/packages/web/lib/networking/mutations/revokeApiKeyMutation.ts b/packages/web/lib/networking/mutations/revokeApiKeyMutation.ts new file mode 100644 index 000000000..83f9447f5 --- /dev/null +++ b/packages/web/lib/networking/mutations/revokeApiKeyMutation.ts @@ -0,0 +1,39 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { ApiKey } from '../queries/useGetApiKeysQuery' + +interface RevokeApiKeyResult { + revokeApiKey: RevokeApiKey + errorCodes?: unknown[] +} + +type RevokeApiKey = { + apiKey: ApiKey +} + +export async function revokeApiKeyMutation( + id: string +): Promise { + const mutation = gql` + mutation { + revokeApiKey(id: "${id}") { + ... on RevokeApiKeySuccess { + apiKey { + id + } + } + ... on RevokeApiKeyError { + errorCodes + } + } + } + ` + + try { + const data = (await gqlFetcher(mutation)) as RevokeApiKeyResult + return data.errorCodes ? undefined : data.revokeApiKey.apiKey.id + } catch (error) { + console.log('revokeApiKeyMutation error', error) + return undefined + } +} diff --git a/packages/web/lib/networking/queries/useGetApiKeysQuery.tsx b/packages/web/lib/networking/queries/useGetApiKeysQuery.tsx new file mode 100644 index 000000000..b13422427 --- /dev/null +++ b/packages/web/lib/networking/queries/useGetApiKeysQuery.tsx @@ -0,0 +1,75 @@ +import { gql } from 'graphql-request' +import useSWR from 'swr' +import { publicGqlFetcher } from '../networkHelpers' + +export interface ApiKey { + id: string + name: string + key?: string + scopes: string[] + createdAt: Date + expiresAt: Date + usedAt?: Date +} + +interface ApiKeysQueryResponse { + isValidating: boolean + apiKeys: ApiKey[] + revalidate: () => void +} + +interface ApiKeysQueryResponseData { + apiKeys: ApiKeysData +} + +interface ApiKeysData { + apiKeys: unknown +} + +export function useGetApiKeysQuery(): ApiKeysQueryResponse { + const query = gql` + query GetApiKeys { + apiKeys { + ... on ApiKeysSuccess { + apiKeys { + id + name + key + scopes + createdAt + expiresAt + usedAt + } + } + ... on ApiKeysError { + errorCodes + } + } + } + ` + + const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher) + console.log('api keys data', data) + + try { + if (data) { + const result = data as ApiKeysQueryResponseData + const apiKeys = result.apiKeys.apiKeys as ApiKey[] + return { + isValidating, + apiKeys, + revalidate: () => { + mutate() + }, + } + } + } catch (error) { + console.log('error', error) + } + return { + isValidating: false, + apiKeys: [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + revalidate: () => {}, + } +} diff --git a/packages/web/pages/settings/api.tsx b/packages/web/pages/settings/api.tsx new file mode 100644 index 000000000..38430bf83 --- /dev/null +++ b/packages/web/pages/settings/api.tsx @@ -0,0 +1,147 @@ +import { PrimaryLayout } from '../../components/templates/PrimaryLayout' +import { Toaster } from 'react-hot-toast' +import { Table } from '../../components/elements/Table' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { useMemo, useState } from 'react' +import { useGetApiKeysQuery } from '../../lib/networking/queries/useGetApiKeysQuery' +import { FormInputProps } from '../../components/elements/FormElements' +import { generateApiKeyMutation } from '../../lib/networking/mutations/generateApiKeyMutation' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { FormModal } from '../../components/patterns/FormModal' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { revokeApiKeyMutation } from '../../lib/networking/mutations/revokeApiKeyMutation' + +interface ApiKey { + name: string + scopes: string + expiresAt: string + usedAt: string +} + +export default function Api(): JSX.Element { + const { apiKeys, revalidate } = useGetApiKeysQuery() + const [onDeleteId, setOnDeleteId] = useState('') + const [addModelOpen, setAddModelOpen] = useState(false) + const [name, setName] = useState('') + // const [scopes, setScopes] = useState(undefined) + const [expiresAt, setExpiresAt] = useState(new Date()) + const [formInputs, setFormInputs] = useState([]) + const [apiKeyGenerated, setApiKeyGenerated] = useState('') + + const headers = ['Name', 'Scopes', 'Used at', 'Expires on'] + const rows = useMemo(() => { + const rows = new Map() + apiKeys.forEach((apiKey) => + rows.set(apiKey.id, { + name: apiKey.name, + scopes: apiKey.scopes.join(', ') || 'All', + usedAt: apiKey.usedAt + ? new Date(apiKey.usedAt).toISOString() + : 'Never used', + expiresAt: new Date(apiKey.expiresAt).toDateString(), + }) + ) + return rows + }, [apiKeys]) + + // default expiry date is 1 year from now + const defaultExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365) + .toISOString() + .split('T')[0] + + applyStoredTheme(false) + + async function onDelete(id: string): Promise { + const result = await revokeApiKeyMutation(id) + if (result) { + showSuccessToast('API Key deleted', { position: 'bottom-right' }) + } else { + showErrorToast('Failed to delete', { position: 'bottom-right' }) + } + revalidate() + } + + async function onCreate(): Promise { + const result = await generateApiKeyMutation({ name, expiresAt }) + if (result) { + setApiKeyGenerated(result) + showSuccessToast('API key generated', { position: 'bottom-right' }) + } else { + showErrorToast('Failed to add', { position: 'bottom-right' }) + } + revalidate() + } + + return ( + + + + {addModelOpen && ( + + )} + + {apiKeyGenerated && ( + { + await navigator.clipboard.writeText(apiKeyGenerated) + setApiKeyGenerated('') + }} + onOpenChange={() => setApiKeyGenerated('')} + /> + )} + + {onDeleteId && ( + { + await onDelete(onDeleteId) + setOnDeleteId('') + }} + onOpenChange={() => setOnDeleteId('')} + /> + )} + { + setFormInputs([ + { + label: 'Name', + onChange: setName, + name: 'name', + required: true, + }, + { + label: 'Expired on', + name: 'expiredAt', + required: true, + onChange: setExpiresAt, + type: 'date', + min: new Date().toISOString().split('T')[0], // today + value: defaultExpiresAt, + }, + ]) + setName('') + setExpiresAt(new Date(defaultExpiresAt)) + setAddModelOpen(true) + }} + /> + + ) +}