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:
arc53-machine committed 2026-09-21 10:32:17 +01:00
1 parent 91f2ec2f09
commit 52069e3f8a
17 files changed
+509 -6

No files matched your search

+2
View File
@@ -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',
);
});
});
+20
View File
@@ -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,
+11 -1
View File
@@ -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"
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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": "タブを左にスクロール",
+14
View File
@@ -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": "Прокрутить вкладки влево",
+14
View File
@@ -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": "向左捲動標籤",
+14
View File
@@ -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>
);
}
+51 -2
View File
@@ -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);
});
});
+25
View File
@@ -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(