mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
enhancement: make api tokens viewable (#23439)
This commit is contained in:
parent
c8de96f027
commit
5989d3c7be
@ -14,6 +14,9 @@ module.exports = ({ env }) => ({
|
|||||||
salt: env('TRANSFER_TOKEN_SALT', 'example-salt'),
|
salt: env('TRANSFER_TOKEN_SALT', 'example-salt'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
secrets: {
|
||||||
|
encryptionKey: env('ENCRYPTION_KEY', 'example-key'),
|
||||||
|
},
|
||||||
flags: {
|
flags: {
|
||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||||
|
@ -15,6 +15,7 @@ APP_KEYS=<%= appKeys %>
|
|||||||
API_TOKEN_SALT=<%= apiTokenSalt %>
|
API_TOKEN_SALT=<%= apiTokenSalt %>
|
||||||
ADMIN_JWT_SECRET=<%= adminJwtToken %>
|
ADMIN_JWT_SECRET=<%= adminJwtToken %>
|
||||||
TRANSFER_TOKEN_SALT=<%= transferTokenSalt %>
|
TRANSFER_TOKEN_SALT=<%= transferTokenSalt %>
|
||||||
|
ENCRYPTION_KEY=<%= encryptionKey %>
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_CLIENT=<%= database.client %>
|
DATABASE_CLIENT=<%= database.client %>
|
||||||
@ -35,6 +36,7 @@ export function generateDotEnv(scope: Scope) {
|
|||||||
apiTokenSalt: generateASecret(),
|
apiTokenSalt: generateASecret(),
|
||||||
transferTokenSalt: generateASecret(),
|
transferTokenSalt: generateASecret(),
|
||||||
adminJwtToken: generateASecret(),
|
adminJwtToken: generateASecret(),
|
||||||
|
encryptionKey: generateASecret(),
|
||||||
database: {
|
database: {
|
||||||
client: scope.database.client,
|
client: scope.database.client,
|
||||||
connection: {
|
connection: {
|
||||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
|||||||
ADMIN_JWT_SECRET=tobemodified
|
ADMIN_JWT_SECRET=tobemodified
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
TRANSFER_TOKEN_SALT=tobemodified
|
||||||
JWT_SECRET=tobemodified
|
JWT_SECRET=tobemodified
|
||||||
|
ENCRYPTION_KEY=tobemodified
|
||||||
|
@ -10,6 +10,9 @@ module.exports = ({ env }) => ({
|
|||||||
salt: env('TRANSFER_TOKEN_SALT'),
|
salt: env('TRANSFER_TOKEN_SALT'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
secrets: {
|
||||||
|
encryptionKey: env('ENCRYPTION_KEY'),
|
||||||
|
},
|
||||||
flags: {
|
flags: {
|
||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
|||||||
ADMIN_JWT_SECRET=tobemodified
|
ADMIN_JWT_SECRET=tobemodified
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
TRANSFER_TOKEN_SALT=tobemodified
|
||||||
JWT_SECRET=tobemodified
|
JWT_SECRET=tobemodified
|
||||||
|
ENCRYPTION_KEY=tobemodified
|
||||||
|
@ -10,6 +10,9 @@ export default ({ env }) => ({
|
|||||||
salt: env('TRANSFER_TOKEN_SALT'),
|
salt: env('TRANSFER_TOKEN_SALT'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
secrets: {
|
||||||
|
encryptionKey: env('ENCRYPTION_KEY'),
|
||||||
|
},
|
||||||
flags: {
|
flags: {
|
||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
|||||||
ADMIN_JWT_SECRET=tobemodified
|
ADMIN_JWT_SECRET=tobemodified
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
TRANSFER_TOKEN_SALT=tobemodified
|
||||||
JWT_SECRET=tobemodified
|
JWT_SECRET=tobemodified
|
||||||
|
ENCRYPTION_KEY=tobemodified
|
||||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
|||||||
ADMIN_JWT_SECRET=tobemodified
|
ADMIN_JWT_SECRET=tobemodified
|
||||||
TRANSFER_TOKEN_SALT=tobemodified
|
TRANSFER_TOKEN_SALT=tobemodified
|
||||||
JWT_SECRET=tobemodified
|
JWT_SECRET=tobemodified
|
||||||
|
ENCRYPTION_KEY=tobemodified
|
||||||
|
@ -10,6 +10,9 @@ export default ({ env }) => ({
|
|||||||
salt: env('TRANSFER_TOKEN_SALT'),
|
salt: env('TRANSFER_TOKEN_SALT'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
secrets: {
|
||||||
|
encryptionKey: env('ENCRYPTION_KEY'),
|
||||||
|
},
|
||||||
flags: {
|
flags: {
|
||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { Button, Dialog, Flex } from '@strapi/design-system';
|
import { Button, Dialog, Flex, Tooltip } from '@strapi/design-system';
|
||||||
import { Check, ArrowClockwise } from '@strapi/icons';
|
import { Check, ArrowClockwise, Eye, EyeStriked } from '@strapi/icons';
|
||||||
import { MessageDescriptor, useIntl } from 'react-intl';
|
import { MessageDescriptor, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { ConfirmDialog } from '../../../../components/ConfirmDialog';
|
import { ConfirmDialog } from '../../../../components/ConfirmDialog';
|
||||||
@ -117,7 +117,10 @@ interface FormHeadProps<TToken extends Token | null> {
|
|||||||
token: TToken;
|
token: TToken;
|
||||||
canEditInputs: boolean;
|
canEditInputs: boolean;
|
||||||
canRegenerate: boolean;
|
canRegenerate: boolean;
|
||||||
|
canShowToken?: boolean;
|
||||||
setToken: (token: TToken) => void;
|
setToken: (token: TToken) => void;
|
||||||
|
toggleToken?: () => void;
|
||||||
|
showToken?: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
regenerateUrl: string;
|
regenerateUrl: string;
|
||||||
}
|
}
|
||||||
@ -126,6 +129,9 @@ export const FormHead = <TToken extends Token | null>({
|
|||||||
title,
|
title,
|
||||||
token,
|
token,
|
||||||
setToken,
|
setToken,
|
||||||
|
toggleToken,
|
||||||
|
showToken,
|
||||||
|
canShowToken,
|
||||||
canEditInputs,
|
canEditInputs,
|
||||||
canRegenerate,
|
canRegenerate,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
@ -137,6 +143,7 @@ export const FormHead = <TToken extends Token | null>({
|
|||||||
...token,
|
...token,
|
||||||
accessKey: newKey,
|
accessKey: newKey,
|
||||||
});
|
});
|
||||||
|
toggleToken?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -151,6 +158,31 @@ export const FormHead = <TToken extends Token | null>({
|
|||||||
url={`${regenerateUrl}${token?.id ?? ''}`}
|
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
|
<Button
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
|
@ -64,10 +64,18 @@ export const TokenBox = ({ token, tokenType }: TokenBoxProps) => {
|
|||||||
}
|
}
|
||||||
subtitle={
|
subtitle={
|
||||||
token
|
token
|
||||||
? formatMessage({
|
? formatMessage(
|
||||||
|
tokenType === 'api-token'
|
||||||
|
? {
|
||||||
|
id: 'Settings.tokens.copy.subtitle',
|
||||||
|
defaultMessage: 'Copy this token for use elsewhere',
|
||||||
|
}
|
||||||
|
: {
|
||||||
id: 'Settings.tokens.copy.lastWarning',
|
id: 'Settings.tokens.copy.lastWarning',
|
||||||
defaultMessage: 'Make sure to copy this token, you won’t be able to see it again!',
|
defaultMessage:
|
||||||
})
|
'Make sure to copy this token, you won’t be able to see it again!',
|
||||||
|
}
|
||||||
|
)
|
||||||
: formatMessage({
|
: formatMessage({
|
||||||
id: 'Settings.tokens.copy.editMessage',
|
id: 'Settings.tokens.copy.editMessage',
|
||||||
defaultMessage: 'For security reasons, you can only see your token once.',
|
defaultMessage: 'For security reasons, you can only see your token once.',
|
||||||
|
@ -51,6 +51,8 @@ export const EditView = () => {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
const [showToken, setShowToken] = React.useState(Boolean(locationState?.apiToken?.accessKey));
|
||||||
|
const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const { trackUsage } = useTracking();
|
const { trackUsage } = useTracking();
|
||||||
const setCurrentStep = useGuidedTour('EditView', (state) => state.setCurrentStep);
|
const setCurrentStep = useGuidedTour('EditView', (state) => state.setCurrentStep);
|
||||||
const {
|
const {
|
||||||
@ -69,7 +71,6 @@ export const EditView = () => {
|
|||||||
|
|
||||||
const contentAPIPermissionsQuery = useGetPermissionsQuery();
|
const contentAPIPermissionsQuery = useGetPermissionsQuery();
|
||||||
const contentAPIRoutesQuery = useGetRoutesQuery();
|
const contentAPIRoutesQuery = useGetRoutesQuery();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Separate effects otherwise we could end
|
* Separate effects otherwise we could end
|
||||||
* up duplicating the same notification.
|
* up duplicating the same notification.
|
||||||
@ -173,6 +174,23 @@ export const EditView = () => {
|
|||||||
}
|
}
|
||||||
}, [data]);
|
}, [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 [createToken] = useCreateAPITokenMutation();
|
||||||
const [updateToken] = useUpdateAPITokenMutation();
|
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 = {
|
const providerValue = {
|
||||||
...state,
|
...state,
|
||||||
onChange: handleChangeCheckbox,
|
onChange: handleChangeCheckbox,
|
||||||
@ -312,6 +338,7 @@ export const EditView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating);
|
const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating);
|
||||||
|
const canShowToken = !!apiToken?.accessKey;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Page.Loading />;
|
return <Page.Loading />;
|
||||||
@ -352,17 +379,21 @@ export const EditView = () => {
|
|||||||
}}
|
}}
|
||||||
token={apiToken}
|
token={apiToken}
|
||||||
setToken={setApiToken}
|
setToken={setApiToken}
|
||||||
|
toggleToken={toggleToken}
|
||||||
|
showToken={showToken}
|
||||||
canEditInputs={canEditInputs}
|
canEditInputs={canEditInputs}
|
||||||
canRegenerate={canRegenerate}
|
canRegenerate={canRegenerate}
|
||||||
|
canShowToken={canShowToken}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
regenerateUrl="/admin/api-tokens/"
|
regenerateUrl="/admin/api-tokens/"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Layouts.Content>
|
<Layouts.Content>
|
||||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||||
{Boolean(apiToken?.name) && (
|
{apiToken?.accessKey && showToken && (
|
||||||
<TokenBox token={apiToken?.accessKey} tokenType={API_TOKEN_TYPE} />
|
<TokenBox token={apiToken.accessKey} tokenType={API_TOKEN_TYPE} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormApiTokenContainer
|
<FormApiTokenContainer
|
||||||
errors={errors}
|
errors={errors}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
@ -263,6 +263,7 @@ const EditView = () => {
|
|||||||
}}
|
}}
|
||||||
token={transferToken}
|
token={transferToken}
|
||||||
setToken={setTransferToken}
|
setToken={setTransferToken}
|
||||||
|
canShowToken={false}
|
||||||
canEditInputs={canEditInputs}
|
canEditInputs={canEditInputs}
|
||||||
canRegenerate={canRegenerate}
|
canRegenerate={canRegenerate}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
@ -47,6 +47,13 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
searchable: false,
|
searchable: false,
|
||||||
},
|
},
|
||||||
|
encryptedKey: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
configurable: false,
|
||||||
|
required: false,
|
||||||
|
searchable: false,
|
||||||
|
},
|
||||||
lastUsedAt: {
|
lastUsedAt: {
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
configurable: false,
|
configurable: false,
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
update as apiTokenUpdate,
|
update as apiTokenUpdate,
|
||||||
getByName,
|
getByName,
|
||||||
} from '../api-token';
|
} from '../api-token';
|
||||||
|
import encryptionService from '../encryption';
|
||||||
|
|
||||||
const getActionProvider = (actions = []) => {
|
const getActionProvider = (actions = []) => {
|
||||||
return {
|
return {
|
||||||
@ -26,6 +27,30 @@ describe('API Token', () => {
|
|||||||
hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573',
|
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 now: any;
|
||||||
let nowSpy: any;
|
let nowSpy: any;
|
||||||
|
|
||||||
@ -48,16 +73,13 @@ describe('API Token', () => {
|
|||||||
test('Creates a new read-only token', async () => {
|
test('Creates a new read-only token', async () => {
|
||||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
return { create };
|
return { create };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const attributes = {
|
const attributes = {
|
||||||
name: 'api-token_tests-name',
|
name: 'api-token_tests-name',
|
||||||
@ -72,6 +94,7 @@ describe('API Token', () => {
|
|||||||
data: {
|
data: {
|
||||||
...attributes,
|
...attributes,
|
||||||
accessKey: hash(mockedApiToken.hexedString),
|
accessKey: hash(mockedApiToken.hexedString),
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
lifespan: null,
|
lifespan: null,
|
||||||
},
|
},
|
||||||
@ -80,6 +103,7 @@ describe('API Token', () => {
|
|||||||
expect(res).toEqual({
|
expect(res).toEqual({
|
||||||
...attributes,
|
...attributes,
|
||||||
accessKey: mockedApiToken.hexedString,
|
accessKey: mockedApiToken.hexedString,
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
lifespan: null,
|
lifespan: null,
|
||||||
});
|
});
|
||||||
@ -96,16 +120,13 @@ describe('API Token', () => {
|
|||||||
const expectedExpires = Date.now() + attributes.lifespan;
|
const expectedExpires = Date.now() + attributes.lifespan;
|
||||||
|
|
||||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
return { create };
|
return { create };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const res = await apiTokenCreate(attributes);
|
const res = await apiTokenCreate(attributes);
|
||||||
|
|
||||||
@ -114,6 +135,7 @@ describe('API Token', () => {
|
|||||||
data: {
|
data: {
|
||||||
...attributes,
|
...attributes,
|
||||||
accessKey: hash(mockedApiToken.hexedString),
|
accessKey: hash(mockedApiToken.hexedString),
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
expiresAt: expectedExpires,
|
expiresAt: expectedExpires,
|
||||||
lifespan: attributes.lifespan,
|
lifespan: attributes.lifespan,
|
||||||
},
|
},
|
||||||
@ -122,6 +144,7 @@ describe('API Token', () => {
|
|||||||
expect(res).toEqual({
|
expect(res).toEqual({
|
||||||
...attributes,
|
...attributes,
|
||||||
accessKey: mockedApiToken.hexedString,
|
accessKey: mockedApiToken.hexedString,
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
expiresAt: expectedExpires,
|
expiresAt: expectedExpires,
|
||||||
lifespan: attributes.lifespan,
|
lifespan: attributes.lifespan,
|
||||||
});
|
});
|
||||||
@ -137,16 +160,13 @@ describe('API Token', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
return { create };
|
return { create };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(async () => {
|
expect(async () => {
|
||||||
await apiTokenCreate(attributes);
|
await apiTokenCreate(attributes);
|
||||||
@ -182,7 +202,7 @@ describe('API Token', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
...getActionProvider(['admin::content.content.read'] as any),
|
...getActionProvider(['admin::content.content.read'] as any),
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
@ -193,10 +213,7 @@ describe('API Token', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const res = await apiTokenCreate(attributes);
|
const res = await apiTokenCreate(attributes);
|
||||||
|
|
||||||
@ -213,6 +230,7 @@ describe('API Token', () => {
|
|||||||
data: {
|
data: {
|
||||||
...omit('permissions', attributes),
|
...omit('permissions', attributes),
|
||||||
accessKey: hash(mockedApiToken.hexedString),
|
accessKey: hash(mockedApiToken.hexedString),
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
lifespan: null,
|
lifespan: null,
|
||||||
},
|
},
|
||||||
@ -265,7 +283,7 @@ describe('API Token', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
...getActionProvider(['admin::content.content.read'] as any),
|
...getActionProvider(['admin::content.content.read'] as any),
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
@ -276,10 +294,7 @@ describe('API Token', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const res = await apiTokenCreate(attributes);
|
const res = await apiTokenCreate(attributes);
|
||||||
|
|
||||||
@ -297,6 +312,7 @@ describe('API Token', () => {
|
|||||||
data: {
|
data: {
|
||||||
...omit('permissions', attributes),
|
...omit('permissions', attributes),
|
||||||
accessKey: hash(mockedApiToken.hexedString),
|
accessKey: hash(mockedApiToken.hexedString),
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
lifespan: null,
|
lifespan: null,
|
||||||
},
|
},
|
||||||
@ -338,7 +354,7 @@ describe('API Token', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
...getActionProvider(['api::foo.foo.find', 'api::foo.foo.create'] as any),
|
...getActionProvider(['api::foo.foo.find', 'api::foo.foo.create'] as any),
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
@ -349,10 +365,7 @@ describe('API Token', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const res = await apiTokenCreate(attributes);
|
const res = await apiTokenCreate(attributes);
|
||||||
|
|
||||||
@ -386,7 +399,7 @@ describe('API Token', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
...getActionProvider(['valid-permission'] as any),
|
...getActionProvider(['valid-permission'] as any),
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
@ -396,10 +409,7 @@ describe('API Token', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
await expect(() => apiTokenCreate(attributes)).rejects.toThrowError(
|
await expect(() => apiTokenCreate(attributes)).rejects.toThrowError(
|
||||||
new errors.ApplicationError(
|
new errors.ApplicationError(
|
||||||
@ -426,6 +436,15 @@ describe('API Token', () => {
|
|||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
setupStrapiMock({
|
||||||
|
config: {
|
||||||
|
get: jest.fn(() => ({
|
||||||
|
admin: { apiToken: { salt: 'api-token_tests-salt' } },
|
||||||
|
})),
|
||||||
|
set: mockedConfigSet,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
checkSaltIsDefined();
|
checkSaltIsDefined();
|
||||||
|
|
||||||
expect(mockedAppendFile).not.toHaveBeenCalled();
|
expect(mockedAppendFile).not.toHaveBeenCalled();
|
||||||
@ -433,11 +452,11 @@ describe('API Token', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('It throws if the salt is not defined', () => {
|
test('It throws if the salt is not defined', () => {
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
config: {
|
config: {
|
||||||
get: jest.fn(() => null),
|
get: jest.fn(() => null),
|
||||||
},
|
},
|
||||||
} as any;
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checkSaltIsDefined();
|
checkSaltIsDefined();
|
||||||
@ -611,17 +630,13 @@ describe('API Token', () => {
|
|||||||
test('It regenerates the accessKey', async () => {
|
test('It regenerates the accessKey', async () => {
|
||||||
const update = jest.fn(({ data }) => Promise.resolve(data));
|
const update = jest.fn(({ data }) => Promise.resolve(data));
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
return { update };
|
return { update };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const id = 1;
|
const id = 1;
|
||||||
const res = await regenerate(id);
|
const res = await regenerate(id);
|
||||||
|
|
||||||
@ -630,24 +645,25 @@ describe('API Token', () => {
|
|||||||
select: ['id', 'accessKey'],
|
select: ['id', 'accessKey'],
|
||||||
data: {
|
data: {
|
||||||
accessKey: hash(mockedApiToken.hexedString),
|
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 () => {
|
test('It throws a NotFound if the id is not found', async () => {
|
||||||
const update = jest.fn(() => Promise.resolve(null));
|
const update = jest.fn(() => Promise.resolve(null));
|
||||||
|
|
||||||
global.strapi = {
|
setupStrapiMock({
|
||||||
db: {
|
db: {
|
||||||
query() {
|
query() {
|
||||||
return { update };
|
return { update };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
});
|
||||||
get: jest.fn(() => ''),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const id = 1;
|
const id = 1;
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
@ -659,6 +675,7 @@ describe('API Token', () => {
|
|||||||
select: ['id', 'accessKey'],
|
select: ['id', 'accessKey'],
|
||||||
data: {
|
data: {
|
||||||
accessKey: hash(mockedApiToken.hexedString),
|
accessKey: hash(mockedApiToken.hexedString),
|
||||||
|
encryptedKey: expect.any(String),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -3,6 +3,7 @@ import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from '
|
|||||||
import { errors } from '@strapi/utils';
|
import { errors } from '@strapi/utils';
|
||||||
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
|
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
|
||||||
import constants from './constants';
|
import constants from './constants';
|
||||||
|
import { getService } from '../utils';
|
||||||
|
|
||||||
const { ValidationError, NotFoundError } = errors;
|
const { ValidationError, NotFoundError } = errors;
|
||||||
|
|
||||||
@ -117,15 +118,28 @@ const getBy = async (whereParams: WhereParams = {}): Promise<ApiToken | null> =>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await strapi.db
|
const token = await strapi.db.query('admin::api-token').findOne({
|
||||||
.query('admin::api-token')
|
select: [...SELECT_FIELDS, 'encryptedKey'],
|
||||||
.findOne({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: whereParams });
|
populate: POPULATE_FIELDS,
|
||||||
|
where: whereParams,
|
||||||
|
});
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return 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
|
* Create a token and its permissions
|
||||||
*/
|
*/
|
||||||
const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
||||||
|
const encryptionService = getService('encryption');
|
||||||
const accessKey = crypto.randomBytes(128).toString('hex');
|
const accessKey = crypto.randomBytes(128).toString('hex');
|
||||||
|
const encryptedKey = encryptionService.encrypt(accessKey);
|
||||||
|
|
||||||
assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions);
|
assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions);
|
||||||
assertValidLifespan(attributes.lifespan);
|
assertValidLifespan(attributes.lifespan);
|
||||||
@ -176,6 +192,7 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
|||||||
data: {
|
data: {
|
||||||
...omit('permissions', attributes),
|
...omit('permissions', attributes),
|
||||||
accessKey: hash(accessKey),
|
accessKey: hash(accessKey),
|
||||||
|
encryptedKey,
|
||||||
...getExpirationFields(attributes.lifespan),
|
...getExpirationFields(attributes.lifespan),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -211,12 +228,15 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
|||||||
|
|
||||||
const regenerate = async (id: string | number): Promise<ApiToken> => {
|
const regenerate = async (id: string | number): Promise<ApiToken> => {
|
||||||
const accessKey = crypto.randomBytes(128).toString('hex');
|
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({
|
const apiToken: ApiToken = await strapi.db.query('admin::api-token').update({
|
||||||
select: ['id', 'accessKey'],
|
select: ['id', 'accessKey'],
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
accessKey: hash(accessKey),
|
accessKey: hash(accessKey),
|
||||||
|
encryptedKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
77
packages/core/admin/server/src/services/encryption.ts
Normal file
77
packages/core/admin/server/src/services/encryption.ts
Normal 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,
|
||||||
|
};
|
@ -4,6 +4,7 @@ import user from './user';
|
|||||||
import role from './role';
|
import role from './role';
|
||||||
import passport from './passport';
|
import passport from './passport';
|
||||||
import metrics from './metrics';
|
import metrics from './metrics';
|
||||||
|
import encryption from './encryption';
|
||||||
import * as token from './token';
|
import * as token from './token';
|
||||||
import * as permission from './permission';
|
import * as permission from './permission';
|
||||||
import * as contentType from './content-type';
|
import * as contentType from './content-type';
|
||||||
@ -30,4 +31,5 @@ export default {
|
|||||||
'api-token': apiToken,
|
'api-token': apiToken,
|
||||||
transfer,
|
transfer,
|
||||||
'project-settings': projectSettings,
|
'project-settings': projectSettings,
|
||||||
|
encryption,
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import role from '../services/role';
|
|||||||
import user from '../services/user';
|
import user from '../services/user';
|
||||||
import passport from '../services/passport';
|
import passport from '../services/passport';
|
||||||
import metrics from '../services/metrics';
|
import metrics from '../services/metrics';
|
||||||
|
import encryption from '../services/encryption';
|
||||||
import * as permission from '../services/permission';
|
import * as permission from '../services/permission';
|
||||||
import * as contentType from '../services/content-type';
|
import * as contentType from '../services/content-type';
|
||||||
import * as token from '../services/token';
|
import * as token from '../services/token';
|
||||||
@ -22,6 +23,7 @@ type S = {
|
|||||||
'api-token': typeof apiToken;
|
'api-token': typeof apiToken;
|
||||||
'project-settings': typeof projectSettings;
|
'project-settings': typeof projectSettings;
|
||||||
transfer: typeof transfer;
|
transfer: typeof transfer;
|
||||||
|
encryption: typeof encryption;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };
|
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };
|
||||||
|
@ -3,6 +3,7 @@ import type { Data } from '@strapi/types';
|
|||||||
|
|
||||||
export type ApiToken = {
|
export type ApiToken = {
|
||||||
accessKey: string;
|
accessKey: string;
|
||||||
|
encryptedKey: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
description: string;
|
description: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
@ -15,7 +15,7 @@ const createAPIToken = async (page, tokenName, duration, type) => {
|
|||||||
await page.getByRole('option', { name: type }).click();
|
await page.getByRole('option', { name: type }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).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();
|
await expect(page.getByText('Expiration date:')).toBeVisible();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user