mirror of
https://github.com/Myzel394/kleckrelay-website.git
synced 2025-06-18 23:45:26 +02:00
commit
899208986f
@ -20,6 +20,7 @@
|
||||
"@emotion/styled": "^11.10.4",
|
||||
"@mui/lab": "^5.0.0-alpha.103",
|
||||
"@mui/material": "^5.10.9",
|
||||
"@mui/x-date-pickers": "^6.0.1",
|
||||
"@originjs/vite-plugin-commonjs": "^1.0.3",
|
||||
"@tanstack/react-query": "^4.12.0",
|
||||
"axios": "^1.1.2",
|
||||
|
@ -59,6 +59,10 @@
|
||||
},
|
||||
"report": {
|
||||
"deleted": "Report has been deleted!"
|
||||
},
|
||||
"apiKey": {
|
||||
"keyCopied": "API key has been copied to your clipboard!",
|
||||
"deleted": "API key has been deleted!"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
@ -89,5 +93,22 @@
|
||||
"signup": "Sign up",
|
||||
"login": "Log in",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
42
public/locales/en-US/settings-api-keys.json
Normal file
42
public/locales/en-US/settings-api-keys.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
"title": "Settings",
|
||||
"actions": {
|
||||
"enable2fa": "Two-Factor-Authentication",
|
||||
"aliasPreferences": "Alias Preferences"
|
||||
"aliasPreferences": "Alias Preferences",
|
||||
"apiKeys": "Manage API Keys"
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
"mailVerification": {
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Edit email address?",
|
||||
"description": "Would you like to return to the previous step and edit your email address?",
|
||||
|
16
src/App.tsx
16
src/App.tsx
@ -7,6 +7,8 @@ import React, {ReactElement} from "react"
|
||||
import {queryClient} from "~/constants/react-query"
|
||||
import {getServerSettings} from "~/apis"
|
||||
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 AliasDetailRoute from "~/routes/AliasDetailRoute"
|
||||
import AliasesRoute from "~/routes/AliasesRoute"
|
||||
@ -28,6 +30,7 @@ import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute"
|
||||
import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute"
|
||||
import RootRoute from "~/routes/Root"
|
||||
import Settings2FARoute from "~/routes/Settings2FARoute"
|
||||
import SettingsAPIKeysRoute from "~/routes/SettingsAPIKeysRoute"
|
||||
import SettingsAliasPreferencesRoute from "~/routes/SettingsAliasPreferencesRoute"
|
||||
import SettingsRoute from "~/routes/SettingsRoute"
|
||||
import SignupRoute from "~/routes/SignupRoute"
|
||||
@ -105,6 +108,11 @@ const router = createBrowserRouter([
|
||||
path: "/settings/2fa",
|
||||
element: <Settings2FARoute />,
|
||||
},
|
||||
{
|
||||
path: "/settings/api-keys",
|
||||
loader: getServerSettings,
|
||||
element: <SettingsAPIKeysRoute />,
|
||||
},
|
||||
{
|
||||
path: "/reports",
|
||||
loader: getServerSettings,
|
||||
@ -162,9 +170,11 @@ export default function App(): ReactElement {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={THEME_THEME_MAP[theme]}>
|
||||
<SnackbarProvider>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
<I18nHandler />
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
<I18nHandler />
|
||||
</LocalizationProvider>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
35
src/apis/create-api-key.ts
Normal file
35
src/apis/create-api-key.ts
Normal 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}
|
||||
}
|
10
src/apis/delete-api-key.ts
Normal file
10
src/apis/delete-api-key.ts
Normal 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
22
src/apis/get-api-keys.ts
Normal 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),
|
||||
}
|
||||
}
|
8
src/apis/helpers/parse-api-key.ts
Normal file
8
src/apis/helpers/parse-api-key.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import {APIKey} from "~/server-types"
|
||||
|
||||
export default function parseAPIKey(key: APIKey): APIKey {
|
||||
return {
|
||||
...key,
|
||||
expiresAt: new Date(key.expiresAt),
|
||||
}
|
||||
}
|
@ -68,3 +68,9 @@ export * from "./delete-2fa"
|
||||
export {default as delete2FA} from "./delete-2fa"
|
||||
export * 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"
|
||||
|
@ -25,6 +25,8 @@ export interface DeleteAPIButtonProps {
|
||||
description?: string
|
||||
successMessage?: string
|
||||
navigateTo?: string
|
||||
children?: (onDelete: () => void) => ReactElement
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
export default function DeleteAPIButton({
|
||||
@ -33,7 +35,9 @@ export default function DeleteAPIButton({
|
||||
label,
|
||||
continueLabel,
|
||||
description,
|
||||
navigateTo = "/aliases",
|
||||
navigateTo,
|
||||
onDone,
|
||||
children: render,
|
||||
}: DeleteAPIButtonProps): ReactElement {
|
||||
const {t} = useTranslation("common")
|
||||
const {showError, showSuccess} = useErrorSuccessSnacks()
|
||||
@ -43,7 +47,12 @@ export default function DeleteAPIButton({
|
||||
onError: showError,
|
||||
onSuccess: () => {
|
||||
showSuccess(successMessage || t("messages.deletedObject"))
|
||||
navigate(navigateTo)
|
||||
|
||||
if (navigateTo) {
|
||||
navigate(navigateTo)
|
||||
} else if (onDone) {
|
||||
onDone()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -51,15 +60,19 @@ export default function DeleteAPIButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<MdDelete />}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{render ? (
|
||||
render(() => setShowDeleteDialog(true))
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<MdDelete />}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogContent>
|
||||
|
@ -16,3 +16,18 @@ export const DEFAULT_ALIAS_NOTE: AliasNote = {
|
||||
export const ERROR_SNACKBAR_SHOW_DURATION = 5000
|
||||
export const SUCCESS_SNACKBAR_SHOW_DURATION = 2000
|
||||
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",
|
||||
]
|
||||
|
75
src/route-widgets/SettingsAPIKeysRoute/APIKeyListItem.tsx
Normal file
75
src/route-widgets/SettingsAPIKeysRoute/APIKeyListItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
311
src/route-widgets/SettingsAPIKeysRoute/CreateNewAPIKeyDialog.tsx
Normal file
311
src/route-widgets/SettingsAPIKeysRoute/CreateNewAPIKeyDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
35
src/route-widgets/SettingsAPIKeysRoute/EmptyStateScreen.tsx
Normal file
35
src/route-widgets/SettingsAPIKeysRoute/EmptyStateScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
71
src/routes/SettingsAPIKeysRoute.tsx
Normal file
71
src/routes/SettingsAPIKeysRoute.tsx
Normal 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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import {useTranslation} from "react-i18next"
|
||||
import {GoSettings} from "react-icons/go"
|
||||
import {BsShieldLockFill} from "react-icons/bs"
|
||||
import {MdVpnKey} from "react-icons/md"
|
||||
import {Link} from "react-router-dom"
|
||||
import React, {ReactElement} from "react"
|
||||
|
||||
@ -26,6 +27,12 @@ export default function SettingsRoute(): ReactElement {
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("actions.enable2fa")} />
|
||||
</ListItemButton>
|
||||
<ListItemButton component={Link} to="/settings/api-keys">
|
||||
<ListItemIcon>
|
||||
<MdVpnKey />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("actions.apiKeys")} />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</SimplePageBuilder.Page>
|
||||
)
|
||||
|
@ -84,6 +84,7 @@ export interface ServerSettings {
|
||||
instanceSalt: string
|
||||
publicKey: string
|
||||
allowAliasDeletion: boolean
|
||||
apiKeyMaxDays: number
|
||||
}
|
||||
|
||||
export interface Alias {
|
||||
@ -141,6 +142,25 @@ export interface AliasList {
|
||||
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 {
|
||||
id: string
|
||||
encryptedContent: string
|
||||
|
97
yarn.lock
97
yarn.lock
@ -464,6 +464,13 @@
|
||||
dependencies:
|
||||
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":
|
||||
version "7.18.10"
|
||||
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"
|
||||
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":
|
||||
version "11.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.2.tgz#879db80ba622b3f6076917a1e6f648b1c7d008c7"
|
||||
@ -1077,6 +1138,37 @@
|
||||
prop-types "^15.8.1"
|
||||
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":
|
||||
version "2.1.5"
|
||||
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"
|
||||
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:
|
||||
version "1.4.3"
|
||||
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
|
||||
|
Loading…
x
Reference in New Issue
Block a user