Skip to content

Commit

Permalink
feat(api-keys): add api keys page (#360)
Browse files Browse the repository at this point in the history
* 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
balzdur authored Feb 7, 2024
1 parent 075d581 commit fee614b
Show file tree
Hide file tree
Showing 27 changed files with 694 additions and 63 deletions.
2 changes: 1 addition & 1 deletion packages/app-builder/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/app-builder/public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
"users.inbox_user_role.member_count_other": "MEMBER in {{count}} inboxes",
"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 and store it in a secure place as you will not be able to see this again.",
"api_keys.description": "Description",
"api_keys.role": "API key role",
"api_keys.role.api_client": "API_CLIENT",
"api_keys.role.unknown": "UNKNOWN",
"api_keys.create": "Create new API Key",
"api_keys.delete": "Delete API Key",
"api_keys.delete.content": "You are about to revoke this API key. This action is irreversible, do you want to proceed?",
"case_manager": "Case Manager",
"inboxes": "Inboxes",
"inboxes.new_inbox": "New inbox",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down
43 changes: 43 additions & 0 deletions packages/app-builder/src/models/api-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type ApiKeyDto, type CreatedApiKeyDto } from 'marble-api';
import { assertNever } from 'typescript-utils';

export const apiKeyRoleOptions = ['API_CLIENT'] as const;
type ApiKeyRole = (typeof apiKeyRoleOptions)[number];

function isApiKeyRole(role: string): role is ApiKeyRole {
return apiKeyRoleOptions.includes(role as ApiKeyRole);
}

export interface ApiKey {
id: string;
organizationId: string;
description: string;
role: ApiKeyRole | 'UNKNWON';
}

export function adaptApiKey(apiKeyDto: ApiKeyDto): ApiKey {
const apiKey: ApiKey = {
id: apiKeyDto.id,
organizationId: apiKeyDto.organization_id,
description: apiKeyDto.description,
role: 'UNKNWON',
};
if (isApiKeyRole(apiKeyDto.role)) {
apiKey.role = apiKeyDto.role;
} else {
// @ts-expect-error should be unreachable if all roles are handled
assertNever('[ApiKeyDto] Unknown role', apiKeyDto.role);
}
return apiKey;
}

export type CreatedApiKey = ApiKey & {
key: string;
};

export function adaptCreatedApiKey(apiKey: CreatedApiKeyDto): CreatedApiKey {
return {
...adaptApiKey(apiKey),
key: apiKey.key,
};
}
2 changes: 2 additions & 0 deletions packages/app-builder/src/models/marble-session.ts
Original file line number Diff line number Diff line change
@@ -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>;
37 changes: 37 additions & 0 deletions packages/app-builder/src/repositories/ApiKeyRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type MarbleApi } from '@app-builder/infra/marble-api';
import {
adaptApiKey,
adaptCreatedApiKey,
type ApiKey,
type CreatedApiKey,
} from '@app-builder/models/api-keys';

export interface ApiKeyRepository {
listApiKeys(): Promise<ApiKey[]>;
createApiKey(args: {
description: string;
role: string;
}): Promise<CreatedApiKey>;
deleteApiKey(args: { apiKeyId: string }): Promise<void>;
}

export function getApiKeyRepository() {
return (marbleApiClient: MarbleApi): ApiKeyRepository => ({
listApiKeys: async () => {
const { api_keys } = await marbleApiClient.listApiKeys();

return api_keys.map(adaptApiKey);
},
createApiKey: async ({ description, role }) => {
const { api_key } = await marbleApiClient.createApiKey({
description,
role,
});

return adaptCreatedApiKey(api_key);
},
deleteApiKey: async ({ apiKeyId }) => {
await marbleApiClient.deleteApiKey(apiKeyId);
},
});
}
2 changes: 2 additions & 0 deletions packages/app-builder/src/repositories/init.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type GetMarbleAPIClient } from '@app-builder/infra/marble-api';

