mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
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.
This commit is contained in:
1 parent
91f2ec2f09
commit
52069e3f8a
17 files changed
+509
-6
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<CreateAccessTokenResponse> =>
|
||||
parse<CreateAccessTokenResponse>(
|
||||
await apiClient.post(
|
||||
endpoints.USER.ACCESS_TOKEN_REGENERATE(encodeURIComponent(id)),
|
||||
expiresInDays === undefined ? {} : { expires_in_days: expiresInDays },
|
||||
token,
|
||||
),
|
||||
),
|
||||
|
||||
revoke: async (
|
||||
id: string,
|
||||
token: string | null,
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal={modal}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -833,6 +833,20 @@
|
||||
"usageTitle": "So verwendest du es",
|
||||
"usageHint": "Exportiere es als Umgebungsvariable und sende es als Bearer-Token im Authorization-Header.",
|
||||
"done": "Fertig"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Neu generieren",
|
||||
"aria": "Token {{name}} neu generieren",
|
||||
"title": "Token neu generieren",
|
||||
"warning": "Neues Geheimnis für \"{{name}}\" erzeugen? Das aktuelle Geheimnis funktioniert sofort nicht mehr, alles, was es verwendet, muss aktualisiert werden. Name, Berechtigungen und Einschränkungen bleiben gleich.",
|
||||
"newExpiration": "Neues Ablaufdatum",
|
||||
"submit": "Neu generieren",
|
||||
"submitting": "Wird neu generiert…",
|
||||
"error": "Das Token konnte nicht neu generiert werden. Bitte erneut versuchen.",
|
||||
"done": {
|
||||
"title": "Token neu generiert",
|
||||
"subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Tabs nach links scrollen",
|
||||
|
||||
@@ -838,6 +838,20 @@
|
||||
"usageTitle": "How to use it",
|
||||
"usageHint": "Export it as an environment variable, then send it as a Bearer token in the Authorization header.",
|
||||
"done": "Done"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Regenerate",
|
||||
"aria": "Regenerate token {{name}}",
|
||||
"title": "Regenerate token",
|
||||
"warning": "Generate a new secret for \"{{name}}\"? The current secret stops working immediately, so anything using it must be updated. The name, scopes and restrictions stay the same.",
|
||||
"newExpiration": "New expiration",
|
||||
"submit": "Regenerate",
|
||||
"submitting": "Regenerating…",
|
||||
"error": "Failed to regenerate the token. Please try again.",
|
||||
"done": {
|
||||
"title": "Token regenerated",
|
||||
"subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
|
||||
@@ -833,6 +833,20 @@
|
||||
"usageTitle": "Cómo usarlo",
|
||||
"usageHint": "Expórtalo como variable de entorno y envíalo como token Bearer en la cabecera Authorization.",
|
||||
"done": "Listo"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Regenerar",
|
||||
"aria": "Regenerar el token {{name}}",
|
||||
"title": "Regenerar token",
|
||||
"warning": "¿Generar un nuevo secreto para \"{{name}}\"? El secreto actual deja de funcionar de inmediato, así que todo lo que lo use debe actualizarse. El nombre, los permisos y las restricciones no cambian.",
|
||||
"newExpiration": "Nueva caducidad",
|
||||
"submit": "Regenerar",
|
||||
"submitting": "Regenerando…",
|
||||
"error": "No se pudo regenerar el token. Inténtalo de nuevo.",
|
||||
"done": {
|
||||
"title": "Token regenerado",
|
||||
"subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Desplazar pestañas a la izquierda",
|
||||
|
||||
@@ -833,6 +833,20 @@
|
||||
"usageTitle": "使い方",
|
||||
"usageHint": "環境変数としてエクスポートし、AuthorizationヘッダーのBearerトークンとして送信します。",
|
||||
"done": "完了"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "再生成",
|
||||
"aria": "トークン {{name}} を再生成",
|
||||
"title": "トークンを再生成",
|
||||
"warning": "「{{name}}」の新しいシークレットを生成しますか?現在のシークレットは直ちに無効になるため、使用している箇所をすべて更新する必要があります。名前、スコープ、制限は変わりません。",
|
||||
"newExpiration": "新しい有効期限",
|
||||
"submit": "再生成",
|
||||
"submitting": "再生成中…",
|
||||
"error": "トークンを再生成できませんでした。もう一度お試しください。",
|
||||
"done": {
|
||||
"title": "トークンを再生成しました",
|
||||
"subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "タブを左にスクロール",
|
||||
|
||||
@@ -853,6 +853,20 @@
|
||||
"usageTitle": "Как использовать",
|
||||
"usageHint": "Экспортируйте его как переменную окружения и передавайте как Bearer-токен в заголовке Authorization.",
|
||||
"done": "Готово"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Перевыпустить",
|
||||
"aria": "Перевыпустить токен {{name}}",
|
||||
"title": "Перевыпуск токена",
|
||||
"warning": "Создать новый секрет для «{{name}}»? Текущий секрет сразу перестанет работать, поэтому всё, что его использует, нужно обновить. Имя, права и ограничения останутся прежними.",
|
||||
"newExpiration": "Новый срок действия",
|
||||
"submit": "Перевыпустить",
|
||||
"submitting": "Перевыпуск…",
|
||||
"error": "Не удалось перевыпустить токен. Попробуйте ещё раз.",
|
||||
"done": {
|
||||
"title": "Токен перевыпущен",
|
||||
"subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Прокрутить вкладки влево",
|
||||
|
||||
@@ -833,6 +833,20 @@
|
||||
"usageTitle": "使用方式",
|
||||
"usageHint": "將其匯出為環境變數,然後在 Authorization 標頭中作為 Bearer 權杖傳送。",
|
||||
"done": "完成"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "重新產生",
|
||||
"aria": "重新產生權杖 {{name}}",
|
||||
"title": "重新產生權杖",
|
||||
"warning": "要為「{{name}}」產生新的密鑰嗎?目前的密鑰會立即失效,所有使用它的地方都需要更新。名稱、權限範圍和限制維持不變。",
|
||||
"newExpiration": "新的到期時間",
|
||||
"submit": "重新產生",
|
||||
"submitting": "正在重新產生…",
|
||||
"error": "重新產生權杖失敗,請再試一次。",
|
||||
"done": {
|
||||
"title": "權杖已重新產生",
|
||||
"subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "向左捲動標籤",
|
||||
|
||||
@@ -833,6 +833,20 @@
|
||||
"usageTitle": "使用方法",
|
||||
"usageHint": "将其导出为环境变量,然后在 Authorization 请求头中作为 Bearer 令牌发送。",
|
||||
"done": "完成"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "重新生成",
|
||||
"aria": "重新生成令牌 {{name}}",
|
||||
"title": "重新生成令牌",
|
||||
"warning": "要为“{{name}}”生成新的密钥吗?当前密钥将立即失效,所有使用它的地方都需要更新。名称、权限范围和限制保持不变。",
|
||||
"newExpiration": "新的过期时间",
|
||||
"submit": "重新生成",
|
||||
"submitting": "正在重新生成…",
|
||||
"error": "重新生成令牌失败,请重试。",
|
||||
"done": {
|
||||
"title": "令牌已重新生成",
|
||||
"subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "向左滚动标签",
|
||||
|
||||
@@ -11,15 +11,21 @@ interface AccessTokenCreatedModalProps {
|
||||
/** Plaintext secret; `null` keeps the modal closed. Held only in the parent's component state. */
|
||||
token: string | null;
|
||||
name: string;
|
||||
/** The secret replaces an existing token's secret rather than belonging to a new token. */
|
||||
regenerated?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AccessTokenCreatedModal({
|
||||
token,
|
||||
name,
|
||||
regenerated = false,
|
||||
onClose,
|
||||
}: AccessTokenCreatedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const copy = regenerated
|
||||
? 'settings.accessTokens.regenerate.done'
|
||||
: 'settings.accessTokens.created';
|
||||
const exportSnippet = `export DOCSGPT_TOKEN=${token ?? ''}`;
|
||||
const curlSnippet = `curl -H "Authorization: Bearer $DOCSGPT_TOKEN" ${baseURL}/api/get_agents`;
|
||||
|
||||
@@ -28,7 +34,7 @@ export default function AccessTokenCreatedModal({
|
||||
open={token !== null}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
hideTitle
|
||||
title={t('settings.accessTokens.created.title')}
|
||||
title={t(`${copy}.title`)}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
// The secret cannot be shown again, so a stray click outside must not
|
||||
@@ -47,10 +53,10 @@ export default function AccessTokenCreatedModal({
|
||||
<div className="flex flex-col gap-5 px-1">
|
||||
<div>
|
||||
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
|
||||
{t('settings.accessTokens.created.title')}
|
||||
{t(`${copy}.title`)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm break-words">
|
||||
{t('settings.accessTokens.created.subtitle', {
|
||||
{t(`${copy}.subtitle`, {
|
||||
name,
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
|
||||
@@ -442,6 +442,7 @@ export default function CreateAccessTokenModal({
|
||||
'settings.accessTokens.create.searchResources',
|
||||
)}
|
||||
className="rounded-xl"
|
||||
modal
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import patService, {
|
||||
AccessTokenApiError,
|
||||
AccessTokenPolicy,
|
||||
CreateAccessTokenResponse,
|
||||
PersonalAccessToken,
|
||||
} from '../api/services/patService';
|
||||
import Spinner from '../components/Spinner';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
expiryOptions,
|
||||
NO_ESCAPE,
|
||||
NO_EXPIRY,
|
||||
renewalExpiry,
|
||||
} from '../settings/accessTokenUtils';
|
||||
import { formatDateOnly } from '../utils/dateTimeUtils';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface RegenerateAccessTokenModalProps {
|
||||
/** Token to regenerate; `null` keeps the modal closed. */
|
||||
item: PersonalAccessToken | null;
|
||||
policy: AccessTokenPolicy;
|
||||
onClose: () => void;
|
||||
onRegenerated: (response: CreateAccessTokenResponse) => void;
|
||||
}
|
||||
|
||||
export default function RegenerateAccessTokenModal({
|
||||
item,
|
||||
policy,
|
||||
onClose,
|
||||
onRegenerated,
|
||||
}: RegenerateAccessTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [expiry, setExpiry] = React.useState<number>(NO_EXPIRY);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const expiryChoices = React.useMemo(() => expiryOptions(policy), [policy]);
|
||||
|
||||
// Preselect the lifetime the token was last issued with.
|
||||
React.useEffect(() => {
|
||||
if (!item) return;
|
||||
setExpiry(renewalExpiry(item, policy));
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [item, policy]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!submitting) onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
onRegenerated(await patService.regenerate(item.id, expiry, token));
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate access token:', err);
|
||||
setError(
|
||||
(err instanceof AccessTokenApiError && err.message) ||
|
||||
t('settings.accessTokens.regenerate.error'),
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const expiryLabel = (days: number) =>
|
||||
days === NO_EXPIRY
|
||||
? t('settings.accessTokens.create.noExpiration')
|
||||
: t('settings.accessTokens.create.expiryDays', { count: days });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={item !== null}
|
||||
onOpenChange={(o) => !o && handleClose()}
|
||||
hideTitle
|
||||
title={t('settings.accessTokens.regenerate.title')}
|
||||
size="md"
|
||||
mobileVariant="sheet"
|
||||
isPerformingTask={submitting}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-3xl px-6"
|
||||
>
|
||||
{t('settings.accessTokens.create.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="rounded-3xl px-6 text-white"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="small" />
|
||||
{t('settings.accessTokens.regenerate.submitting')}
|
||||
</span>
|
||||
) : (
|
||||
t('settings.accessTokens.regenerate.submit')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-1">
|
||||
<div>
|
||||
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
|
||||
{t('settings.accessTokens.regenerate.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm break-words">
|
||||
{t('settings.accessTokens.regenerate.warning', {
|
||||
name: item?.name ?? '',
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pat-regenerate-expiry">
|
||||
{t('settings.accessTokens.regenerate.newExpiration')}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(expiry)}
|
||||
onValueChange={(value) => setExpiry(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="pat-regenerate-expiry"
|
||||
className="w-full rounded-xl px-4 py-2"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{expiryChoices.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
{expiryLabel(days)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
expiry === NO_EXPIRY
|
||||
? 'text-amber-700 dark:text-amber-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<CreateAccessTokenResponse | null>(null);
|
||||
// True while `created` holds a regenerated (not brand-new) secret.
|
||||
const [createdByRegenerate, setCreatedByRegenerate] = React.useState(false);
|
||||
const [tokenToRegenerate, setTokenToRegenerate] =
|
||||
React.useState<PersonalAccessToken | null>(null);
|
||||
const [revokeState, setRevokeState] = React.useState<ActiveState>('INACTIVE');
|
||||
const [tokenToRevoke, setTokenToRevoke] =
|
||||
React.useState<PersonalAccessToken | null>(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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full px-4"
|
||||
onClick={() => setTokenToRegenerate(item)}
|
||||
aria-label={t('settings.accessTokens.regenerate.aria', {
|
||||
name: item.name,
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
>
|
||||
{t('settings.accessTokens.regenerate.action')}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const renderRevokeButton = (item: PersonalAccessToken) => (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -373,7 +407,10 @@ export default function PersonalAccessTokens() {
|
||||
{renderExpiry(item)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{renderRevokeButton(item)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{renderRegenerateButton(item)}
|
||||
{renderRevokeButton(item)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -398,7 +435,6 @@ export default function PersonalAccessTokens() {
|
||||
</p>
|
||||
{renderPrefix(item)}
|
||||
</div>
|
||||
{renderRevokeButton(item)}
|
||||
</div>
|
||||
<ScopeChips scopes={item.scopes} />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -419,6 +455,10 @@ export default function PersonalAccessTokens() {
|
||||
renderExpiry(item),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{renderRegenerateButton(item)}
|
||||
{renderRevokeButton(item)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -438,8 +478,17 @@ export default function PersonalAccessTokens() {
|
||||
<AccessTokenCreatedModal
|
||||
token={created?.token ?? null}
|
||||
name={created?.personal_access_token.name ?? ''}
|
||||
regenerated={createdByRegenerate}
|
||||
onClose={() => setCreated(null)}
|
||||
/>
|
||||
{policy && (
|
||||
<RegenerateAccessTokenModal
|
||||
item={tokenToRegenerate}
|
||||
policy={policy}
|
||||
onClose={() => setTokenToRegenerate(null)}
|
||||
onRegenerated={handleRegenerated}
|
||||
/>
|
||||
)}
|
||||
<ConfirmationModal
|
||||
message={t('settings.accessTokens.revokeWarning', {
|
||||
name: tokenToRevoke?.name ?? '',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
NO_ESCAPE,
|
||||
NO_EXPIRY,
|
||||
relativeTime,
|
||||
renewalExpiry,
|
||||
restrictionCounts,
|
||||
scopesToSubmit,
|
||||
toResourceOptions,
|
||||
@@ -305,3 +306,59 @@ describe('eligibleFilterFamilies with chat:run', () => {
|
||||
expect(eligibleFilterFamilies(['tools:write'], all)).toEqual(['tools']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewalExpiry', () => {
|
||||
const policy = {
|
||||
default_lifetime_days: 90,
|
||||
max_lifetime_days: 365,
|
||||
allow_non_expiring: false,
|
||||
};
|
||||
const issued = '2026-09-01T10:00:00+00:00';
|
||||
const plusDays = (days: number) =>
|
||||
new Date(Date.parse(issued) + days * 86400000).toISOString();
|
||||
|
||||
it('preselects the lifetime the token was issued with', () => {
|
||||
expect(
|
||||
renewalExpiry({ created_at: issued, expires_at: plusDays(30) }, policy),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
it('counts from the last regeneration, not from creation', () => {
|
||||
expect(
|
||||
renewalExpiry(
|
||||
{
|
||||
created_at: '2026-01-01T00:00:00+00:00',
|
||||
regenerated_at: issued,
|
||||
expires_at: plusDays(7),
|
||||
},
|
||||
policy,
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to the default for a lifetime the policy no longer offers', () => {
|
||||
expect(
|
||||
renewalExpiry({ created_at: issued, expires_at: plusDays(45) }, policy),
|
||||
).toBe(90);
|
||||
expect(
|
||||
renewalExpiry(
|
||||
{ created_at: issued, expires_at: plusDays(365) },
|
||||
{ ...policy, max_lifetime_days: 180 },
|
||||
),
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it('keeps a non-expiring token non-expiring only while that is allowed', () => {
|
||||
const item = { created_at: issued, expires_at: null };
|
||||
expect(renewalExpiry(item, policy)).toBe(90);
|
||||
expect(renewalExpiry(item, { ...policy, allow_non_expiring: true })).toBe(
|
||||
NO_EXPIRY,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the default on unparseable dates', () => {
|
||||
expect(
|
||||
renewalExpiry({ created_at: null, expires_at: plusDays(30) }, policy),
|
||||
).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,31 @@ export function defaultExpiry(policy: ExpiryPolicy): number {
|
||||
return finite.length > 0 ? finite[finite.length - 1] : NO_EXPIRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifetime to preselect when regenerating: what the token was last issued
|
||||
* with, when the policy still offers it; otherwise the policy default.
|
||||
*/
|
||||
export function renewalExpiry(
|
||||
item: {
|
||||
created_at: string | null;
|
||||
regenerated_at?: string | null;
|
||||
expires_at: string | null;
|
||||
},
|
||||
policy: ExpiryPolicy,
|
||||
): number {
|
||||
const options = expiryOptions(policy);
|
||||
if (!item.expires_at) {
|
||||
return options.includes(NO_EXPIRY) ? NO_EXPIRY : defaultExpiry(policy);
|
||||
}
|
||||
const issued = Date.parse(item.regenerated_at || item.created_at || '');
|
||||
const expires = Date.parse(item.expires_at);
|
||||
if (Number.isNaN(issued) || Number.isNaN(expires)) {
|
||||
return defaultExpiry(policy);
|
||||
}
|
||||
const days = Math.round((expires - issued) / DAY_MS);
|
||||
return options.includes(days) ? days : defaultExpiry(policy);
|
||||
}
|
||||
|
||||
export type ExpiryStatus = 'never' | 'expired' | 'expiringSoon' | 'ok';
|
||||
|
||||
export function expiryStatus(
|
||||
|
||||
Reference in new issue
Block a user