Skip to content

Commit 8491915

Browse files
myftijaTrigger.dev RepoOps
authored andcommitted
feat(webapp): show hidden root API keys masked and allow regenerating them
The API keys page now always lists the root API key. A hidden root key shows masked to its last 4 characters and can be regenerated, but never revealed or copied. When it's hidden and you have no other keys, the page prompts you to create an API key for your backend. "Revoke the previous key immediately" now also stops older root keys still in their 24-hour grace window. Mono-RevId: e0466df43d3fae68e32f01da137835ea3dec4858
1 parent 71486a9 commit 8491915

11 files changed

Lines changed: 544 additions & 49 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The API keys page now always lists your root API key, masked to its last 4 characters when it's hidden, so you can regenerate it and revoke the previous key. When the root key is hidden, the page points you to creating an API key for your backend.

‎apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type ModalProps = {
1919
title: string;
2020
hasVercelIntegration: boolean;
2121
isDevelopment: boolean;
22+
isRootApiKeyHidden?: boolean;
2223
};
2324

2425
type ModalContentProps = ModalProps & {
@@ -31,6 +32,7 @@ export function RegenerateApiKeyModal({
3132
title,
3233
hasVercelIntegration,
3334
isDevelopment,
35+
isRootApiKeyHidden = false,
3436
}: ModalProps) {
3537
const randomWord = generateTwoRandomWords();
3638
const [open, setOpen] = useState(false);
@@ -48,6 +50,7 @@ export function RegenerateApiKeyModal({
4850
title={title}
4951
hasVercelIntegration={hasVercelIntegration}
5052
isDevelopment={isDevelopment}
53+
isRootApiKeyHidden={isRootApiKeyHidden}
5154
randomWord={randomWord}
5255
closeModal={() => setOpen(false)}
5356
/>
@@ -62,6 +65,7 @@ const RegenerateApiKeyModalContent = ({
6265
title,
6366
hasVercelIntegration,
6467
isDevelopment,
68+
isRootApiKeyHidden,
6569
closeModal,
6670
}: ModalContentProps) => {
6771
const [confirmationText, setConfirmationText] = useState("");
@@ -83,6 +87,7 @@ const RegenerateApiKeyModalContent = ({
8387
action={`/resources/environments/${id}/regenerate-api-key`}
8488
className="mt-2 w-full"
8589
>
90+
{isRootApiKeyHidden ? <input type="hidden" name="rootApiKeyHidden" value="true" /> : null}
8691
<Fieldset className="w-full">
8792
<InputGroup className="max-w-full">
8893
<RadioGroup name="previousKey" defaultValue="grace" className="flex flex-col gap-2">
@@ -91,14 +96,22 @@ const RegenerateApiKeyModalContent = ({
9196
value="grace"
9297
variant="description"
9398
label="Keep the previous key working for 24 hours"
94-
description="Roll out the new key to your environment variables without downtime. The previous key stops working after 24 hours."
99+
description={
100+
isRootApiKeyHidden
101+
? "Anything still using the previous key keeps working for 24 hours."
102+
: "Roll out the new key to your environment variables without downtime. The previous key stops working after 24 hours."
103+
}
95104
/>
96105
<RadioGroupItem
97106
id="previousKey-revoke"
98107
value="revoke"
99108
variant="description"
100109
label="Revoke the previous key immediately"
101-
description="Choose this if the key may have leaked. Deployments and services still using the previous key will fail until they are updated with the new key."
110+
description={
111+
isRootApiKeyHidden
112+
? "Choose this if the key may have leaked. It stops working now, along with any older key still in its 24-hour window."
113+
: "Choose this if the key may have leaked. It stops working now, along with any older key still in its 24-hour window. Deployments and services still using them will fail until they are updated with the new key."
114+
}
102115
/>
103116
</RadioGroup>
104117
</InputGroup>

‎apps/webapp/app/models/api-key.server.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type RootApiKeyMutationInput = {
2727

2828
type RegenerateApiKeyInput = RootApiKeyMutationInput & {
2929
revokeImmediately?: boolean;
30+
allowHidden?: boolean;
3031
};
3132

3233
export class RootApiKeyNotVisibleError extends Error {
@@ -38,7 +39,8 @@ export class RootApiKeyNotVisibleError extends Error {
3839

3940
async function findRootApiKeyEnvironment(
4041
{ userId, environmentId }: RootApiKeyMutationInput,
41-
prismaClient: PrismaClient
42+
prismaClient: PrismaClient,
43+
{ allowHidden = false }: { allowHidden?: boolean } = {}
4244
) {
4345
const requestedEnvironment = await prismaClient.runtimeEnvironment.findFirst({
4446
where: {
@@ -86,16 +88,17 @@ async function findRootApiKeyEnvironment(
8688
throw new Error("User does not have permission to manage this root API key");
8789
}
8890

89-
if (environment.rootApiKeyHiddenAt) {
91+
if (environment.rootApiKeyHiddenAt && !allowHidden) {
9092
throw new RootApiKeyNotVisibleError();
9193
}
9294

9395
return environment;
9496
}
9597

96-
async function lockVisibleRootApiKeyEnvironment(
98+
async function lockRootApiKeyEnvironment(
9799
prismaClient: PrismaTransactionClient,
98-
environmentId: string
100+
environmentId: string,
101+
{ allowHidden = false }: { allowHidden?: boolean } = {}
99102
) {
100103
const [environment] = await prismaClient.$queryRaw<
101104
Array<{ apiKey: string; rootApiKeyHiddenAt: Date | null }>
@@ -106,18 +109,18 @@ async function lockVisibleRootApiKeyEnvironment(
106109
FOR UPDATE
107110
`;
108111

109-
if (!environment || environment.rootApiKeyHiddenAt) {
112+
if (!environment || (environment.rootApiKeyHiddenAt && !allowHidden)) {
110113
throw new RootApiKeyNotVisibleError();
111114
}
112115

113116
return environment;
114117
}
115118

116119
export async function regenerateApiKey(
117-
{ revokeImmediately = false, ...input }: RegenerateApiKeyInput,
120+
{ revokeImmediately = false, allowHidden = false, ...input }: RegenerateApiKeyInput,
118121
{ prismaClient = prisma }: { prismaClient?: PrismaClient } = {}
119122
) {
120-
const environment = await findRootApiKeyEnvironment(input, prismaClient);
123+
const environment = await findRootApiKeyEnvironment(input, prismaClient, { allowHidden });
121124
const newApiKey = createApiKeyForEnv(environment.type);
122125
const newPkApiKey = createPkApiKeyForEnv(environment.type);
123126
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
@@ -126,7 +129,9 @@ export async function regenerateApiKey(
126129
prismaClient,
127130
"regenerate root API key",
128131
async (tx) => {
129-
const currentEnvironment = await lockVisibleRootApiKeyEnvironment(tx, environment.id);
132+
const currentEnvironment = await lockRootApiKeyEnvironment(tx, environment.id, {
133+
allowHidden,
134+
});
130135

131136
await tx.runtimeEnvironment.update({
132137
where: { id: environment.id },
@@ -136,7 +141,13 @@ export async function regenerateApiKey(
136141
},
137142
});
138143

139-
if (!revokeImmediately) {
144+
if (revokeImmediately) {
145+
const now = new Date();
146+
await tx.revokedApiKey.updateMany({
147+
where: { runtimeEnvironmentId: environment.id, expiresAt: { gt: now } },
148+
data: { expiresAt: now },
149+
});
150+
} else {
140151
await tx.revokedApiKey.create({
141152
data: {
142153
apiKey: currentEnvironment.apiKey,
@@ -146,7 +157,12 @@ export async function regenerateApiKey(
146157
});
147158
}
148159

149-
return { ...environment, apiKey: newApiKey, pkApiKey: newPkApiKey };
160+
return {
161+
...environment,
162+
apiKey: newApiKey,
163+
pkApiKey: newPkApiKey,
164+
rootApiKeyHiddenAt: currentEnvironment.rootApiKeyHiddenAt,
165+
};
150166
}
151167
);
152168

@@ -172,7 +188,7 @@ export async function disableRootApiKeyVisibility(
172188
prismaClient,
173189
"disable root API key visibility",
174190
async (tx) => {
175-
const currentEnvironment = await lockVisibleRootApiKeyEnvironment(tx, environment.id);
191+
const currentEnvironment = await lockRootApiKeyEnvironment(tx, environment.id);
176192

177193
await tx.runtimeEnvironment.update({
178194
where: { id: environment.id },

‎apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,19 +120,19 @@ export class ApiKeysPresenter {
120120
]);
121121
const presetsById = new Map(presets?.map((preset) => [preset.id, preset]));
122122
const { taskIdentifiers, organizationId: _organizationId, ...environmentData } = environment;
123-
const rootApiKey = keyEnvironment.rootApiKeyHiddenAt
124-
? null
125-
: {
126-
id: keyEnvironment.id,
127-
name: "Root API key",
128-
value: keyEnvironment.apiKey,
129-
obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)),
130-
};
123+
const isRootApiKeyHidden = keyEnvironment.rootApiKeyHiddenAt !== null;
124+
const rootApiKey = {
125+
id: keyEnvironment.id,
126+
name: "Root API key",
127+
value: isRootApiKeyHidden ? null : keyEnvironment.apiKey,
128+
obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)),
129+
isHidden: isRootApiKeyHidden,
130+
};
131131

132132
return {
133133
environment: {
134134
...environmentData,
135-
apiKey: rootApiKey?.value ?? null,
135+
apiKey: rootApiKey.value,
136136
keyEnvironmentId,
137137
},
138138
availableTasks: taskIdentifiers.map((task) => task.slug),

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx‎

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
TableHeaderCell,
5555
TableRow,
5656
} from "~/components/primitives/Table";
57+
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
5758
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
5859
import {
5960
createEnvironmentApiKey,
@@ -329,7 +330,14 @@ export default function Page() {
329330
.join("\n")
330331
: null;
331332

332-
const hasApiKeys = rootApiKey !== null || apiKeys.length > 0;
333+
const hasUsableApiKey = apiKeys.some(
334+
(apiKey) =>
335+
!apiKey.revokedAt && !(apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() <= loadedAt)
336+
);
337+
const showCreateApiKeyPrompt = rootApiKey?.isHidden === true && !hasUsableApiKey;
338+
const canManageRootApiKey = canWriteApiKeys || rootApiKey?.isHidden === false;
339+
const chooseAccessHint =
340+
isRbacPluginAvailable && presets !== null ? ", and choose what it can access" : "";
333341

334342
return (
335343
<PageContainer>
@@ -378,9 +386,9 @@ export default function Page() {
378386
/>
379387
) : null}
380388
<RevokedFilter checked={showRevoked} />
381-
{hasApiKeys ? (
389+
{showCreateApiKeyPrompt ? null : (
382390
<NewApiKeyButton canWrite={canWriteApiKeys} variant="primary/small" />
383-
) : null}
391+
)}
384392
</div>
385393
</div>
386394

@@ -401,7 +409,14 @@ export default function Page() {
401409
<span className="font-mono text-text-dimmed">
402410
{rootApiKey.obfuscated}
403411
</span>
404-
<CopyButton value={rootApiKey.value} variant="icon" size="small" />
412+
{rootApiKey.value ? (
413+
<CopyButton value={rootApiKey.value} variant="icon" size="small" />
414+
) : (
415+
<InfoIconTooltip
416+
content={`Root keys are being phased out. Create a new API key instead${chooseAccessHint}.`}
417+
contentClassName="max-w-xs"
418+
/>
419+
)}
405420
</div>
406421
</TableCell>
407422
<TableCell>
@@ -417,17 +432,22 @@ export default function Page() {
417432
isSticky
418433
className="w-32"
419434
hiddenButtons={
420-
<>
421-
{canWriteApiKeys ? (
422-
<RegenerateApiKeyModal
423-
id={environment.keyEnvironmentId}
424-
title={environmentFullTitle(apiKeyEnvironmentLabel)}
425-
hasVercelIntegration={hasVercelIntegration}
426-
isDevelopment={environment.type === "DEVELOPMENT"}
427-
/>
428-
) : null}
429-
<DisableRootApiKeyButton canWrite={canWriteApiKeys} />
430-
</>
435+
canManageRootApiKey ? (
436+
<>
437+
{canWriteApiKeys ? (
438+
<RegenerateApiKeyModal
439+
id={environment.keyEnvironmentId}
440+
title={environmentFullTitle(apiKeyEnvironmentLabel)}
441+
hasVercelIntegration={hasVercelIntegration}
442+
isDevelopment={environment.type === "DEVELOPMENT"}
443+
isRootApiKeyHidden={rootApiKey.isHidden}
444+
/>
445+
) : null}
446+
{rootApiKey.isHidden ? null : (
447+
<DisableRootApiKeyButton canWrite={canWriteApiKeys} />
448+
)}
449+
</>
450+
) : null
431451
}
432452
/>
433453
</TableRow>
@@ -493,19 +513,19 @@ export default function Page() {
493513
);
494514
})}
495515

496-
{hasApiKeys ? null : (
516+
{showCreateApiKeyPrompt ? (
497517
<TableBlankRow colSpan={8}>
498518
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
499519
<div className="flex flex-col items-center gap-y-1">
500-
<Header2>You don't have any API keys.</Header2>
520+
<Header2>Create an API key for your backend</Header2>
501521
<Paragraph variant="small">
502-
Create one so your backend can authenticate with Trigger.dev.
522+
{`Root keys are being phased out. Create an API key so your backend can authenticate with Trigger.dev${chooseAccessHint}.`}
503523
</Paragraph>
504524
</div>
505525
<NewApiKeyButton canWrite={canWriteApiKeys} variant="primary/medium" />
506526
</div>
507527
</TableBlankRow>
508-
)}
528+
) : null}
509529
</TableBody>
510530
</Table>
511531
</NewApiKeyDialog>
@@ -1265,8 +1285,8 @@ function DisableRootApiKeyButton({ canWrite }: { canWrite: boolean }) {
12651285
<Form method="post" className="flex flex-col gap-4 pt-3">
12661286
<input type="hidden" name="action" value="disable-root" />
12671287
<Callout variant="warning">
1268-
The root API key will no longer be displayed for this environment. A new hidden root key
1269-
will be generated, and this can't be reversed.
1288+
The full root API key will no longer be displayed for this environment, only its last 4
1289+
characters. A new hidden root key will be generated, and this can't be reversed.
12701290
</Callout>
12711291
<Paragraph>
12721292
The current root key will remain valid for 24 hours. Create and deploy an additional API

‎apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ export const action = dashboardAction(
5353
const formData = await request.formData();
5454
const syncToVercel = formData.get("syncToVercel") === "on";
5555
const revokeImmediately = formData.get("previousKey") === "revoke";
56+
const allowHidden = formData.get("rootApiKeyHidden") === "true";
5657

5758
try {
5859
const updatedEnvironment = await regenerateApiKey({
5960
userId: user.id,
6061
environmentId,
6162
revokeImmediately,
63+
allowHidden,
6264
});
6365

6466
// Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT

0 commit comments

Comments
 (0)