Merge pull request #17 from Myzel394/add-api

Add api
This commit is contained in:
Myzel394 2023-03-11 13:02:23 +01:00 committed by GitHub
commit 899208986f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 816 additions and 16 deletions

View File

@ -20,6 +20,7 @@
"@emotion/styled": "^11.10.4", "@emotion/styled": "^11.10.4",
"@mui/lab": "^5.0.0-alpha.103", "@mui/lab": "^5.0.0-alpha.103",
"@mui/material": "^5.10.9", "@mui/material": "^5.10.9",
"@mui/x-date-pickers": "^6.0.1",
"@originjs/vite-plugin-commonjs": "^1.0.3", "@originjs/vite-plugin-commonjs": "^1.0.3",
"@tanstack/react-query": "^4.12.0", "@tanstack/react-query": "^4.12.0",
"axios": "^1.1.2", "axios": "^1.1.2",

View File

@ -59,6 +59,10 @@
}, },
"report": { "report": {
"deleted": "Report has been deleted!" "deleted": "Report has been deleted!"
},
"apiKey": {
"keyCopied": "API key has been copied to your clipboard!",
"deleted": "API key has been deleted!"
} }
}, },
"general": { "general": {
@ -89,5 +93,22 @@
"signup": "Sign up", "signup": "Sign up",
"login": "Log in", "login": "Log in",
"logout": "Log out" "logout": "Log out"
},
"values": {
"scopes": {
"basic_profile": "Basic Profile",
"full_profile": "Full Profile",
"read_preferences": "Read Preferences",
"update_preferences": "Update Preferences",
"read_alias": "Read Aliases",
"create_alias": "Create Aliases",
"update_alias": "Update Aliases",
"delete_alias": "Delete Aliases",
"read_report": "Read Reports",
"delete_report": "Delete Reports"
}
} }
} }

View File

@ -0,0 +1,42 @@
{
"title": "Manage your API Keys",
"create": {
"label": "Create a new API Key",
"description": "Define a label and the scopes you want to grant to this API Key.",
"continueActionLabel": "Create API Key",
"success": "Your API Key has been created. Copy it now, you won't be able to see it again.",
"form": {
"label": {
"label": "Label"
},
"expiresAt": {
"label": "Expiration date",
"errors": {
"tooFarInFuture": "This date is too far in the future.",
"inPast": "Expiration date must be in the future."
},
"values": {
"1days": "1 Day",
"7days": "1 Week",
"30days": "1 Month",
"180days": "1/2 Year",
"360days": "1 Year"
}
},
"scopes": {
"label": "Scopes"
}
}
},
"emptyState": {
"title": "Welcome to your API Keys",
"description": "Create an API Key to get started with the API."
},
"actions": {
"delete": {
"label": "Delete API Key",
"description": "Are you sure you want to delete this API Key? Your existing integrations using this API Key will stop working. You can create a new API Key to replace it.",
"continueActionLabel": "Delete API Key"
}
}
}

View File

@ -2,6 +2,7 @@
"title": "Settings", "title": "Settings",
"actions": { "actions": {
"enable2fa": "Two-Factor-Authentication", "enable2fa": "Two-Factor-Authentication",
"aliasPreferences": "Alias Preferences" "aliasPreferences": "Alias Preferences",
"apiKeys": "Manage API Keys"
} }
} }

View File

