Merge pull request #763 from omnivore-app/feature/api-key-ui

API key UI
This commit is contained in:
Hongbo Wu 2022-06-07 10:01:45 +08:00 committed by GitHub
commit 3e3f2d1102
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 314 additions and 1 deletions

View file

@ -34,7 +34,10 @@ export const apiKeysResolver = authorized<ApiKeysSuccess, ApiKeysError>(
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 {

View file

@ -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}
/>
)
}

View file

@ -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<string | undefined> {
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
}
}

View file

@ -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<any | undefined> {
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
}
}

View file

@ -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: () => {},
}
}

View file

@ -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<string>('')
const [addModelOpen, setAddModelOpen] = useState(false)
const [name, setName] = useState('')
// const [scopes, setScopes] = useState<string[] | undefined>(undefined)
const [expiresAt, setExpiresAt] = useState<Date>(new Date())
const [formInputs, setFormInputs] = useState<FormInputProps[]>([])
const [apiKeyGenerated, setApiKeyGenerated] = useState('')
const headers = ['Name', 'Scopes', 'Used at', 'Expires on']
const rows = useMemo(() => {
const rows = new Map<string, ApiKey>()
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<void> {
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<void> {
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 (
<PrimaryLayout pageTestId={'api-keys'}>
<Toaster
containerStyle={{
top: '5rem',
}}
/>
{addModelOpen && (
<FormModal
title={'Generate API Key'}
onSubmit={onCreate}
onOpenChange={setAddModelOpen}
inputs={formInputs}
acceptButtonLabel={'Generate'}
/>
)}
{apiKeyGenerated && (
<ConfirmationModal
message={`API key generated. Copy the key and use it in your application.
You wont be able to see it again!
Key: ${apiKeyGenerated}`}
acceptButtonLabel={'Copy'}
onAccept={async () => {
await navigator.clipboard.writeText(apiKeyGenerated)
setApiKeyGenerated('')
}}
onOpenChange={() => setApiKeyGenerated('')}
/>
)}
{onDeleteId && (
<ConfirmationModal
message={'API key would be revoked. This action cannot be undone.'}
onAccept={async () => {
await onDelete(onDeleteId)
setOnDeleteId('')
}}
onOpenChange={() => setOnDeleteId('')}
/>
)}
<Table
heading={'API Keys'}
headers={headers}
rows={rows}
onDelete={setOnDeleteId}
onAdd={() => {
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)
}}
/>
</PrimaryLayout>
)
}