import { getApiKeyRepository } from './ApiKeyRepository';
import { getCaseRepository } from './CaseRepository';
import { getDataModelRepository } from './DataModelRepository';
import { getDecisionRepository } from './DecisionRepository';
Expand Down Expand Up @@ -40,6 +41,7 @@ export function makeServerRepositories({
scenarioRepository: getScenarioRepository(),
organizationRepository: getOrganizationRepository(),
dataModelRepository: getDataModelRepository(),
apiKeyRepository: getApiKeyRepository(),
};
}

Expand Down
12 changes: 6 additions & 6 deletions packages/app-builder/src/routes/_builder+/settings+/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ export default function Settings() {
to={getRoute('/settings/users')}
/>
</li>
{/* <li>
<SettingsNavLink
text={t('settings:api_keys')}
to={getRoute('/settings/api-keys')}
/>
</li> */}
<li>
<SettingsNavLink
text={t('settings:api_keys')}
to={getRoute('/settings/api-keys')}
/>
</li>
</ul>
</SettingsNavSection>
<SettingsNavSection
Expand Down
134 changes: 133 additions & 1 deletion packages/app-builder/src/routes/_builder+/settings+/api-keys.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,135 @@
import {
Callout,
CollapsiblePaper,
CopyToClipboardButton,
Page,
} from '@app-builder/components';
import { isAdmin } from '@app-builder/models';
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';
import { serverServices } from '@app-builder/services/init.server';
import { getRoute } from '@app-builder/utils/routes';
import { json, type LoaderFunctionArgs, redirect } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { createColumnHelper, getCoreRowModel } from '@tanstack/react-table';
import clsx from 'clsx';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, useTable } from 'ui-design-system';

export async function loader({ request }: LoaderFunctionArgs) {
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();

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() {
return <div>ApiKeys</div>;
const { t } = useTranslation(['settings']);
const { apiKeys, createdApiKey } = useLoaderData<typeof loader>();

const columns = useMemo(() => {
return [
columnHelper.accessor((row) => row.description, {
id: 'description',
header: t('settings:api_keys.description'),
size: 300,
}),
columnHelper.accessor((row) => row.role, {
id: 'role',
header: t('settings:api_keys.role'),
size: 150,
cell: ({ getValue }) => t(tKeyForApiKeyRole(getValue())),
}),
columnHelper.display({
id: 'actions',
size: 100,
cell: ({ cell }) => {
return (
<div className="text-grey-00 group-hover:text-grey-100 flex gap-2">
<DeleteApiKey apiKey={cell.row.original} />
</div>
);
},
}),
];
}, [t]);

const { table, getBodyProps, rows, getContainerProps } = useTable({
data: apiKeys,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
});

return (
<Page.Container>
<Page.Content>
{createdApiKey ? <CreatedAPIKey createdApiKey={createdApiKey} /> : null}
<CollapsiblePaper.Container>
<CollapsiblePaper.Title>
<span className="flex-1">{t('settings:tags')}</span>
<CreateApiKey />
</CollapsiblePaper.Title>
<CollapsiblePaper.Content>
<Table.Container {...getContainerProps()} className="max-h-96">
<Table.Header headerGroups={table.getHeaderGroups()} />
<Table.Body {...getBodyProps()}>
{rows.map((row) => {
return (
<Table.Row
key={row.id}
tabIndex={0}
className={clsx('hover:bg-grey-02 cursor-pointer')}
row={row}
/>
);
})}
</Table.Body>
</Table.Container>
</CollapsiblePaper.Content>
</CollapsiblePaper.Container>
</Page.Content>
</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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function useRefreshToken() {
.authenticationClientRepository;

firebaseIdToken().then(
(idToken) => {
(idToken: string) => {
submit(
{ idToken, csrf },
{ method: 'POST', action: getRoute('/ressources/auth/refresh') },
Expand Down
Loading

0 comments on commit fee614b

Please sign in to comment.