diff --git a/package.json b/package.json index 45423c9..02a2912 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en-US/common.json b/public/locales/en-US/common.json index ce207da..3fb6c83 100644 --- a/public/locales/en-US/common.json +++ b/public/locales/en-US/common.json @@ -59,6 +59,9 @@ }, "report": { "deleted": "Report has been deleted!" + }, + "apiKey": { + "keyCopied": "API key has been copied to your clipboard!" } }, "general": { @@ -89,5 +92,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" + } } } diff --git a/public/locales/en-US/settings-api-keys.json b/public/locales/en-US/settings-api-keys.json index fe1a5d7..e2e5623 100644 --- a/public/locales/en-US/settings-api-keys.json +++ b/public/locales/en-US/settings-api-keys.json @@ -1,7 +1,32 @@ { "title": "Manage your API Keys", "create": { - "label": "Create a new API Key" + "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", diff --git a/src/App.tsx b/src/App.tsx index c58c86c..48a0768 100644 --- a/src/App.tsx +++ b/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" @@ -108,6 +110,7 @@ const router = createBrowserRouter([ }, { path: "/settings/api-keys", + loader: getServerSettings, element: , }, { @@ -167,9 +170,11 @@ export default function App(): ReactElement { - - - + + + + + diff --git a/src/apis/create-api-key.ts b/src/apis/create-api-key.ts index e44558f..ac7582c 100644 --- a/src/apis/create-api-key.ts +++ b/src/apis/create-api-key.ts @@ -1,5 +1,6 @@ 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 { @@ -13,12 +14,22 @@ export default async function createAPIKey({ label, scopes, expiresAt, -}: CreateAPIKeyData): Promise { - const {data} = await client.post(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/api-key`, { - label, - scopes, - expiresAt, - }) +}: CreateAPIKeyData): Promise { + 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) + return parseAPIKey(data) as APIKey & {key: string} } diff --git a/src/constants/values.ts b/src/constants/values.ts index 430804c..453e7a5 100644 --- a/src/constants/values.ts +++ b/src/constants/values.ts @@ -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", +] diff --git a/src/route-widgets/SettingsAPIKeysRoute/APIKeyListItem.tsx b/src/route-widgets/SettingsAPIKeysRoute/APIKeyListItem.tsx index 93e9783..c410a51 100644 --- a/src/route-widgets/SettingsAPIKeysRoute/APIKeyListItem.tsx +++ b/src/route-widgets/SettingsAPIKeysRoute/APIKeyListItem.tsx @@ -1,24 +1,53 @@ import {ReactElement} from "react" import {APIKey} from "~/server-types" -import {IconButton, ListItem, ListItemSecondaryAction, ListItemText} from "@mui/material" +import {Alert, IconButton, ListItem, ListItemSecondaryAction, ListItemText} from "@mui/material" import {useTranslation} from "react-i18next" -import {MdDelete} from "react-icons/md" +import {MdContentCopy, MdDelete} from "react-icons/md" +import {useCopyToClipboard} from "react-use" +import {ErrorSnack, SuccessSnack} from "~/components" export interface APIKeyListItemProps { apiKey: APIKey + privateKey?: string } -export default function APIKeyListItem({apiKey}: APIKeyListItemProps): ReactElement { - const {t} = useTranslation("settings-api-keys") +export default function APIKeyListItem({apiKey, privateKey}: APIKeyListItemProps): ReactElement { + const {t} = useTranslation(["settings-api-keys", "common"]) + + const [{value, error}, copy] = useCopyToClipboard() + + if (privateKey) { + return ( + <> + + + {privateKey} + + copy(privateKey)}> + + + + + + + + + ) + } return ( - - - - - - + <> + + + + + + + ) } diff --git a/src/route-widgets/SettingsAPIKeysRoute/CreateNewAPIKeyDialog.tsx b/src/route-widgets/SettingsAPIKeysRoute/CreateNewAPIKeyDialog.tsx new file mode 100644 index 0000000..cdabcb5 --- /dev/null +++ b/src/route-widgets/SettingsAPIKeysRoute/CreateNewAPIKeyDialog.tsx @@ -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 = { + profile: , + alias: , + report: , + preferences: , +} + +const API_KEY_SCOPE_TYPE_ICON_MAP: Record = { + read: , + create: , + update: , + delete: , +} + +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() + .of(yup.string()) + .required() + .label(t("create.form.scopes.label")), + }) + + const {mutateAsync} = useMutation( + data => createAPIKey(data), + { + onSuccess: async key => { + onClose() + + showSuccess(t("create.success")) + + onCreated(key) + }, + }, + ) + const formik = useFormik({ + 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 ( + + {t("create.label")} +
+ + {t("create.description")} + + + + + + ), + }} + /> + + + + + {t("create.form.scopes.label")} + + + 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[]) => ( + + {selected.map(value => ( + + ))} + + )} + > + {API_KEY_SCOPES.map(scope => ( + + + + + { + API_KEY_SCOPE_ICON_MAP[ + scope + .replace(":", "_") + .split("_")[1] + ] + } + + + + {t(`values.scopes.${scope.replace(":", "_")}`, { + ns: "common", + })} + + + + ))} + + + {formik.touched.scopes && formik.errors.scopes} + + + + + + + formik.setFieldValue("expiresAt", value)} + /> + + {formik.touched.expiresAt && formik.errors.expiresAt} + + + + + {PRESET_DAYS.filter( + days => days <= serverSettings.apiKeyMaxDays, + ).map(days => ( + + = days + ? "filled" + : "outlined" + } + onClick={() => + formik.setFieldValue( + "expiresAt", + addDays( + normalizeTime(new Date()), + days, + ), + ) + } + /> + + ))} + + + + + {formik.errors.detail && ( + + {formik.errors.detail} + + )} + + + + + + +
+
+ ) +} diff --git a/src/routes/SettingsAPIKeysRoute.tsx b/src/routes/SettingsAPIKeysRoute.tsx index 82d9ab8..684e1f5 100644 --- a/src/routes/SettingsAPIKeysRoute.tsx +++ b/src/routes/SettingsAPIKeysRoute.tsx @@ -1,4 +1,4 @@ -import {ReactElement} from "react" +import {ReactElement, useState} from "react" import {useTranslation} from "react-i18next" import {useQuery} from "@tanstack/react-query" import {APIKey, PaginationResult} from "~/server-types" @@ -6,43 +6,65 @@ import {AxiosError} from "axios" import {getAPIKeys} from "~/apis" import {QueryResult, SimplePage} from "~/components" import {Button, List} from "@mui/material" -import {Link} from "react-router-dom" +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 query = useQuery, AxiosError>(["get_api_keys"], () => - getAPIKeys(), - ) + const queryKey = ["get_api_keys"] + const query = useQuery, AxiosError>(queryKey, () => getAPIKeys()) + + const [createdAPIKey, setCreatedAPIKey] = useState<(APIKey & {key: string}) | null>(null) + const [createNew, setCreateNew] = useState(false) return ( - - {t("create.label")} - - } - > - , AxiosError> query={query}> - {({items: apiKeys}) => - apiKeys.length > 0 ? ( - - {apiKeys.map(apiKey => ( - - ))} - - ) : ( - - ) + <> + setCreateNew(true)} + startIcon={} + > + {t("create.label")} + } - - + > + , AxiosError> query={query}> + {({items: apiKeys}) => + apiKeys.length > 0 ? ( + + {apiKeys.map(apiKey => ( + + ))} + + ) : ( + + ) + } + + + setCreateNew(false)} + onCreated={key => { + query.refetch() + setCreatedAPIKey(key) + }} + /> + ) } diff --git a/src/server-types.ts b/src/server-types.ts index 6046098..a46d565 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -84,6 +84,7 @@ export interface ServerSettings { instanceSalt: string publicKey: string allowAliasDeletion: boolean + apiKeyMaxDays: number } export interface Alias { @@ -141,11 +142,23 @@ export interface AliasList { type: AliasType } +export type APIKeyScope = + | "profile_basic" + | "full_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: string[] + scopes: APIKeyScope[] } export interface Report { diff --git a/yarn.lock b/yarn.lock index 4d7337a..9763a1a 100644 --- a/yarn.lock +++ b/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"