enhancement: make api tokens viewable (#23439)

This commit is contained in:
Bassel Kanso 2025-05-05 11:06:33 +03:00 committed by GitHub
parent c8de96f027
commit 5989d3c7be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 357 additions and 63 deletions

View File

@ -14,6 +14,9 @@ module.exports = ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT', 'example-salt'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY', 'example-key'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -15,6 +15,7 @@ APP_KEYS=<%= appKeys %>
API_TOKEN_SALT=<%= apiTokenSalt %>
ADMIN_JWT_SECRET=<%= adminJwtToken %>
TRANSFER_TOKEN_SALT=<%= transferTokenSalt %>
ENCRYPTION_KEY=<%= encryptionKey %>
# Database
DATABASE_CLIENT=<%= database.client %>
@ -35,6 +36,7 @@ export function generateDotEnv(scope: Scope) {
apiTokenSalt: generateASecret(),
transferTokenSalt: generateASecret(),
adminJwtToken: generateASecret(),
encryptionKey: generateASecret(),
database: {
client: scope.database.client,
connection: {

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -10,6 +10,9 @@ module.exports = ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -10,6 +10,9 @@ export default ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -10,6 +10,9 @@ export default ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { Button, Dialog, Flex } from '@strapi/design-system';
import { Check, ArrowClockwise } from '@strapi/icons';
import { Button, Dialog, Flex, Tooltip } from '@strapi/design-system';
import { Check, ArrowClockwise, Eye, EyeStriked } from '@strapi/icons';
import { MessageDescriptor, useIntl } from 'react-intl';
import { ConfirmDialog } from '../../../../components/ConfirmDialog';
@ -117,7 +117,10 @@ interface FormHeadProps<TToken extends Token | null> {
token: TToken;
canEditInputs: boolean;
canRegenerate: boolean;
canShowToken?: boolean;
setToken: (token: TToken) => void;
toggleToken?: () => void;
showToken?: boolean;
isSubmitting: boolean;
regenerateUrl: string;
}
@ -126,6 +129,9 @@ export const FormHead = <TToken extends Token | null>({
title,
token,
setToken,
toggleToken,
showToken,
canShowToken,
canEditInputs,
canRegenerate,
isSubmitting,
@ -137,6 +143,7 @@ export const FormHead = <TToken extends Token | null>({
...token,
accessKey: newKey,
});
toggleToken?.();
};
return (
@ -151,6 +158,31 @@ export const FormHead = <TToken extends Token | null>({
url={`${regenerateUrl}${token?.id ?? ''}`}
/>
)}
{token?.id && toggleToken && (
<Tooltip
label={
!canShowToken &&
formatMessage({
id: 'Settings.tokens.encryptionKeyMissing',
defaultMessage:
'In order to view the token, you need a valid encryption key in the admin configuration',
})
}
>
<Button
type="button"
startIcon={showToken ? <EyeStriked /> : <Eye />}
variant="secondary"
onClick={() => toggleToken?.()}
disabled={!canShowToken}
>
{formatMessage({
id: 'Settings.tokens.viewToken',
defaultMessage: 'View token',
})}
</Button>
</Tooltip>
)}
<Button
disabled={isSubmitting}
loading={isSubmitting}

View File

@ -64,10 +64,18 @@ export const TokenBox = ({ token, tokenType }: TokenBoxProps) => {
}
subtitle={
token
? formatMessage({
id: 'Settings.tokens.copy.lastWarning',
defaultMessage: 'Make sure to copy this token, you wont be able to see it again!',
})
? formatMessage(
tokenType === 'api-token'
? {
id: 'Settings.tokens.copy.subtitle',
defaultMessage: 'Copy this token for use elsewhere',
}
: {
id: 'Settings.tokens.copy.lastWarning',
defaultMessage:
'Make sure to copy this token, you wont be able to see it again!',
}
)
: formatMessage({
id: 'Settings.tokens.copy.editMessage',
defaultMessage: 'For security reasons, you can only see your token once.',

View File

@ -51,6 +51,8 @@ export const EditView = () => {
}
: null
);
const [showToken, setShowToken] = React.useState(Boolean(locationState?.apiToken?.accessKey));
const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { trackUsage } = useTracking();
const setCurrentStep = useGuidedTour('EditView', (state) => state.setCurrentStep);
const {
@ -69,7 +71,6 @@ export const EditView = () => {
const contentAPIPermissionsQuery = useGetPermissionsQuery();
const contentAPIRoutesQuery = useGetRoutesQuery();
/**
* Separate effects otherwise we could end
* up duplicating the same notification.
@ -173,6 +174,23 @@ export const EditView = () => {
}
}, [data]);
React.useEffect(() => {
// Only set up timer when token is shown
if (showToken) {
hideTimerRef.current = setTimeout(() => {
setShowToken(false);
}, 30000); // 30 seconds
// Cleanup on unmount or when showToken changes
return () => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
}
}, [showToken]);
const [createToken] = useCreateAPITokenMutation();
const [updateToken] = useUpdateAPITokenMutation();
@ -304,6 +322,14 @@ export const EditView = () => {
});
};
const toggleToken = () => {
setShowToken((prev) => !prev);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
const providerValue = {
...state,
onChange: handleChangeCheckbox,
@ -312,6 +338,7 @@ export const EditView = () => {
};
const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating);
const canShowToken = !!apiToken?.accessKey;
if (isLoading) {
return <Page.Loading />;
@ -352,17 +379,21 @@ export const EditView = () => {
}}
token={apiToken}
setToken={setApiToken}
toggleToken={toggleToken}
showToken={showToken}
canEditInputs={canEditInputs}
canRegenerate={canRegenerate}
canShowToken={canShowToken}
isSubmitting={isSubmitting}
regenerateUrl="/admin/api-tokens/"
/>
<Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={6}>
{Boolean(apiToken?.name) && (
<TokenBox token={apiToken?.accessKey} tokenType={API_TOKEN_TYPE} />
{apiToken?.accessKey && showToken && (
<TokenBox token={apiToken.accessKey} tokenType={API_TOKEN_TYPE} />
)}
<FormApiTokenContainer
errors={errors}
onChange={handleChange}

View File

@ -263,6 +263,7 @@ const EditView = () => {
}}
token={transferToken}
setToken={setTransferToken}
canShowToken={false}
canEditInputs={canEditInputs}
canRegenerate={canRegenerate}
isSubmitting={isSubmitting}

View File

@ -47,6 +47,13 @@ export default {
required: true,
searchable: false,
},
encryptedKey: {
type: 'string',
minLength: 1,
configurable: false,
required: false,
searchable: false,
},
lastUsedAt: {
type: 'datetime',
configurable: false,

View File

@ -13,6 +13,7 @@ import {
update as apiTokenUpdate,
getByName,
} from '../api-token';
import encryptionService from '../encryption';
const getActionProvider = (actions = []) => {
return {
@ -26,6 +27,30 @@ describe('API Token', () => {
hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573',
};
const ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
const setupStrapiMock = (overrides = {}) => {
global.strapi = {
db: {
query: jest.fn(() => ({})),
},
config: {
get: jest.fn((key) => {
if (key === 'admin.secrets.encryptionKey') {
return ENCRYPTION_KEY;
}
return '';
}),
},
admin: {
services: {
encryption: encryptionService,
},
},
...overrides,
} as any;
};
let now: any;
let nowSpy: any;
@ -48,16 +73,13 @@ describe('API Token', () => {
test('Creates a new read-only token', async () => {
const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
setupStrapiMock({
db: {
query() {
return { create };
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const attributes = {
name: 'api-token_tests-name',
@ -72,6 +94,7 @@ describe('API Token', () => {
data: {
...attributes,
accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: null,
lifespan: null,
},
@ -80,6 +103,7 @@ describe('API Token', () => {
expect(res).toEqual({
...attributes,
accessKey: mockedApiToken.hexedString,
encryptedKey: expect.any(String),
expiresAt: null,
lifespan: null,
});
@ -96,16 +120,13 @@ describe('API Token', () => {
const expectedExpires = Date.now() + attributes.lifespan;
const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
setupStrapiMock({
db: {
query() {
return { create };
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const res = await apiTokenCreate(attributes);
@ -114,6 +135,7 @@ describe('API Token', () => {
data: {
...attributes,
accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: expectedExpires,
lifespan: attributes.lifespan,
},
@ -122,6 +144,7 @@ describe('API Token', () => {
expect(res).toEqual({
...attributes,
accessKey: mockedApiToken.hexedString,
encryptedKey: expect.any(String),
expiresAt: expectedExpires,
lifespan: attributes.lifespan,
});
@ -137,16 +160,13 @@ describe('API Token', () => {
} as any;
const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
setupStrapiMock({
db: {
query() {
return { create };
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
expect(async () => {
await apiTokenCreate(attributes);
@ -182,7 +202,7 @@ describe('API Token', () => {
)
);
global.strapi = {
setupStrapiMock({
...getActionProvider(['admin::content.content.read'] as any),
db: {
query() {
@ -193,10 +213,7 @@ describe('API Token', () => {
};
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const res = await apiTokenCreate(attributes);
@ -213,6 +230,7 @@ describe('API Token', () => {
data: {
...omit('permissions', attributes),
accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: null,
lifespan: null,
},
@ -265,7 +283,7 @@ describe('API Token', () => {
)
);
global.strapi = {
setupStrapiMock({
...getActionProvider(['admin::content.content.read'] as any),
db: {
query() {
@ -276,10 +294,7 @@ describe('API Token', () => {
};
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const res = await apiTokenCreate(attributes);
@ -297,6 +312,7 @@ describe('API Token', () => {
data: {
...omit('permissions', attributes),
accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: null,
lifespan: null,
},
@ -338,7 +354,7 @@ describe('API Token', () => {
)
);
global.strapi = {
setupStrapiMock({
...getActionProvider(['api::foo.foo.find', 'api::foo.foo.create'] as any),
db: {
query() {
@ -349,10 +365,7 @@ describe('API Token', () => {
};
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const res = await apiTokenCreate(attributes);
@ -386,7 +399,7 @@ describe('API Token', () => {
)
);
global.strapi = {
setupStrapiMock({
...getActionProvider(['valid-permission'] as any),
db: {
query() {
@ -396,10 +409,7 @@ describe('API Token', () => {
};
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
await expect(() => apiTokenCreate(attributes)).rejects.toThrowError(
new errors.ApplicationError(
@ -426,6 +436,15 @@ describe('API Token', () => {
},
} as any;
setupStrapiMock({
config: {
get: jest.fn(() => ({
admin: { apiToken: { salt: 'api-token_tests-salt' } },
})),
set: mockedConfigSet,
},
});
checkSaltIsDefined();
expect(mockedAppendFile).not.toHaveBeenCalled();
@ -433,11 +452,11 @@ describe('API Token', () => {
});
test('It throws if the salt is not defined', () => {
global.strapi = {
setupStrapiMock({
config: {
get: jest.fn(() => null),
},
} as any;
});
try {
checkSaltIsDefined();
@ -611,17 +630,13 @@ describe('API Token', () => {
test('It regenerates the accessKey', async () => {
const update = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
setupStrapiMock({
db: {
query() {
return { update };
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const id = 1;
const res = await regenerate(id);
@ -630,24 +645,25 @@ describe('API Token', () => {
select: ['id', 'accessKey'],
data: {
accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
},
});
expect(res).toEqual({ accessKey: mockedApiToken.hexedString });
expect(res).toEqual({
accessKey: mockedApiToken.hexedString,
encryptedKey: expect.any(String),
});
});
test('It throws a NotFound if the id is not found', async () => {
const update = jest.fn(() => Promise.resolve(null));
global.strapi = {
setupStrapiMock({
db: {
query() {
return { update };
},
},
config: {
get: jest.fn(() => ''),
},
} as any;
});
const id = 1;
await expect(async () => {
@ -659,6 +675,7 @@ describe('API Token', () => {
select: ['id', 'accessKey'],
data: {
accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
},
});
});

View File

@ -0,0 +1,78 @@
import crypto from 'crypto';
import encryption from '../encryption';
describe('Encryption Service', () => {
const ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
beforeEach(() => {
global.strapi = {
config: {
get: jest.fn((key) => {
if (key === 'admin.secrets.encryptionKey') {
return ENCRYPTION_KEY;
}
return undefined;
}),
},
log: {
warn: jest.fn(),
},
} as any;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('encrypt', () => {
test('encrypts and returns a four-part colon-separated string (version, iv, encrypted, auth)', () => {
const encrypted = encryption.encrypt('super secret');
expect(encrypted).toMatch(/^v1:[a-f0-9]+:[a-f0-9]+:[a-f0-9]+$/);
const parts = encrypted!.split(':');
expect(parts).toHaveLength(4);
});
test('returns null and logs warning when key is missing', () => {
(global.strapi.config.get as jest.Mock).mockReturnValue(undefined);
const result = encryption.encrypt('test');
expect(result).toBeNull();
expect(global.strapi.log.warn).toHaveBeenCalledWith('Encryption key is missing from config');
});
});
describe('decrypt', () => {
test('round-trips encrypted → decrypted', () => {
const original = 'secret message';
const encrypted = encryption.encrypt(original);
const decrypted = encryption.decrypt(encrypted!);
expect(decrypted).toBe(original);
});
test('throws for malformed input', () => {
expect(() => encryption.decrypt('v1:bad-format')).toThrow('Invalid encrypted value format');
});
test('returns null and logs warning when key is missing', () => {
(global.strapi.config.get as jest.Mock).mockReturnValue(undefined);
const result = encryption.decrypt('v1:iv:payload:tag');
expect(result).toBeNull();
expect(global.strapi.log.warn).toHaveBeenCalledWith('Encryption key is missing from config');
});
test('returns null and logs warning when decryption fails due to wrong key', () => {
const encrypted = encryption.encrypt('cannot decrypt this');
const wrongKey = crypto.randomBytes(32).toString('hex');
(global.strapi.config.get as jest.Mock).mockReturnValueOnce(wrongKey);
const result = encryption.decrypt(encrypted!);
expect(result).toBeNull();
expect(global.strapi.log.warn).toHaveBeenCalledWith(
'[decrypt] Unable to decrypt value — encryption key may have changed or data is corrupted.'
);
});
});
});

View File

@ -3,6 +3,7 @@ import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from '
import { errors } from '@strapi/utils';
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
import constants from './constants';
import { getService } from '../utils';
const { ValidationError, NotFoundError } = errors;
@ -117,15 +118,28 @@ const getBy = async (whereParams: WhereParams = {}): Promise<ApiToken | null> =>
return null;
}
const token = await strapi.db
.query('admin::api-token')
.findOne({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: whereParams });
const token = await strapi.db.query('admin::api-token').findOne({
select: [...SELECT_FIELDS, 'encryptedKey'],
populate: POPULATE_FIELDS,
where: whereParams,
});
if (!token) {
return token;
}
return flattenTokenPermissions(token);
const { encryptedKey, ...rest } = token;
if (!encryptedKey) {
return flattenTokenPermissions(rest);
}
const accessKey = getService('encryption').decrypt(encryptedKey);
return flattenTokenPermissions({
...rest,
accessKey,
});
};
/**
@ -164,7 +178,9 @@ const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => {
* Create a token and its permissions
*/
const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
const encryptionService = getService('encryption');
const accessKey = crypto.randomBytes(128).toString('hex');
const encryptedKey = encryptionService.encrypt(accessKey);
assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions);
assertValidLifespan(attributes.lifespan);
@ -176,6 +192,7 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
data: {
...omit('permissions', attributes),
accessKey: hash(accessKey),
encryptedKey,
...getExpirationFields(attributes.lifespan),
},
});
@ -211,12 +228,15 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
const regenerate = async (id: string | number): Promise<ApiToken> => {
const accessKey = crypto.randomBytes(128).toString('hex');
const encryptionService = getService('encryption');
const encryptedKey = encryptionService.encrypt(accessKey);
const apiToken: ApiToken = await strapi.db.query('admin::api-token').update({
select: ['id', 'accessKey'],
where: { id },
data: {
accessKey: hash(accessKey),
encryptedKey,
},
});

View File

@ -0,0 +1,77 @@
import crypto from 'crypto';
const IV_LENGTH = 16; // 16 bytes for AES-GCM IV
const ENCRYPTION_VERSION = 'v1';
const getHashedKey = (): Buffer | null => {
const rawKey: string = strapi.config.get('admin.secrets.encryptionKey');
if (!rawKey) {
strapi.log.warn('Encryption key is missing from config');
return null;
}
return crypto.createHash('sha256').update(rawKey).digest(); // Always 32 bytes
};
/**
* Encrypts a value string using AES-256-GCM.
* Returns a string prefixed with the encryption version and includes IV, encrypted content, and auth tag (all hex-encoded).
*/
const encrypt = (value: string) => {
const key = getHashedKey();
if (!key) return null;
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${ENCRYPTION_VERSION}:${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`;
};
/**
* Decrypts a value encrypted by encrypt().
* Supports versioned formats like v1:iv:encrypted:authTag
*/
const decrypt = (encryptedValue: string) => {
const [version, ...rest] = encryptedValue.split(':');
if (version !== ENCRYPTION_VERSION) {
throw new Error(`Unsupported encryption version: ${version}`);
}
const [ivHex, encryptedHex, tagHex] = rest;
if (!ivHex || !encryptedHex || !tagHex) {
throw new Error('Invalid encrypted value format');
}
const key = getHashedKey();
if (!key) return null;
const iv = Buffer.from(ivHex, 'hex');
const encryptedText = Buffer.from(encryptedHex, 'hex');
const authTag = Buffer.from(tagHex, 'hex');
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, undefined, 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (err) {
strapi.log.warn(
'[decrypt] Unable to decrypt value — encryption key may have changed or data is corrupted.'
);
return null;
}
};
export default {
encrypt,
decrypt,
};

View File

@ -4,6 +4,7 @@ import user from './user';
import role from './role';
import passport from './passport';
import metrics from './metrics';
import encryption from './encryption';
import * as token from './token';
import * as permission from './permission';
import * as contentType from './content-type';
@ -30,4 +31,5 @@ export default {
'api-token': apiToken,
transfer,
'project-settings': projectSettings,
encryption,
};

View File

@ -3,6 +3,7 @@ import role from '../services/role';
import user from '../services/user';
import passport from '../services/passport';
import metrics from '../services/metrics';
import encryption from '../services/encryption';
import * as permission from '../services/permission';
import * as contentType from '../services/content-type';
import * as token from '../services/token';
@ -22,6 +23,7 @@ type S = {
'api-token': typeof apiToken;
'project-settings': typeof projectSettings;
transfer: typeof transfer;
encryption: typeof encryption;
};
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };

View File

@ -3,6 +3,7 @@ import type { Data } from '@strapi/types';
export type ApiToken = {
accessKey: string;
encryptedKey: string;
createdAt: string;
description: string;
expiresAt: string;

View File

@ -15,7 +15,7 @@ const createAPIToken = async (page, tokenName, duration, type) => {
await page.getByRole('option', { name: type }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Make sure to copy this token')).toBeVisible();
await expect(page.getByText('Copy this token for use elsewhere')).toBeVisible();
await expect(page.getByText('Expiration date:')).toBeVisible();
};