@ -7,7 +7,7 @@
"mailVerification": { "mailVerification": {
"title": "You got mail!", "title": "You got mail!",
"description": "We sent you an email with a link to confirm your email address. Please check your inbox and click on the link to continue.", "description": "We sent you an email with a link to confirm your email address. Please check your inbox and click on the link to continue.",
"maskAsNotSpam": "If you can't find the email, please check your spam folder. If the email lands in your spam folder, please mark it as \"Not Spam\" to receive all emails in the future", "markAsNotSpam": "If you can't find the email, please check your spam folder. If the email lands in your spam folder, please mark it as \"Not Spam\" to receive all emails in the future",
"editEmail": { "editEmail": {
"title": "Edit email address?", "title": "Edit email address?",
"description": "Would you like to return to the previous step and edit your email address?", "description": "Would you like to return to the previous step and edit your email address?",

View File

@ -7,6 +7,8 @@ import React, {ReactElement} from "react"
import {queryClient} from "~/constants/react-query" import {queryClient} from "~/constants/react-query"
import {getServerSettings} from "~/apis" import {getServerSettings} from "~/apis"
import {darkTheme, lightTheme} from "~/constants/themes" import {darkTheme, lightTheme} from "~/constants/themes"
import {LocalizationProvider} from "@mui/x-date-pickers"
import {AdapterDateFns} from "@mui/x-date-pickers/AdapterDateFns"
import AdminRoute from "~/routes/AdminRoute" import AdminRoute from "~/routes/AdminRoute"
import AliasDetailRoute from "~/routes/AliasDetailRoute" import AliasDetailRoute from "~/routes/AliasDetailRoute"
import AliasesRoute from "~/routes/AliasesRoute" import AliasesRoute from "~/routes/AliasesRoute"
@ -28,6 +30,7 @@ import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute"
import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute" import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute"
import RootRoute from "~/routes/Root" import RootRoute from "~/routes/Root"
import Settings2FARoute from "~/routes/Settings2FARoute" import Settings2FARoute from "~/routes/Settings2FARoute"
import SettingsAPIKeysRoute from "~/routes/SettingsAPIKeysRoute"
import SettingsAliasPreferencesRoute from "~/routes/SettingsAliasPreferencesRoute" import SettingsAliasPreferencesRoute from "~/routes/SettingsAliasPreferencesRoute"
import SettingsRoute from "~/routes/SettingsRoute" import SettingsRoute from "~/routes/SettingsRoute"
import SignupRoute from "~/routes/SignupRoute" import SignupRoute from "~/routes/SignupRoute"
@ -105,6 +108,11 @@ const router = createBrowserRouter([
path: "/settings/2fa", path: "/settings/2fa",
element: <Settings2FARoute />, element: <Settings2FARoute />,
}, },
{
path: "/settings/api-keys",
loader: getServerSettings,
element: <SettingsAPIKeysRoute />,
},
{ {
path: "/reports", path: "/reports",
loader: getServerSettings, loader: getServerSettings,
@ -162,9 +170,11 @@ export default function App(): ReactElement {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider theme={THEME_THEME_MAP[theme]}> <ThemeProvider theme={THEME_THEME_MAP[theme]}>
<SnackbarProvider> <SnackbarProvider>
<CssBaseline /> <LocalizationProvider dateAdapter={AdapterDateFns}>
<RouterProvider router={router} /> <CssBaseline />
<I18nHandler /> <RouterProvider router={router} />
<I18nHandler />
</LocalizationProvider>
</SnackbarProvider> </SnackbarProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>

View File

@ -0,0 +1,35 @@
import {APIKey} from "~/server-types"
import {client} from "~/constants/axios-client"
import formatISO from "date-fns/formatISO"
import parseAPIKey from "~/apis/helpers/parse-api-key"
export interface CreateAPIKeyData {
label: string
scopes: APIKey["scopes"]
expiresAt?: Date
}
export default async function createAPIKey({
label,
scopes,
expiresAt,
}: CreateAPIKeyData): Promise<APIKey & {key: string}> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/api-key`,
{
label,
scopes,
expiresAt: expiresAt
? formatISO(expiresAt, {
representation: "date",
})
: undefined,
},
{
withCredentials: true,
},
)
return parseAPIKey(data) as APIKey & {key: string}
}

View File

@ -0,0 +1,10 @@
import {client} from "~/constants/axios-client"
import {SimpleDetailResponse} from "~/server-types"
export default async function deleteAPIKey(id: string): Promise<SimpleDetailResponse> {
const {data} = await client.delete(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/api-key/${id}`, {
withCredentials: true,
})
return data
}

22
src/apis/get-api-keys.ts Normal file
View File

@ -0,0 +1,22 @@
import {APIKey, GetPageData, PaginationResult} from "~/server-types"
import {client} from "~/constants/axios-client"
import parseAPIKey from "~/apis/helpers/parse-api-key"
export interface GetAPIKeysData extends GetPageData {}
export default async function getAPIKeys({size, page}: GetAPIKeysData = {}): Promise<
PaginationResult<APIKey>
> {
const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/api-key/`, {
withCredentials: true,
params: {
size,
page,
},
})
return {
...data,
items: data.items.map(parseAPIKey),
}
}

View File

@ -0,0 +1,8 @@
import {APIKey} from "~/server-types"
export default function parseAPIKey(key: APIKey): APIKey {
return {
...key,
expiresAt: new Date(key.expiresAt),
}
}

View File

@ -68,3 +68,9 @@ export * from "./delete-2fa"
export {default as delete2FA} from "./delete-2fa" export {default as delete2FA} from "./delete-2fa"
export * from "./verify-otp" export * from "./verify-otp"
export {default as verifyOTP} from "./verify-otp" export {default as verifyOTP} from "./verify-otp"
export * from "./create-api-key"
export {default as createAPIKey} from "./create-api-key"
export * from "./get-api-keys"
export {default as getAPIKeys} from "./get-api-keys"
export * from "./delete-api-key"
export {default as deleteAPIKey} from "./delete-api-key"

View File

@ -25,6 +25,8 @@ export interface DeleteAPIButtonProps {
description?: string description?: string
successMessage?: string successMessage?: string
navigateTo?: string navigateTo?: string
children?: (onDelete: () => void) => ReactElement
onDone?: () => void
} }
export default function DeleteAPIButton({ export default function DeleteAPIButton({
@ -33,7 +35,9 @@ export default function DeleteAPIButton({
label, label,
continueLabel, continueLabel,
description, description,
navigateTo = "/aliases", navigateTo,
onDone,
children: render,
}: DeleteAPIButtonProps): ReactElement { }: DeleteAPIButtonProps): ReactElement {
const {t} = useTranslation("common") const {t} = useTranslation("common")
const {showError, showSuccess} = useErrorSuccessSnacks() const {showError, showSuccess} = useErrorSuccessSnacks()
@ -43,7 +47,12 @@ export default function DeleteAPIButton({
onError: showError, onError: showError,
onSuccess: () => { onSuccess: () => {
showSuccess(successMessage || t("messages.deletedObject")) showSuccess(successMessage || t("messages.deletedObject"))
navigate(navigateTo)
if (navigateTo) {
navigate(navigateTo)
} else if (onDone) {
onDone()
}
}, },
}) })
@ -51,15 +60,19 @@ export default function DeleteAPIButton({
return ( return (
<> <>
<Button {render ? (
variant="outlined" render(() => setShowDeleteDialog(true))
color="error" ) : (
size="small" <Button
startIcon={<MdDelete />} variant="outlined"
onClick={() => setShowDeleteDialog(true)} color="error"
> size="small"
{label} startIcon={<MdDelete />}
</Button> onClick={() => setShowDeleteDialog(true)}
>
{label}
</Button>
)}
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}> <Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}>
<DialogTitle>{label}</DialogTitle> <DialogTitle>{label}</DialogTitle>
<DialogContent> <DialogContent>

View File

@ -16,3 +16,18 @@ export const DEFAULT_ALIAS_NOTE: AliasNote = {
export const ERROR_SNACKBAR_SHOW_DURATION = 5000 export const ERROR_SNACKBAR_SHOW_DURATION = 5000
export const SUCCESS_SNACKBAR_SHOW_DURATION = 2000 export const SUCCESS_SNACKBAR_SHOW_DURATION = 2000
export const AUTHENTICATION_PATHS = ["/auth/login", "/auth/signup", "/auth/complete-account"] export const AUTHENTICATION_PATHS = ["/auth/login", "/auth/signup", "/auth/complete-account"]
export const API_KEY_SCOPES = [
"basic_profile",
"full_profile",
"read:preferences",
"update:preferences",
"read:alias",
"create:alias",
"update:alias",
"delete:alias",
"read:report",
"delete:report",
]

View File

@ -0,0 +1,75 @@
import {ReactElement} from "react"
import {APIKey} from "~/server-types"
import {Alert, IconButton, ListItem, ListItemSecondaryAction, ListItemText} from "@mui/material"
import {useTranslation} from "react-i18next"
import {MdContentCopy, MdDelete} from "react-icons/md"
import {useCopyToClipboard} from "react-use"
import {DeleteButton, ErrorSnack, SuccessSnack} from "~/components"
import {deleteAPIKey} from "~/apis"
import {queryClient} from "~/constants/react-query"
export interface APIKeyListItemProps {
apiKey: APIKey
queryKey: readonly string[]
privateKey?: string
}
export default function APIKeyListItem({
queryKey,
apiKey,
privateKey,
}: APIKeyListItemProps): ReactElement {
const {t} = useTranslation(["settings-api-keys", "common"])
const [{value, error}, copy] = useCopyToClipboard()
if (privateKey) {
return (
<>
<Alert severity="success">
<ListItem>
<ListItemText>{privateKey}</ListItemText>
<ListItemSecondaryAction>
<IconButton edge="end" onClick={() => copy(privateKey)}>
<MdContentCopy />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Alert>
<SuccessSnack
key={value}
message={value && t("messages.apiKey.keyCopied", {ns: "common"})}
/>
<ErrorSnack message={error && t("messages.errors.copyFailed", {ns: "common"})} />
</>
)
}
return (
<ListItem>
<>
<ListItemText primary={apiKey.label} secondary={apiKey.expiresAt.toString()} />
<ListItemSecondaryAction>
<DeleteButton
onDelete={() => deleteAPIKey(apiKey.id)}
onDone={() =>
queryClient.invalidateQueries({
queryKey,
})
}
label={t("actions.delete.label")}
description={t("actions.delete.description")}
continueLabel={t("actions.delete.continueActionLabel")}
successMessage={t("messages.apiKey.deleted", {ns: "common"})}
>
{onDelete => (
<IconButton edge="end" onClick={onDelete}>
<MdDelete />
</IconButton>
)}
</DeleteButton>
</ListItemSecondaryAction>
</>
</ListItem>
)
}

View File

@ -0,0 +1,311 @@
import * as yup from "yup"
import {ReactElement} from "react"
import {
Alert,
Badge,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
FormHelperText,
Grid,
InputAdornment,
InputLabel,
ListItem,
ListItemIcon,
ListItemText,
MenuItem,
Select,
TextField,
} from "@mui/material"
import {useTranslation} from "react-i18next"
import {useFormik} from "formik"
import {CreateAPIKeyData, createAPIKey} from "~/apis"
import {APIKey, APIKeyScope, ServerSettings} from "~/server-types"
import {BiText} from "react-icons/bi"
import {CgProfile} from "react-icons/cg"
import {DatePicker} from "@mui/x-date-pickers"
import {useLoaderData} from "react-router-dom"
import {API_KEY_SCOPES} from "~/constants/values"
import {FaMask} from "react-icons/fa"
import {MdAdd, MdCancel, MdDelete, MdEdit, MdTextSnippet} from "react-icons/md"
import {GoSettings} from "react-icons/go"
import {TiEye} from "react-icons/ti"
import {useMutation} from "@tanstack/react-query"
import {AxiosError} from "axios"
import {parseFastAPIError} from "~/utils"
import {useErrorSuccessSnacks} from "~/hooks"
import addDays from "date-fns/addDays"
import diffInDays from "date-fns/differenceInDays"
import set from "date-fns/set"
export interface CreateNewAPIKeyDialogProps {
open: boolean
onClose: () => void
onCreated: (key: APIKey & {key: string}) => void
}
const PRESET_DAYS: number[] = [1, 7, 30, 180, 360]
const API_KEY_SCOPE_ICON_MAP: Record<string, ReactElement> = {
profile: <CgProfile />,
alias: <FaMask />,
report: <MdTextSnippet />,
preferences: <GoSettings />,
}
const API_KEY_SCOPE_TYPE_ICON_MAP: Record<string, ReactElement> = {
read: <TiEye />,
create: <MdAdd />,
update: <MdEdit />,
delete: <MdDelete />,
}
const normalizeTime = (date: Date) =>
set(date, {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
})
export default function CreateNewAPIKeyDialog({
open,
onClose,
onCreated,
}: CreateNewAPIKeyDialogProps): ReactElement {
const {t} = useTranslation(["settings-api-keys", "common"])
const serverSettings = useLoaderData() as ServerSettings
const {showSuccess} = useErrorSuccessSnacks()
const scheme = yup.object().shape({
label: yup.string().required().label(t("create.form.label.label")),
expiresAt: yup.date().required().label(t("create.form.expiresAt.label")),
scopes: yup
.array<APIKeyScope[]>()
.of(yup.string())
.required()
.label(t("create.form.scopes.label")),
})
const {mutateAsync} = useMutation<APIKey & {key: string}, AxiosError, CreateAPIKeyData>(
data => createAPIKey(data),
{
onSuccess: async key => {
onClose()
showSuccess(t("create.success"))
onCreated(key)
},
},
)
const formik = useFormik<CreateAPIKeyData & {detail: string}>({
validationSchema: scheme,
initialValues: {
label: "",
expiresAt: addDays(new Date(), 30),
scopes: [],
detail: "",
},
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync(values)
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("create.label")}</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<DialogContentText>{t("create.description")}</DialogContentText>
<Grid container spacing={4} mt={1}>
<Grid item xs={12}>
<TextField
fullWidth
name="label"
id="label"
label={t("create.form.label.label")}
disabled={formik.isSubmitting}
error={formik.touched.label && Boolean(formik.errors.label)}
helperText={formik.touched.label && formik.errors.label}
value={formik.values.label}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<BiText />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item xs={12}>
<FormControl
fullWidth
error={formik.touched.scopes && Boolean(formik.errors.scopes)}
>
<InputLabel htmlFor="scopes" id="scopes-label">
{t("create.form.scopes.label")}
</InputLabel>
<Select<string[]>
multiple
fullWidth
name="scopes"
id="scopes"
label={t("create.form.scopes.label")}
disabled={formik.isSubmitting}
error={formik.touched.scopes && Boolean(formik.errors.scopes)}
value={formik.values.scopes}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
renderValue={(selected: string[]) => (
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.5}}>
{selected.map(value => (
<Chip
key={value}
label={t(
`values.scopes.${value.replace(":", "_")}`,
{
ns: "common",
},
)}
/>
))}
</Box>
)}
>
{API_KEY_SCOPES.map(scope => (
<MenuItem key={scope} value={scope}>
<ListItem>
<ListItemIcon>
<Badge
badgeContent={
API_KEY_SCOPE_TYPE_ICON_MAP[
scope
.replace(":", "_")
.split("_")[0]
]
}
>
{
API_KEY_SCOPE_ICON_MAP[
scope
.replace(":", "_")
.split("_")[1]
]
}
</Badge>
</ListItemIcon>
<ListItemText>
{t(`values.scopes.${scope.replace(":", "_")}`, {
ns: "common",
})}
</ListItemText>
</ListItem>
</MenuItem>
))}
</Select>
<FormHelperText
error={Boolean(formik.touched.scopes && formik.errors.scopes)}
>
{formik.touched.scopes && formik.errors.scopes}
</FormHelperText>
</FormControl>
</Grid>
<Grid item xs={12}>
<Grid container spacing={1}>
<Grid item xs={12}>
<DatePicker
value={formik.values.expiresAt}
disabled={formik.isSubmitting}
label={t("create.form.expiresAt.label")}
slotProps={{
textField: {
fullWidth: true,
name: "expiresAt",
error:
formik.touched.expiresAt &&
Boolean(formik.errors.expiresAt),
helperText:
formik.touched.expiresAt &&
formik.errors.expiresAt,
onBlur: formik.handleBlur,
},
}}
minDate={new Date()}
maxDate={addDays(new Date(), serverSettings.apiKeyMaxDays)}
onChange={value => formik.setFieldValue("expiresAt", value)}
/>
<FormHelperText
error={
formik.touched.expiresAt &&
Boolean(formik.errors.expiresAt)
}
>
{formik.touched.expiresAt && formik.errors.expiresAt}
</FormHelperText>
</Grid>
<Grid item>
<Grid container spacing={1} direction="row">
{PRESET_DAYS.filter(
days => days <= serverSettings.apiKeyMaxDays,
).map(days => (
<Grid item key={days}>
<Chip
label={t(
`create.form.expiresAt.values.${days}days`,
)}
variant={
diffInDays(
formik.values.expiresAt!,
normalizeTime(new Date()),
) >= days
? "filled"
: "outlined"
}
onClick={() =>
formik.setFieldValue(
"expiresAt",
addDays(
normalizeTime(new Date()),
days,
),
)
}
/>
</Grid>
))}
</Grid>
</Grid>
</Grid>
</Grid>
{formik.errors.detail && (
<Grid item xs={12}>
<Alert severity="error">{formik.errors.detail}</Alert>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button startIcon={<MdCancel />} onClick={onClose}>
{t("general.cancelLabel", {ns: "common"})}
</Button>
<Button type="submit" variant="contained">
{t("create.continueActionLabel")}
</Button>
</DialogActions>
</form>
</Dialog>
)
}

View File

@ -0,0 +1,35 @@
import {ReactElement} from "react"
import {useTranslation} from "react-i18next"
import {Container, Grid, Typography} from "@mui/material"
import {MdVpnKey} from "react-icons/md"
export default function EmptyStateScreen(): ReactElement {
const {t} = useTranslation("settings-api-keys")
return (
<Container maxWidth="xs">
<Grid
container
spacing={4}
direction="column"
alignItems="center"
maxWidth="80%"
alignSelf="center"
marginX="auto"
>
<Grid item>
<Typography variant="h6" component="h2">
{t("emptyState.title")}
</Typography>
</Grid>
<Grid item>
<MdVpnKey size={40} />
</Grid>
<Grid item>
<Typography variant="body1">{t("emptyState.description")}</Typography>
</Grid>
</Grid>
</Container>
)
}

View File

@ -0,0 +1,71 @@
import {ReactElement, useState} from "react"
import {useTranslation} from "react-i18next"
import {useQuery} from "@tanstack/react-query"
import {APIKey, PaginationResult} from "~/server-types"
import {AxiosError} from "axios"
import {getAPIKeys} from "~/apis"
import {QueryResult, SimplePage} from "~/components"
import {Button, List} from "@mui/material"
import {MdAdd} from "react-icons/md"
import APIKeyListItem from "~/route-widgets/SettingsAPIKeysRoute/APIKeyListItem"
import CreateNewAPIKeyDialog from "../route-widgets/SettingsAPIKeysRoute/CreateNewAPIKeyDialog"
import EmptyStateScreen from "~/route-widgets/SettingsAPIKeysRoute/EmptyStateScreen"
export default function SettingsAPIKeysRoute(): ReactElement {
const {t} = useTranslation("settings-api-keys")
const queryKey = ["get_api_keys"]
const query = useQuery<PaginationResult<APIKey>, AxiosError>(queryKey, () => getAPIKeys())
const [createdAPIKey, setCreatedAPIKey] = useState<(APIKey & {key: string}) | null>(null)
const [createNew, setCreateNew] = useState<boolean>(false)
return (
<>
<SimplePage
title={t("title")}
actions={
<Button
variant="contained"
color="primary"
onClick={() => setCreateNew(true)}
startIcon={<MdAdd />}
>
{t("create.label")}
</Button>
}
>
<QueryResult<PaginationResult<APIKey>, AxiosError> query={query}>
{({items: apiKeys}) =>
apiKeys.length > 0 ? (
<List>
{apiKeys.map(apiKey => (
<APIKeyListItem
apiKey={apiKey}
key={apiKey.id}
queryKey={queryKey}
privateKey={
apiKey.id === createdAPIKey?.id
? createdAPIKey.key
: undefined
}
/>
))}
</List>
) : (
<EmptyStateScreen />
)
}
</QueryResult>
</SimplePage>
<CreateNewAPIKeyDialog
key={createNew.toString()}
open={createNew}
onClose={() => setCreateNew(false)}
onCreated={key => {
query.refetch()
setCreatedAPIKey(key)
}}
/>
</>
)
}

View File

@ -1,6 +1,7 @@
import {useTranslation} from "react-i18next" import {useTranslation} from "react-i18next"
import {GoSettings} from "react-icons/go" import {GoSettings} from "react-icons/go"
import {BsShieldLockFill} from "react-icons/bs" import {BsShieldLockFill} from "react-icons/bs"
import {MdVpnKey} from "react-icons/md"
import {Link} from "react-router-dom" import {Link} from "react-router-dom"
import React, {ReactElement} from "react" import React, {ReactElement} from "react"
@ -26,6 +27,12 @@ export default function SettingsRoute(): ReactElement {
</ListItemIcon> </ListItemIcon>
<ListItemText primary={t("actions.enable2fa")} /> <ListItemText primary={t("actions.enable2fa")} />
</ListItemButton> </ListItemButton>
<ListItemButton component={Link} to="/settings/api-keys">
<ListItemIcon>
<MdVpnKey />
</ListItemIcon>
<ListItemText primary={t("actions.apiKeys")} />
</ListItemButton>
</List> </List>
</SimplePageBuilder.Page> </SimplePageBuilder.Page>
) )

View File

@ -84,6 +84,7 @@ export interface ServerSettings {
instanceSalt: string instanceSalt: string
publicKey: string publicKey: string
allowAliasDeletion: boolean allowAliasDeletion: boolean
apiKeyMaxDays: number
} }
export interface Alias { export interface Alias {
@ -141,6 +142,25 @@ export interface AliasList {
type: AliasType type: AliasType
} }
export type APIKeyScope =
| "full_profile"
| "basic_profile"
| "read:preferences"
| "update:preferences"
| "read:alias"
| "create:alias"
| "update:alias"
| "delete:alias"
| "read:report"
| "delete:report"
export interface APIKey {
id: string
label: string
expiresAt: Date
scopes: APIKeyScope[]
}
export interface Report { export interface Report {
id: string id: string
encryptedContent: string encryptedContent: string

View File

@ -464,6 +464,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.10" regenerator-runtime "^0.13.10"
"@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0":
version "7.21.0"
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/template@^7.18.10": "@babel/template@^7.18.10":
version "7.18.10" version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
@ -562,6 +569,60 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@date-io/core@^2.16.0":
version "2.16.0"
resolved "https://registry.npmjs.org/@date-io/core/-/core-2.16.0.tgz#7871bfc1d9bca9aa35ad444a239505589d0f22f6"
integrity sha512-DYmSzkr+jToahwWrsiRA2/pzMEtz9Bq1euJwoOuYwuwIYXnZFtHajY2E6a1VNVDc9jP8YUXK1BvnZH9mmT19Zg==
"@date-io/date-fns-jalali@^2.16.0":
version "2.16.0"
resolved "https://registry.npmjs.org/@date-io/date-fns-jalali/-/date-fns-jalali-2.16.0.tgz#0e2916c287e00d708a6f9f6d6e6c44ec377994c5"
integrity sha512-MNVvGYwRiBydbvY7gvZM14W2kosIG29G1Ekw5qmYWOXkIIFngh6ZvV7/uVGDCW+gqlIeSz/XitZXA9n8RO0tJw==
dependencies:
"@date-io/core" "^2.16.0"
"@date-io/date-fns@^2.16.0":
version "2.16.0"
resolved "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.16.0.tgz#bd5e09b6ecb47ee55e593fc3a87e7b2caaa3da40"
integrity sha512-bfm5FJjucqlrnQcXDVU5RD+nlGmL3iWgkHTq3uAZWVIuBu6dDmGa3m8a6zo2VQQpu8ambq9H22UyUpn7590joA==
dependencies:
"@date-io/core" "^2.16.0"
"@date-io/dayjs@^2.16.0":
version "2.16.0"
resolved "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.16.0.tgz#0d2c254ad8db1306fdc4b8eda197cb53c9af89dc"
integrity sha512-y5qKyX2j/HG3zMvIxTobYZRGnd1FUW2olZLS0vTj7bEkBQkjd2RO7/FEwDY03Z1geVGlXKnzIATEVBVaGzV4Iw==
dependencies:
"@date-io/core" "^2.16.0"
"@date-io/hijri@^2.16.1":
version "2.16.1"
resolved "https://registry.npmjs.org/@date-io/hijri/-/hijri-2.16.1.tgz#a5e7e5b875e0ac8719c235eaf51d4188f21ea193"
integrity sha512-6BxY0mtnqj5cBiXluRs3uWN0mSJwGw0AB2ZxqtEHvBFoiSYEojW51AETnfPIWpdvDsBn+WAC7QrfBvQZnoyIkQ==
dependencies:
"@date-io/moment" "^2.16.1"
"@date-io/jalaali@^2.16.1":
version "2.16.1"
resolved "https://registry.npmjs.org/@date-io/jalaali/-/jalaali-2.16.1.tgz#c53323fc429f8fe6ab205d36c003d6de071f17e8"
integrity sha512-GLw87G/WJ1DNrQHW8p/LqkqAqTUSqBSRin0H1pRPwCccB5Fh7GT64sadjzEvjW56lPJ0aq2vp5yI2eIjZajfrw==
dependencies:
"@date-io/moment" "^2.16.1"
"@date-io/luxon@^2.16.1":
version "2.16.1"
resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.16.1.tgz#b08786614cb58831c729a15807753011e4acb966"
integrity sha512-aeYp5K9PSHV28946pC+9UKUi/xMMYoaGelrpDibZSgHu2VWHXrr7zWLEr+pMPThSs5vt8Ei365PO+84pCm37WQ==
dependencies:
"@date-io/core" "^2.16.0"
"@date-io/moment@^2.16.1":
version "2.16.1"
resolved "https://registry.npmjs.org/@date-io/moment/-/moment-2.16.1.tgz#ec6e0daa486871e0e6412036c6f806842a0eeed4"
integrity sha512-JkxldQxUqZBfZtsaCcCMkm/dmytdyq5pS1RxshCQ4fHhsvP5A7gSqPD22QbVXMcJydi3d3v1Y8BQdUKEuGACZQ==
dependencies:
"@date-io/core" "^2.16.0"
"@emotion/babel-plugin@^11.10.0": "@emotion/babel-plugin@^11.10.0":
version "11.10.2" version "11.10.2"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.2.tgz#879db80ba622b3f6076917a1e6f648b1c7d008c7" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.2.tgz#879db80ba622b3f6076917a1e6f648b1c7d008c7"
@ -1077,6 +1138,37 @@
prop-types "^15.8.1" prop-types "^15.8.1"
react-is "^18.2.0" react-is "^18.2.0"
"@mui/utils@^5.11.7":
version "5.11.12"
resolved "https://registry.npmjs.org/@mui/utils/-/utils-5.11.12.tgz#627f491c0e7267398590af5e6cb14b306170d914"
integrity sha512-5vH9B/v8pzkpEPO2HvGM54ToXV6cFdAn8UrvdN8TMEEwpn/ycW0jLiyBcgUlPsQ+xha7hqXCPQYHaYFDIcwaiw==
dependencies:
"@babel/runtime" "^7.21.0"
"@types/prop-types" "^15.7.5"
"@types/react-is" "^16.7.1 || ^17.0.0"
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/x-date-pickers@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.0.1.tgz#855f71b0661602c105dfd75bd0d38cd8c40d59f5"
integrity sha512-eZ3uTWp2uA08h4IULoqI7rkP16ltzZH0kSdZdvLCvaa4ix1ZP5zGcvt8CdtxM+NFWjRS7fGWPOgOoScGw9lKLQ==
dependencies:
"@babel/runtime" "^7.20.13"
"@date-io/core" "^2.16.0"
"@date-io/date-fns" "^2.16.0"
"@date-io/date-fns-jalali" "^2.16.0"
"@date-io/dayjs" "^2.16.0"
"@date-io/hijri" "^2.16.1"
"@date-io/jalaali" "^2.16.1"
"@date-io/luxon" "^2.16.1"
"@date-io/moment" "^2.16.1"
"@mui/utils" "^5.11.7"
"@types/react-transition-group" "^4.4.5"
clsx "^1.2.1"
prop-types "^15.8.1"
react-transition-group "^4.4.5"
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@ -4774,6 +4866,11 @@ regenerator-runtime@^0.13.10, regenerator-runtime@^0.13.4:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee"
integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==
regenerator-runtime@^0.13.11:
version "0.13.11"
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
regexp.prototype.flags@^1.3.0, regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3: regexp.prototype.flags@^1.3.0, regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3:
version "1.4.3" version "1.4.3"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"