GeezerVison is showing github.com — feat(webapp): show hidden root API keys masked and allow regenerating… · triggerdotdev/trigger.dev@8491915
File treeExpand file treeCollapse file tree Open diff view settings _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +6Lines changed: 6 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| | 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. |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx +15-2Lines changed: 15 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -19,6 +19,7 @@ type ModalProps = { | 19 | 19 | title: string; | 20 | 20 | hasVercelIntegration: boolean; | 21 | 21 | isDevelopment: boolean; | | 22 | + isRootApiKeyHidden?: boolean; | 22 | 23 | }; | 23 | 24 |
| 24 | 25 | type ModalContentProps = ModalProps & { | @@ -31,6 +32,7 @@ export function RegenerateApiKeyModal({ | 31 | 32 | title, | 32 | 33 | hasVercelIntegration, | 33 | 34 | isDevelopment, | | 35 | + isRootApiKeyHidden = false, | 34 | 36 | }: ModalProps) { | 35 | 37 | const randomWord = generateTwoRandomWords(); | 36 | 38 | const [open, setOpen] = useState(false); | @@ -48,6 +50,7 @@ export function RegenerateApiKeyModal({ | 48 | 50 | title={title} | 49 | 51 | hasVercelIntegration={hasVercelIntegration} | 50 | 52 | isDevelopment={isDevelopment} | | 53 | + isRootApiKeyHidden={isRootApiKeyHidden} | 51 | 54 | randomWord={randomWord} | 52 | 55 | closeModal={() => setOpen(false)} | 53 | 56 | /> | @@ -62,6 +65,7 @@ const RegenerateApiKeyModalContent = ({ | 62 | 65 | title, | 63 | 66 | hasVercelIntegration, | 64 | 67 | isDevelopment, | | 68 | + isRootApiKeyHidden, | 65 | 69 | closeModal, | 66 | 70 | }: ModalContentProps) => { | 67 | 71 | const [confirmationText, setConfirmationText] = useState(""); | @@ -83,6 +87,7 @@ const RegenerateApiKeyModalContent = ({ | 83 | 87 | action={`/resources/environments/${id}/regenerate-api-key`} | 84 | 88 | className="mt-2 w-full" | 85 | 89 | > | | 90 | + {isRootApiKeyHidden ? <input type="hidden" name="rootApiKeyHidden" value="true" /> : null} | 86 | 91 | <Fieldset className="w-full"> | 87 | 92 | <InputGroup className="max-w-full"> | 88 | 93 | <RadioGroup name="previousKey" defaultValue="grace" className="flex flex-col gap-2"> | @@ -91,14 +96,22 @@ const RegenerateApiKeyModalContent = ({ | 91 | 96 | value="grace" | 92 | 97 | variant="description" | 93 | 98 | 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 | + } | 95 | 104 | /> | 96 | 105 | <RadioGroupItem | 97 | 106 | id="previousKey-revoke" | 98 | 107 | value="revoke" | 99 | 108 | variant="description" | 100 | 109 | 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 | + } | 102 | 115 | /> | 103 | 116 | </RadioGroup> | 104 | 117 | </InputGroup> | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/models/api-key.server.ts +27-11Lines changed: 27 additions & 11 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -27,6 +27,7 @@ type RootApiKeyMutationInput = { | 27 | 27 |
| 28 | 28 | type RegenerateApiKeyInput = RootApiKeyMutationInput & { | 29 | 29 | revokeImmediately?: boolean; | | 30 | + allowHidden?: boolean; | 30 | 31 | }; | 31 | 32 |
| 32 | 33 | export class RootApiKeyNotVisibleError extends Error { | @@ -38,7 +39,8 @@ export class RootApiKeyNotVisibleError extends Error { | 38 | 39 |
| 39 | 40 | async function findRootApiKeyEnvironment( | 40 | 41 | { userId, environmentId }: RootApiKeyMutationInput, | 41 | | - prismaClient: PrismaClient | | 42 | + prismaClient: PrismaClient, | | 43 | + { allowHidden = false }: { allowHidden?: boolean } = {} | 42 | 44 | ) { | 43 | 45 | const requestedEnvironment = await prismaClient.runtimeEnvironment.findFirst({ | 44 | 46 | where: { | @@ -86,16 +88,17 @@ async function findRootApiKeyEnvironment( | 86 | 88 | throw new Error("User does not have permission to manage this root API key"); | 87 | 89 | } | 88 | 90 |
| 89 | | - if (environment.rootApiKeyHiddenAt) { | | 91 | + if (environment.rootApiKeyHiddenAt && !allowHidden) { | 90 | 92 | throw new RootApiKeyNotVisibleError(); | 91 | 93 | } | 92 | 94 |
| 93 | 95 | return environment; | 94 | 96 | } | 95 | 97 |
| 96 | | -async function lockVisibleRootApiKeyEnvironment( | | 98 | +async function lockRootApiKeyEnvironment( | 97 | 99 | prismaClient: PrismaTransactionClient, | 98 | | - environmentId: string | | 100 | + environmentId: string, | | 101 | + { allowHidden = false }: { allowHidden?: boolean } = {} | 99 | 102 | ) { | 100 | 103 | const [environment] = await prismaClient.$queryRaw< | 101 | 104 | Array<{ apiKey: string; rootApiKeyHiddenAt: Date | null }> | @@ -106,18 +109,18 @@ async function lockVisibleRootApiKeyEnvironment( | 106 | 109 | FOR UPDATE | 107 | 110 | `; | 108 | 111 |
| 109 | | - if (!environment || environment.rootApiKeyHiddenAt) { | | 112 | + if (!environment || (environment.rootApiKeyHiddenAt && !allowHidden)) { | 110 | 113 | throw new RootApiKeyNotVisibleError(); | 111 | 114 | } | 112 | 115 |
| 113 | 116 | return environment; | 114 | 117 | } | 115 | 118 |
| 116 | 119 | export async function regenerateApiKey( | 117 | | - { revokeImmediately = false, ...input }: RegenerateApiKeyInput, | | 120 | + { revokeImmediately = false, allowHidden = false, ...input }: RegenerateApiKeyInput, | 118 | 121 | { prismaClient = prisma }: { prismaClient?: PrismaClient } = {} | 119 | 122 | ) { | 120 | | - const environment = await findRootApiKeyEnvironment(input, prismaClient); | | 123 | + const environment = await findRootApiKeyEnvironment(input, prismaClient, { allowHidden }); | 121 | 124 | const newApiKey = createApiKeyForEnv(environment.type); | 122 | 125 | const newPkApiKey = createPkApiKeyForEnv(environment.type); | 123 | 126 | const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS); | @@ -126,7 +129,9 @@ export async function regenerateApiKey( | 126 | 129 | prismaClient, | 127 | 130 | "regenerate root API key", | 128 | 131 | async (tx) => { | 129 | | - const currentEnvironment = await lockVisibleRootApiKeyEnvironment(tx, environment.id); | | 132 | + const currentEnvironment = await lockRootApiKeyEnvironment(tx, environment.id, { | | 133 | + allowHidden, | | 134 | + }); | 130 | 135 |
| 131 | 136 | await tx.runtimeEnvironment.update({ | 132 | 137 | where: { id: environment.id }, | @@ -136,7 +141,13 @@ export async function regenerateApiKey( | 136 | 141 | }, | 137 | 142 | }); | 138 | 143 |
| 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 { | 140 | 151 | await tx.revokedApiKey.create({ | 141 | 152 | data: { | 142 | 153 | apiKey: currentEnvironment.apiKey, | @@ -146,7 +157,12 @@ export async function regenerateApiKey( | 146 | 157 | }); | 147 | 158 | } | 148 | 159 |
| 149 | | - return { ...environment, apiKey: newApiKey, pkApiKey: newPkApiKey }; | | 160 | + return { | | 161 | + ...environment, | | 162 | + apiKey: newApiKey, | | 163 | + pkApiKey: newPkApiKey, | | 164 | + rootApiKeyHiddenAt: currentEnvironment.rootApiKeyHiddenAt, | | 165 | + }; | 150 | 166 | } | 151 | 167 | ); | 152 | 168 |
| @@ -172,7 +188,7 @@ export async function disableRootApiKeyVisibility( | 172 | 188 | prismaClient, | 173 | 189 | "disable root API key visibility", | 174 | 190 | async (tx) => { | 175 | | - const currentEnvironment = await lockVisibleRootApiKeyEnvironment(tx, environment.id); | | 191 | + const currentEnvironment = await lockRootApiKeyEnvironment(tx, environment.id); | 176 | 192 |
| 177 | 193 | await tx.runtimeEnvironment.update({ | 178 | 194 | where: { id: environment.id }, | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +9-9Lines changed: 9 additions & 9 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -120,19 +120,19 @@ export class ApiKeysPresenter { | 120 | 120 | ]); | 121 | 121 | const presetsById = new Map(presets?.map((preset) => [preset.id, preset])); | 122 | 122 | 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 | + }; | 131 | 131 |
| 132 | 132 | return { | 133 | 133 | environment: { | 134 | 134 | ...environmentData, | 135 | | - apiKey: rootApiKey?.value ?? null, | | 135 | + apiKey: rootApiKey.value, | 136 | 136 | keyEnvironmentId, | 137 | 137 | }, | 138 | 138 | availableTasks: taskIdentifiers.map((task) => task.slug), | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +41-21Lines changed: 41 additions & 21 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -54,6 +54,7 @@ import { | 54 | 54 | TableHeaderCell, | 55 | 55 | TableRow, | 56 | 56 | } from "~/components/primitives/Table"; | | 57 | +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; | 57 | 58 | import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; | 58 | 59 | import { | 59 | 60 | createEnvironmentApiKey, | @@ -329,7 +330,14 @@ export default function Page() { | 329 | 330 | .join("\n") | 330 | 331 | : null; | 331 | 332 |
| 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" : ""; | 333 | 341 |
| 334 | 342 | return ( | 335 | 343 | <PageContainer> | @@ -378,9 +386,9 @@ export default function Page() { | 378 | 386 | /> | 379 | 387 | ) : null} | 380 | 388 | <RevokedFilter checked={showRevoked} /> | 381 | | - {hasApiKeys ? ( | | 389 | + {showCreateApiKeyPrompt ? null : ( | 382 | 390 | <NewApiKeyButton canWrite={canWriteApiKeys} variant="primary/small" /> | 383 | | - ) : null} | | 391 | + )} | 384 | 392 | </div> | 385 | 393 | </div> | 386 | 394 |
| @@ -401,7 +409,14 @@ export default function Page() { | 401 | 409 | <span className="font-mono text-text-dimmed"> | 402 | 410 | {rootApiKey.obfuscated} | 403 | 411 | </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 | + )} | 405 | 420 | </div> | 406 | 421 | </TableCell> | 407 | 422 | <TableCell> | @@ -417,17 +432,22 @@ export default function Page() { | 417 | 432 | isSticky | 418 | 433 | className="w-32" | 419 | 434 | 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 | 431 | 451 | } | 432 | 452 | /> | 433 | 453 | </TableRow> | @@ -493,19 +513,19 @@ export default function Page() { | 493 | 513 | ); | 494 | 514 | })} | 495 | 515 |
| 496 | | - {hasApiKeys ? null : ( | | 516 | + {showCreateApiKeyPrompt ? ( | 497 | 517 | <TableBlankRow colSpan={8}> | 498 | 518 | <div className="flex flex-col items-center justify-center gap-y-4 py-8"> | 499 | 519 | <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> | 501 | 521 | <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}.`} | 503 | 523 | </Paragraph> | 504 | 524 | </div> | 505 | 525 | <NewApiKeyButton canWrite={canWriteApiKeys} variant="primary/medium" /> | 506 | 526 | </div> | 507 | 527 | </TableBlankRow> | 508 | | - )} | | 528 | + ) : null} | 509 | 529 | </TableBody> | 510 | 530 | </Table> | 511 | 531 | </NewApiKeyDialog> | @@ -1265,8 +1285,8 @@ function DisableRootApiKeyButton({ canWrite }: { canWrite: boolean }) { | 1265 | 1285 | <Form method="post" className="flex flex-col gap-4 pt-3"> | 1266 | 1286 | <input type="hidden" name="action" value="disable-root" /> | 1267 | 1287 | <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. | 1270 | 1290 | </Callout> | 1271 | 1291 | <Paragraph> | 1272 | 1292 | The current root key will remain valid for 24 hours. Create and deploy an additional API | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +2Lines changed: 2 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -53,12 +53,14 @@ export const action = dashboardAction( | 53 | 53 | const formData = await request.formData(); | 54 | 54 | const syncToVercel = formData.get("syncToVercel") === "on"; | 55 | 55 | const revokeImmediately = formData.get("previousKey") === "revoke"; | | 56 | + const allowHidden = formData.get("rootApiKeyHidden") === "true"; | 56 | 57 |
| 57 | 58 | try { | 58 | 59 | const updatedEnvironment = await regenerateApiKey({ | 59 | 60 | userId: user.id, | 60 | 61 | environmentId, | 61 | 62 | revokeImmediately, | | 63 | + allowHidden, | 62 | 64 | }); | 63 | 65 |
| 64 | 66 | // Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT | |
|