Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api-keys): add api keys page (#360)
* feat(api): add api keys endpoints * feat(api-keys): add ApiKeyRepository and model * feat(api-keys): add api keys page * feat(api-keys): display created api key on api keys page diff --git a/packages/app-builder/public/locales/en/common.json b/packages/app-builder/public/locales/en/common.json index a9a5cc4f..7dc96052 100644 --- a/packages/app-builder/public/locales/en/common.json +++ b/packages/app-builder/public/locales/en/common.json @@ -23,7 +23,7 @@ "delete": "Delete", "close": "Close", "clipboard.aria-label": "Copy to clipboard: {{value}}", - "clipboard.copy": "Copied in clipboard: {{value}}", + "clipboard.copy": "Copied in clipboard: <Value>{{value}}</Value>", "empty_scenario_iteration_list": "You don't have any rules. Click on create rule to make a new one", "success.save": "Saved successfully", "success.add_to_case": "Decision successfully added to case", diff --git a/packages/app-builder/public/locales/en/settings.json b/packages/app-builder/public/locales/en/settings.json index 3e1266c5..a39e6f4f 100644 --- a/packages/app-builder/public/locales/en/settings.json +++ b/packages/app-builder/public/locales/en/settings.json @@ -24,6 +24,7 @@ "users.inbox_user_role.unknown": "unknown", "api_keys": "API Keys", "api_keys.new_api_key": "New API Key", + "api_keys.copy_api_key": "Make sure to copy it now as you will not be able to see this again.", "api_keys.description": "Description", "api_keys.role": "API key role", "api_keys.role.admin": "ADMIN", diff --git a/packages/app-builder/src/components/CopyToClipboardButton.tsx b/packages/app-builder/src/components/CopyToClipboardButton.tsx index 193486d6..f2ae451e 100644 --- a/packages/app-builder/src/components/CopyToClipboardButton.tsx +++ b/packages/app-builder/src/components/CopyToClipboardButton.tsx @@ -12,7 +12,7 @@ export const CopyToClipboardButton = forwardRef< return ( <div ref={ref} - className="border-grey-10 text-grey-100 flex h-8 cursor-pointer select-none items-center gap-3 rounded border px-2 font-normal" + className="border-grey-10 text-grey-100 flex min-h-8 cursor-pointer select-none items-center gap-3 break-all rounded border px-2 font-normal" {...getCopyToClipboardProps(toCopy)} {...props} > diff --git a/packages/app-builder/src/models/marble-session.ts b/packages/app-builder/src/models/marble-session.ts index bfef0bb6..ea0131e6 100644 --- a/packages/app-builder/src/models/marble-session.ts +++ b/packages/app-builder/src/models/marble-session.ts @@ -1,11 +1,13 @@ import { type Session } from '@remix-run/node'; import { type Token } from 'marble-api'; +import { type CreatedApiKey } from './api-keys'; import { type AuthErrors } from './auth-errors'; import { type CurrentUser } from './user'; export type AuthData = { authToken: Token; lng: string; user: CurrentUser }; export type AuthFlashData = { authError: { message: AuthErrors }; + createdApiKey: CreatedApiKey; }; export type AuthSession = Session<AuthData, AuthFlashData>; diff --git a/packages/app-builder/src/routes/_builder+/settings+/api-keys.tsx b/packages/app-builder/src/routes/_builder+/settings+/api-keys.tsx index e95e0095..58c58616 100644 --- a/packages/app-builder/src/routes/_builder+/settings+/api-keys.tsx +++ b/packages/app-builder/src/routes/_builder+/settings+/api-keys.tsx @@ -1,6 +1,11 @@ -import { CollapsiblePaper, Page } from '@app-builder/components'; +import { + Callout, + CollapsiblePaper, + CopyToClipboardButton, + Page, +} from '@app-builder/components'; import { isAdmin } from '@app-builder/models'; -import { type ApiKey } from '@app-builder/models/api-keys'; +import { type ApiKey, type CreatedApiKey } from '@app-builder/models/api-keys'; import { CreateApiKey } from '@app-builder/routes/ressources+/settings+/api-keys+/create'; import { DeleteApiKey } from '@app-builder/routes/ressources+/settings+/api-keys+/delete'; import { tKeyForApiKeyRole } from '@app-builder/services/i18n/translation-keys/api-key'; @@ -15,24 +20,38 @@ import { useTranslation } from 'react-i18next'; import { Table, useTable } from 'ui-design-system'; export async function loader({ request }: LoaderFunctionArgs) { - const { authService } = serverServices; + const { authService, authSessionService } = serverServices; const { apiKey, user } = await authService.isAuthenticated(request, { failureRedirect: getRoute('/sign-in'), }); if (!isAdmin(user)) { return redirect(getRoute('/')); } - const apiKeys = await apiKey.listApiKeys(); - return json({ apiKeys }); + const authSession = await authSessionService.getSession(request); + const createdApiKey = authSession.get('createdApiKey'); + const headers = new Headers(); + if (createdApiKey) { + headers.set( + 'Set-Cookie', + await authSessionService.commitSession(authSession), + ); + } + + return json( + { apiKeys, createdApiKey }, + { + headers, + }, + ); } const columnHelper = createColumnHelper<ApiKey>(); export default function ApiKeys() { const { t } = useTranslation(['settings']); - const { apiKeys } = useLoaderData<typeof loader>(); + const { apiKeys, createdApiKey } = useLoaderData<typeof loader>(); const columns = useMemo(() => { return [ @@ -71,6 +90,7 @@ export default function ApiKeys() { return ( <Page.Container> <Page.Content> + {createdApiKey ? <CreatedAPIKey createdApiKey={createdApiKey} /> : null} <CollapsiblePaper.Container> <CollapsiblePaper.Title> <span className="flex-1">{t('settings:tags')}</span> @@ -98,3 +118,18 @@ export default function ApiKeys() { </Page.Container> ); } + +function CreatedAPIKey({ createdApiKey }: { createdApiKey: CreatedApiKey }) { + const { t } = useTranslation(['settings']); + return ( + <Callout variant="outlined"> + <div className="flex flex-col gap-1"> + <span className="font-bold">{t('settings:api_keys.new_api_key')}</span> + <span>{t('settings:api_keys.copy_api_key')}</span> + <CopyToClipboardButton toCopy={createdApiKey.key}> + <span className="text-s font-semibold">{createdApiKey.key}</span> + </CopyToClipboardButton> + </div> + </Callout> + ); +} diff --git a/packages/app-builder/src/routes/ressources+/auth+/refresh.tsx b/packages/app-builder/src/routes/ressources+/auth+/refresh.tsx index 1c3a1366..284a66f6 100644 --- a/packages/app-builder/src/routes/ressources+/auth+/refresh.tsx +++ b/packages/app-builder/src/routes/ressources+/auth+/refresh.tsx @@ -34,7 +34,7 @@ export function useRefreshToken() { .authenticationClientRepository; firebaseIdToken().then( - (idToken) => { + (idToken: string) => { submit( { idToken, csrf }, { method: 'POST', action: getRoute('/ressources/auth/refresh') }, diff --git a/packages/app-builder/src/routes/ressources+/settings+/api-keys+/create.tsx b/packages/app-builder/src/routes/ressources+/settings+/api-keys+/create.tsx index 2ff36092..de5d3a9a 100644 --- a/packages/app-builder/src/routes/ressources+/settings+/api-keys+/create.tsx +++ b/packages/app-builder/src/routes/ressources+/settings+/api-keys+/create.tsx @@ -14,6 +14,7 @@ import { type ActionFunctionArgs, json, redirect } from '@remix-run/node'; import { useFetcher, useNavigation } from '@remix-run/react'; import { useEffect, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { AuthenticityTokenInput } from 'remix-utils/csrf/react'; import { Button, Modal } from 'ui-design-system'; import { Icon } from 'ui-icons'; import { z } from 'zod'; @@ -24,13 +25,13 @@ const createApiKeyFormSchema = z.object({ }); export async function action({ request }: ActionFunctionArgs) { - const { - authService, - toastSessionService: { getSession, commitSession }, - } = serverServices; + const { authService, csrfService, toastSessionService, authSessionService } = + serverServices; const { apiKey } = await authService.isAuthenticated(request, { failureRedirect: getRoute('/sign-in'), }); + await csrfService.validate(request); + const formData = await request.formData(); const submission = parse(formData, { schema: createApiKeyFormSchema }); @@ -38,19 +39,27 @@ export async function action({ request }: ActionFunctionArgs) { return json(submission); } - const session = await getSession(request); - try { - //TODO(apikey): find a way to display created api key - await apiKey.createApiKey(submission.value); - return redirect(getRoute('/settings/api-keys')); + const createdApiKey = await apiKey.createApiKey(submission.value); + + const authSession = await authSessionService.getSession(request); + authSession.flash('createdApiKey', createdApiKey); + + return redirect(getRoute('/settings/api-keys'), { + headers: { + 'Set-Cookie': await authSessionService.commitSession(authSession), + }, + }); } catch (error) { - setToastMessage(session, { + const toastSession = await toastSessionService.getSession(request); + setToastMessage(toastSession, { type: 'error', messageKey: 'common:errors.unknown', }); return json(submission, { - headers: { 'Set-Cookie': await commitSession(session) }, + headers: { + 'Set-Cookie': await toastSessionService.commitSession(toastSession), + }, }); } } @@ -106,6 +115,7 @@ const CreateApiKeyContent = () => { > <Modal.Title>{t('settings:api_keys.new_api_key')}</Modal.Title> <div className="bg-grey-00 flex flex-col gap-6 p-6"> + <AuthenticityTokenInput /> <FormField config={description} className="group flex flex-col gap-2"> <FormLabel>{t('settings:api_keys.description')}</FormLabel> <FormInput type="text" /> diff --git a/packages/app-builder/src/utils/use-get-copy-to-clipboard.ts b/packages/app-builder/src/utils/use-get-copy-to-clipboard.ts deleted file mode 100644 index 1125c52f..00000000 --- a/packages/app-builder/src/utils/use-get-copy-to-clipboard.ts +++ /dev/null @@ -1,28 +0,0 @@ -import toast from 'react-hot-toast'; -import { useTranslation } from 'react-i18next'; - -export function useGetCopyToClipboard() { - const { t } = useTranslation('common'); - return (value: string) => ({ - type: 'button', - 'aria-label': t('clipboard.aria-label', { - replace: { - value, - }, - }), - onClick: async () => { - try { - await navigator.clipboard.writeText(value); - toast.success( - t('clipboard.copy', { - replace: { - value, - }, - }), - ); - } catch (err) { - toast.error(t('errors.unknown')); - } - }, - }); -} diff --git a/packages/app-builder/src/utils/use-get-copy-to-clipboard.tsx b/packages/app-builder/src/utils/use-get-copy-to-clipboard.tsx new file mode 100644 index 00000000..4d343993 --- /dev/null +++ b/packages/app-builder/src/utils/use-get-copy-to-clipboard.tsx @@ -0,0 +1,37 @@ +import toast from 'react-hot-toast'; +import { Trans, useTranslation } from 'react-i18next'; + +export function useGetCopyToClipboard() { + const { t } = useTranslation('common'); + return (value: string) => ({ + type: 'button', + 'aria-label': t('clipboard.aria-label', { + replace: { + value, + }, + }), + onClick: async () => { + try { + await navigator.clipboard.writeText(value); + toast.success(() => ( + <span className="text-s text-grey-100 font-normal first-letter:capitalize"> + <Trans + t={t} + i18nKey="clipboard.copy" + components={{ + Value: ( + <span className="text-s text-grey-100 whitespace-pre-wrap break-all font-semibold" /> + ), + }} + values={{ + value, + }} + /> + </span> + )); + } catch (err) { + toast.error(t('errors.unknown')); + } + }, + }); +} * feat(i18n): change some text in the settings page * fix(apikeys): only allow API_CLIENT role to create API keys
- Loading branch information