mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #763 from omnivore-app/feature/api-key-ui
API key UI
This commit is contained in:
commit
3e3f2d1102
6 changed files with 314 additions and 1 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
75
packages/web/lib/networking/queries/useGetApiKeysQuery.tsx
Normal file
75
packages/web/lib/networking/queries/useGetApiKeysQuery.tsx
Normal 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: () => {},
|
||||
}
|
||||
}
|
||||
147
packages/web/pages/settings/api.tsx
Normal file
147
packages/web/pages/settings/api.tsx
Normal 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 won’t 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue