From 52069e3f8a8a2aec4a22234712b84c33e2510d68 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:32:17 +0100 Subject: [PATCH] feat(frontend): regenerate access tokens; fix resource pickers inside the create modal Each token gets a Regenerate action: a confirmation with the new expiration (preselecting the lifetime the token was issued with), then the one-time secret view. The "restrict to specific resources" pickers could not be scrolled and did not close on an outside click. Inside a Modal a non-modal popover is portalled outside the dialog, so the dialog's scroll lock swallowed the wheel, and Radix defers its outside-click dismissal to the document click, which Modal stops from propagating. MultiSelect takes a `modal` prop for that case. --- frontend/src/api/endpoints.ts | 2 + frontend/src/api/services/patService.test.ts | 44 ++++ frontend/src/api/services/patService.ts | 20 ++ frontend/src/components/ui/multi-select.tsx | 12 +- frontend/src/locale/de.json | 14 ++ frontend/src/locale/en.json | 14 ++ frontend/src/locale/es.json | 14 ++ frontend/src/locale/jp.json | 14 ++ frontend/src/locale/ru.json | 14 ++ frontend/src/locale/zh-TW.json | 14 ++ frontend/src/locale/zh.json | 14 ++ .../src/modals/AccessTokenCreatedModal.tsx | 12 +- .../src/modals/CreateAccessTokenModal.tsx | 1 + .../src/modals/RegenerateAccessTokenModal.tsx | 191 ++++++++++++++++++ .../src/settings/PersonalAccessTokens.tsx | 53 ++++- .../src/settings/accessTokenUtils.test.ts | 57 ++++++ frontend/src/settings/accessTokenUtils.ts | 25 +++ 17 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 frontend/src/modals/RegenerateAccessTokenModal.tsx diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index cd19899c..6f5f36e6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -154,6 +154,8 @@ const endpoints = { `/api/devices/pairings/${deviceCode}`, ACCESS_TOKENS: '/api/user/tokens', ACCESS_TOKEN: (id: string) => `/api/user/tokens/${id}`, + ACCESS_TOKEN_REGENERATE: (id: string) => + `/api/user/tokens/${id}/regenerate`, }, V1: { CHAT_COMPLETIONS: '/v1/chat/completions', diff --git a/frontend/src/api/services/patService.test.ts b/frontend/src/api/services/patService.test.ts index 63b7a88e..e6f479c9 100644 --- a/frontend/src/api/services/patService.test.ts +++ b/frontend/src/api/services/patService.test.ts @@ -154,3 +154,47 @@ describe('patService.revoke', () => { ); }); }); + +describe('patService.regenerate', () => { + it('POSTs the chosen lifetime to the regenerate endpoint', async () => { + const body = { + success: true, + token: 'dgpt_pat_newsecret', + personal_access_token: TOKEN_ROW, + }; + const spy = vi.spyOn(apiClient, 'post').mockResolvedValue(response(body)); + + const result = await patService.regenerate(TOKEN_ROW.id, 30, 'session-jwt'); + + expect(spy).toHaveBeenCalledWith( + `/api/user/tokens/${TOKEN_ROW.id}/regenerate`, + { expires_in_days: 30 }, + 'session-jwt', + ); + expect(result.token).toBe('dgpt_pat_newsecret'); + }); + + it('sends an empty body to keep the original lifetime', async () => { + const spy = vi + .spyOn(apiClient, 'post') + .mockResolvedValue(response({ success: true, token: 't' })); + + await patService.regenerate(TOKEN_ROW.id, undefined, null); + + expect(spy).toHaveBeenCalledWith( + `/api/user/tokens/${TOKEN_ROW.id}/regenerate`, + {}, + null, + ); + }); + + it('surfaces the server message', async () => { + vi.spyOn(apiClient, 'post').mockResolvedValue( + response({ success: false, message: 'Token not found' }, 404), + ); + + await expect(patService.regenerate('missing', 30, null)).rejects.toThrow( + 'Token not found', + ); + }); +}); diff --git a/frontend/src/api/services/patService.ts b/frontend/src/api/services/patService.ts index 09eb197a..fda7017f 100644 --- a/frontend/src/api/services/patService.ts +++ b/frontend/src/api/services/patService.ts @@ -14,6 +14,8 @@ export interface PersonalAccessToken { last_used_at: string | null; last_used_ip: string | null; created_at: string | null; + /** Set once the secret has been regenerated; the lifetime then counts from here. */ + regenerated_at?: string | null; revoked_at: string | null; } @@ -95,6 +97,24 @@ const patService = { await apiClient.post(endpoints.USER.ACCESS_TOKENS, payload, token), ), + /** + * New secret for the same token (name, scopes and restrictions stay); the old + * secret stops working at once. `expiresInDays` omitted = the lifetime the + * token was last issued with. + */ + regenerate: async ( + id: string, + expiresInDays: number | undefined, + token: string | null, + ): Promise => + parse( + await apiClient.post( + endpoints.USER.ACCESS_TOKEN_REGENERATE(encodeURIComponent(id)), + expiresInDays === undefined ? {} : { expires_in_days: expiresInDays }, + token, + ), + ), + revoke: async ( id: string, token: string | null, diff --git a/frontend/src/components/ui/multi-select.tsx b/frontend/src/components/ui/multi-select.tsx index 826c658d..e436dd8f 100644 --- a/frontend/src/components/ui/multi-select.tsx +++ b/frontend/src/components/ui/multi-select.tsx @@ -32,6 +32,15 @@ interface MultiSelectProps { emptyText?: string; searchPlaceholder?: string; className?: string; + /** + * Set when the MultiSelect sits inside a Modal. A non-modal popover there + * cannot scroll (the dialog's scroll lock swallows the wheel, since the + * dropdown is portalled outside it) and never closes on an outside click + * (Radix defers that to the document `click`, which Modal stops from + * propagating). A modal popover owns its own scroll lock and dismisses on + * pointerdown instead. + */ + modal?: boolean; } export function MultiSelect({ @@ -42,6 +51,7 @@ export function MultiSelect({ emptyText = 'No results found.', searchPlaceholder = 'Search...', className, + modal = false, }: MultiSelectProps) { const [open, setOpen] = React.useState(false); @@ -63,7 +73,7 @@ export function MultiSelect({ .map((option) => option.label); return ( - + + + + } + > +
+
+

+ {t('settings.accessTokens.regenerate.title')} +

+

+ {t('settings.accessTokens.regenerate.warning', { + name: item?.name ?? '', + ...NO_ESCAPE, + })} +

+
+ +
+ + +

+ {expiry === NO_EXPIRY + ? t('settings.accessTokens.create.noExpirationHint') + : t('settings.accessTokens.create.expiresOn', { + date: formatDateOnly( + new Date(Date.now() + expiry * DAY_MS).toISOString(), + ), + ...NO_ESCAPE, + })} +

+
+ + {error && ( +

+ {error} +

+ )} +
+ + ); +} diff --git a/frontend/src/settings/PersonalAccessTokens.tsx b/frontend/src/settings/PersonalAccessTokens.tsx index f417200c..27cebbb0 100644 --- a/frontend/src/settings/PersonalAccessTokens.tsx +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -28,6 +28,7 @@ import { useDarkTheme } from '../hooks'; import AccessTokenCreatedModal from '../modals/AccessTokenCreatedModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import CreateAccessTokenModal from '../modals/CreateAccessTokenModal'; +import RegenerateAccessTokenModal from '../modals/RegenerateAccessTokenModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils'; @@ -89,6 +90,10 @@ export default function PersonalAccessTokens() { // The plaintext secret lives only here, and only until the modal closes. const [created, setCreated] = React.useState(null); + // True while `created` holds a regenerated (not brand-new) secret. + const [createdByRegenerate, setCreatedByRegenerate] = React.useState(false); + const [tokenToRegenerate, setTokenToRegenerate] = + React.useState(null); const [revokeState, setRevokeState] = React.useState('INACTIVE'); const [tokenToRevoke, setTokenToRevoke] = React.useState(null); @@ -116,8 +121,20 @@ export default function PersonalAccessTokens() { loadTokens(true); }, [loadTokens]); + const handleRegenerated = (response: CreateAccessTokenResponse) => { + const updated = response.personal_access_token; + setTokenToRegenerate(null); + setCreatedByRegenerate(true); + setCreated(response); + setTokens((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)), + ); + setError(null); + }; + const handleCreated = (response: CreateAccessTokenResponse) => { setCreateOpen(false); + setCreatedByRegenerate(false); setCreated(response); setTokens((prev) => [response.personal_access_token, ...prev]); }; @@ -212,6 +229,23 @@ export default function PersonalAccessTokens() { ); }; + const renderRegenerateButton = (item: PersonalAccessToken) => + policy?.enabled ? ( + + ) : null; + const renderRevokeButton = (item: PersonalAccessToken) => (