@cosmicdrift/kumiko-bundled-features 0.108.0 → 0.109.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.108.0",
3
+ "version": "0.109.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -95,11 +95,11 @@
95
95
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
96
96
  },
97
97
  "dependencies": {
98
- "@cosmicdrift/kumiko-dispatcher-live": "0.108.0",
99
- "@cosmicdrift/kumiko-framework": "0.108.0",
100
- "@cosmicdrift/kumiko-headless": "0.108.0",
101
- "@cosmicdrift/kumiko-renderer": "0.108.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.108.0",
98
+ "@cosmicdrift/kumiko-dispatcher-live": "0.109.0",
99
+ "@cosmicdrift/kumiko-framework": "0.109.0",
100
+ "@cosmicdrift/kumiko-headless": "0.109.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.109.0",
102
+ "@cosmicdrift/kumiko-renderer-web": "0.109.0",
103
103
  "@mollie/api-client": "^4.5.0",
104
104
  "@node-rs/argon2": "^2.0.2",
105
105
  "@types/nodemailer": "^8.0.0",
@@ -49,10 +49,11 @@ let patResolver: PatResolver | undefined;
49
49
  const encryptionKey = randomBytes(32).toString("base64");
50
50
  const TENANT: TenantId = testTenantId(1);
51
51
 
52
- // One scope "tokens" grants exactly the two PAT queries — deliberately NOT
53
- // sessions:query:user-session:mine, so that QN is the out-of-scope probe.
52
+ // One domain "tokens" whose read set is exactly the two PAT queries —
53
+ // deliberately NOT sessions:query:user-session:mine, so that QN is the
54
+ // out-of-scope probe. Granted as "tokens:read".
54
55
  const SCOPES: PatScopeConfig = {
55
- tokens: { label: "Tokens", qns: [PatQueries.mine, PatQueries.availableScopes] },
56
+ tokens: { label: "Tokens", read: [PatQueries.mine, PatQueries.availableScopes] },
56
57
  };
57
58
 
58
59
  async function mintToken(
@@ -63,7 +64,7 @@ async function mintToken(
63
64
  PatHandlers.create,
64
65
  {
65
66
  name: "test",
66
- scopes: opts?.scopes ?? ["tokens"],
67
+ scopes: opts?.scopes ?? ["tokens:read"],
67
68
  ...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
68
69
  },
69
70
  actor,
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { expandScopes, type PatScopeConfig, parseGrant } from "../scopes";
3
+
4
+ const CONFIG: PatScopeConfig = {
5
+ credit: { label: "Kredite", read: ["credit:query:*"], write: ["credit:write:*"] },
6
+ miete: { label: "Mieten", read: ["ledger:query:*"] }, // read-only domain
7
+ };
8
+
9
+ describe("parseGrant", () => {
10
+ it("splits domain:level on the last colon", () => {
11
+ expect(parseGrant("credit:write")).toEqual({ domain: "credit", level: "write" });
12
+ });
13
+ it("rejects a bare token", () => {
14
+ expect(parseGrant("credit")).toBeNull();
15
+ });
16
+ });
17
+
18
+ describe("expandScopes", () => {
19
+ it("read grants only the read QNs", () => {
20
+ expect(expandScopes(CONFIG, ["credit:read"])).toEqual(["credit:query:*"]);
21
+ });
22
+
23
+ it("write grants read + write QNs", () => {
24
+ expect(expandScopes(CONFIG, ["credit:write"]).sort()).toEqual(
25
+ ["credit:query:*", "credit:write:*"].sort(),
26
+ );
27
+ });
28
+
29
+ it("read-only domain ignores a write grant's missing write set", () => {
30
+ expect(expandScopes(CONFIG, ["miete:write"])).toEqual(["ledger:query:*"]);
31
+ });
32
+
33
+ it("unknown domain contributes nothing (fail-closed)", () => {
34
+ expect(expandScopes(CONFIG, ["ghost:write"])).toEqual([]);
35
+ });
36
+
37
+ it("unions across multiple grants without duplicates", () => {
38
+ expect(expandScopes(CONFIG, ["credit:read", "miete:read"]).sort()).toEqual(
39
+ ["credit:query:*", "ledger:query:*"].sort(),
40
+ );
41
+ });
42
+ });
@@ -2,14 +2,20 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
3
  import type { PatScopeConfig } from "../scopes";
4
4
 
5
- // Returns the app-declared scopes ({name, label}) so the mint UI can render a
6
- // checkbox per scope. Built from the config the feature was mounted with the
7
- // list is static per deployment, not per user.
5
+ // Returns the app-declared scope domains ({name, label, canWrite}) so the mint
6
+ // UI can render a per-domain level picker (no access / read / read & write).
7
+ // canWrite is false for read-only domains the UI hides the write option.
8
+ // Static per deployment, not per user.
8
9
  export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
9
10
  return defineQueryHandler({
10
11
  name: "available-scopes",
11
12
  schema: z.object({}),
12
13
  access: { openToAll: true },
13
- handler: async () => Object.entries(scopes).map(([name, def]) => ({ name, label: def.label })),
14
+ handler: async () =>
15
+ Object.entries(scopes).map(([name, def]) => ({
16
+ name,
17
+ label: def.label,
18
+ canWrite: (def.write?.length ?? 0) > 0,
19
+ })),
14
20
  });
15
21
  }
@@ -1,22 +1,43 @@
1
- // App-declared scopes. Each scope is a named bundle of QN globs the app author
2
- // wires when mounting the featurea scope may span features (e.g. a "miete"
3
- // scope granting ledger + folders QNs). The token stores granted scope NAMES;
4
- // the resolver expands them to globs at request time.
1
+ // @runtime client
2
+ // Pure scope helpers + typesclient-marked so the web screen (web/) may import
3
+ // parseGrant without pulling the feature's server runtime barrel.
4
+ //
5
+ // App-declared scopes — two axes, like GitHub fine-grained PATs: WHICH API
6
+ // (the domain, keyed here) × the permission LEVEL (read vs read+write). Each
7
+ // domain declares its read-QN globs and (optionally) its write-QN globs. A
8
+ // domain may span features (e.g. a "miete" domain granting ledger + folders).
5
9
  export type PatScopeDef = {
6
10
  readonly label: string;
7
- readonly qns: readonly string[];
11
+ readonly read: readonly string[];
12
+ // Omit for a read-only domain — the UI then offers only "no access" / "read".
13
+ readonly write?: readonly string[];
8
14
  };
9
15
 
10
16
  export type PatScopeConfig = Readonly<Record<string, PatScopeDef>>;
11
17
 
12
- // Expand granted scope names into the union of their QN globs. Unknown names
13
- // (scope dropped from config after a token was minted) contribute nothing —
14
- // fail-closed: the token silently loses that capability rather than erroring.
18
+ export type PatLevel = "read" | "write";
19
+
20
+ // A granted scope is the string "<domain>:<level>" (e.g. "credit:write"). The
21
+ // domain key never contains a colon, so split on the LAST one.
22
+ export function parseGrant(grant: string): { domain: string; level: string } | null {
23
+ const idx = grant.lastIndexOf(":");
24
+ if (idx <= 0) return null;
25
+ return { domain: grant.slice(0, idx), level: grant.slice(idx + 1) };
26
+ }
27
+
28
+ // Expand granted "<domain>:<level>" entries into the union of QN globs: read
29
+ // always grants the read QNs; write additionally grants the write QNs. Unknown
30
+ // domains contribute nothing (fail-closed — a scope dropped from config after a
31
+ // token was minted silently loses that capability).
15
32
  export function expandScopes(config: PatScopeConfig, granted: readonly string[]): string[] {
16
33
  const out = new Set<string>();
17
- for (const name of granted) {
18
- const def = config[name];
19
- if (def) for (const qn of def.qns) out.add(qn);
34
+ for (const grant of granted) {
35
+ const parsed = parseGrant(grant);
36
+ if (!parsed) continue;
37
+ const def = config[parsed.domain];
38
+ if (!def) continue;
39
+ for (const q of def.read) out.add(q);
40
+ if (parsed.level === "write") for (const q of def.write ?? []) out.add(q);
20
41
  }
21
42
  return [...out];
22
43
  }
@@ -5,41 +5,65 @@ export const defaultTranslations: TranslationsByLocale = {
5
5
  de: {
6
6
  "pat.title": "Personal Access Tokens",
7
7
  "pat.create.title": "Neuen Token erstellen",
8
+ "pat.create.subtitle":
9
+ "Wähle Berechtigungen und eine Gültigkeit. Der Token wird nur einmal angezeigt.",
8
10
  "pat.create.name": "Name",
9
11
  "pat.create.namePlaceholder": "z. B. CLI-Automatisierung",
10
- "pat.create.scopes": "Berechtigungen",
12
+ "pat.create.scopes": "Zugriff pro Bereich",
13
+ "pat.level.none": "Kein Zugriff",
14
+ "pat.level.read": "Lesen",
15
+ "pat.level.write": "Lesen & Schreiben",
16
+ "pat.create.expiry": "Gültig bis",
11
17
  "pat.create.submit": "Token erstellen",
12
18
  "pat.create.needName": "Bitte einen Namen vergeben.",
13
19
  "pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
14
- "pat.created.title": "Token erstellt — jetzt kopieren",
15
- "pat.created.hint": "Dieser Token wird nur einmal angezeigt. Kopiere ihn jetzt.",
16
- "pat.created.dismiss": "Verstanden",
20
+ "pat.expiry.30d": "30 Tage",
21
+ "pat.expiry.90d": "90 Tage",
22
+ "pat.expiry.1y": "1 Jahr",
23
+ "pat.expiry.never": "Unbegrenzt",
24
+ "pat.created.hint":
25
+ "Kopiere diesen Token jetzt — er wird aus Sicherheitsgründen nur dieses eine Mal angezeigt.",
26
+ "pat.created.copy": "Kopieren",
27
+ "pat.created.copied": "Kopiert ✓",
28
+ "pat.created.dismiss": "Fertig",
17
29
  "pat.list.title": "Deine Tokens",
18
30
  "pat.list.empty": "Noch keine Tokens.",
19
- "pat.list.created": "Erstellt",
20
- "pat.list.revoked": "Widerrufen",
21
- "pat.list.expired": "Abgelaufen",
22
31
  "pat.list.revoke": "Widerrufen",
32
+ "pat.list.revoked": "Widerrufen",
33
+ "pat.list.validUntil": "Gültig bis {date}",
34
+ "pat.list.neverExpires": "Unbegrenzt gültig",
35
+ "pat.list.created": "Erstellt {date}",
23
36
  "pat.error.generic": "Aktion fehlgeschlagen.",
24
37
  },
25
38
  en: {
26
39
  "pat.title": "Personal Access Tokens",
27
40
  "pat.create.title": "Create a new token",
41
+ "pat.create.subtitle": "Pick permissions and an expiration. The token is shown only once.",
28
42
  "pat.create.name": "Name",
29
43
  "pat.create.namePlaceholder": "e.g. CLI automation",
30
- "pat.create.scopes": "Permissions",
44
+ "pat.create.scopes": "Per-API access",
45
+ "pat.level.none": "No access",
46
+ "pat.level.read": "Read",
47
+ "pat.level.write": "Read & write",
48
+ "pat.create.expiry": "Expiration",
31
49
  "pat.create.submit": "Create token",
32
50
  "pat.create.needName": "Please enter a name.",
33
51
  "pat.create.needScope": "Please select at least one permission.",
34
- "pat.created.title": "Token created — copy it now",
35
- "pat.created.hint": "This token is shown only once. Copy it now.",
36
- "pat.created.dismiss": "Got it",
52
+ "pat.expiry.30d": "30 days",
53
+ "pat.expiry.90d": "90 days",
54
+ "pat.expiry.1y": "1 year",
55
+ "pat.expiry.never": "No expiration",
56
+ "pat.created.hint": "Copy this token now — for security it is shown only this once.",
57
+ "pat.created.copy": "Copy",
58
+ "pat.created.copied": "Copied ✓",
59
+ "pat.created.dismiss": "Done",
37
60
  "pat.list.title": "Your tokens",
38
61
  "pat.list.empty": "No tokens yet.",
39
- "pat.list.created": "Created",
40
- "pat.list.revoked": "Revoked",
41
- "pat.list.expired": "Expired",
42
62
  "pat.list.revoke": "Revoke",
63
+ "pat.list.revoked": "Revoked",
64
+ "pat.list.validUntil": "Valid until {date}",
65
+ "pat.list.neverExpires": "Never expires",
66
+ "pat.list.created": "Created {date}",
43
67
  "pat.error.generic": "Action failed.",
44
68
  },
45
69
  };
@@ -1,8 +1,10 @@
1
1
  // @runtime client
2
- // PatTokensScreen — logged-in self-service for Personal Access Tokens: mint a
3
- // token (name + scope toggles, plaintext shown once), list your tokens and
4
- // revoke them. The feature registers it dormant (r.screen); the app places it
5
- // via r.nav. Imports only from ../constants (pure) no server pull-in.
2
+ // PatTokensScreen — logged-in self-service for Personal Access Tokens. Two-axis
3
+ // scopes (like GitHub fine-grained PATs): per API domain pick a permission LEVEL
4
+ // (no access / read / read & write). Mint → copy the plaintext ONCE it's never
5
+ // re-displayed. Layout follows the framework's polished-screen convention (Form
6
+ // primitive + Tailwind), cards match the mh style. The feature registers this
7
+ // dormant (r.screen); the app places it via r.nav.
6
8
 
7
9
  import {
8
10
  useDispatcher,
@@ -12,95 +14,160 @@ import {
12
14
  } from "@cosmicdrift/kumiko-renderer";
13
15
  import { type ReactNode, useState } from "react";
14
16
  import { PatHandlers, PatQueries } from "../constants";
17
+ import { parseGrant } from "../scopes";
15
18
 
16
- type ScopeOption = { readonly name: string; readonly label: string };
19
+ type ScopeDomain = { readonly name: string; readonly label: string; readonly canWrite: boolean };
17
20
  type TokenRow = {
18
21
  readonly id: string;
19
22
  readonly name: string;
20
- readonly prefix: string;
21
23
  readonly scopes: readonly string[];
22
24
  readonly createdAt: string | null;
23
25
  readonly expiresAt: string | null;
24
26
  readonly revokedAt: string | null;
25
27
  };
26
28
 
27
- function isExpired(expiresAt: string | null): boolean {
28
- return expiresAt !== null && new Date(expiresAt).getTime() <= Date.now();
29
+ type Level = "none" | "read" | "write";
30
+ type ExpiryKey = "30d" | "90d" | "1y" | "never";
31
+ const EXPIRY_DAYS: Record<ExpiryKey, number | undefined> = {
32
+ "30d": 30,
33
+ "90d": 90,
34
+ "1y": 365,
35
+ never: undefined,
36
+ };
37
+
38
+ // Timestamps arrive as ISO strings over the JSON API — slice the date part
39
+ // without touching the banned Date API.
40
+ function isoDate(value: string | null): string | null {
41
+ return typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : null;
29
42
  }
30
43
 
31
44
  export function PatTokensScreen(): ReactNode {
32
45
  const t = useTranslation();
33
- const { Section, Heading, Field, Input, Button, Banner, Card, Text } = usePrimitives();
46
+ const { Form, Field, Input, Button, Banner, Card, Heading } = usePrimitives();
34
47
  const dispatcher = useDispatcher();
35
48
 
36
- const scopesQuery = useQuery<readonly ScopeOption[]>(PatQueries.availableScopes, {});
49
+ const scopesQuery = useQuery<readonly ScopeDomain[]>(PatQueries.availableScopes, {});
37
50
  const listQuery = useQuery<readonly TokenRow[]>(PatQueries.mine, {});
38
51
 
39
52
  const [name, setName] = useState("");
40
- const [selected, setSelected] = useState<readonly string[]>([]);
41
- const [minted, setMinted] = useState<{ token: string; prefix: string } | null>(null);
53
+ const [levels, setLevels] = useState<Readonly<Record<string, Level>>>({});
54
+ const [expiry, setExpiry] = useState<ExpiryKey>("90d");
55
+ const [minted, setMinted] = useState<{ token: string } | null>(null);
56
+ const [copied, setCopied] = useState(false);
42
57
  const [error, setError] = useState<string | null>(null);
43
58
  const [busy, setBusy] = useState(false);
44
59
 
45
- const scopes = scopesQuery.data ?? [];
60
+ const domains = scopesQuery.data ?? [];
46
61
  const tokens = listQuery.data ?? [];
62
+ const labelOf = (domain: string): string =>
63
+ domains.find((d) => d.name === domain)?.label ?? domain;
64
+
65
+ // Granted scopes = "<domain>:<level>" for every domain not set to "none".
66
+ const grants = (): string[] =>
67
+ Object.entries(levels)
68
+ .filter(([, l]) => l !== "none")
69
+ .map(([domain, l]) => `${domain}:${l}`);
47
70
 
48
- const toggle = (scope: string): void =>
49
- setSelected((cur) => (cur.includes(scope) ? cur.filter((s) => s !== scope) : [...cur, scope]));
71
+ const setLevel = (domain: string, level: Level): void =>
72
+ setLevels((cur) => ({ ...cur, [domain]: level }));
50
73
 
51
74
  const create = async (): Promise<void> => {
52
75
  if (name.trim() === "") return setError(t("pat.create.needName"));
53
- if (selected.length === 0) return setError(t("pat.create.needScope"));
76
+ const scopes = grants();
77
+ if (scopes.length === 0) return setError(t("pat.create.needScope"));
54
78
  setBusy(true);
55
79
  setError(null);
80
+ const days = EXPIRY_DAYS[expiry];
56
81
  const res = await dispatcher.write(PatHandlers.create, {
57
82
  name: name.trim(),
58
- scopes: selected,
83
+ scopes,
84
+ ...(days !== undefined ? { expiresInDays: days } : {}),
59
85
  });
60
86
  setBusy(false);
61
87
  if (!res.isSuccess) return setError(t("pat.error.generic"));
62
- const data = res.data as { token: string; prefix: string };
63
- setMinted({ token: data.token, prefix: data.prefix });
88
+ setMinted({ token: (res.data as { token: string }).token });
89
+ setCopied(false);
64
90
  setName("");
65
- setSelected([]);
91
+ setLevels({});
92
+ setExpiry("90d");
66
93
  void listQuery.refetch?.();
67
94
  };
68
95
 
96
+ const copy = async (): Promise<void> => {
97
+ if (!minted) return;
98
+ try {
99
+ await navigator.clipboard.writeText(minted.token);
100
+ setCopied(true);
101
+ } catch {
102
+ // clipboard blocked (non-secure context) — the token stays selectable in place
103
+ }
104
+ };
105
+
69
106
  const revoke = async (id: string): Promise<void> => {
70
107
  const res = await dispatcher.write(PatHandlers.revoke, { id });
71
108
  if (!res.isSuccess) return setError(t("pat.error.generic"));
72
109
  void listQuery.refetch?.();
73
110
  };
74
111
 
75
- const status = (row: TokenRow): string => {
76
- if (row.revokedAt !== null) return t("pat.list.revoked");
77
- if (isExpired(row.expiresAt)) return t("pat.list.expired");
78
- return "";
112
+ // "Pages (read & write) · Tags (read) · Valid until … · Created …"
113
+ const meta = (row: TokenRow): string => {
114
+ const scopeText = row.scopes
115
+ .map((g) => {
116
+ const p = parseGrant(g);
117
+ if (!p) return g;
118
+ const lvl = p.level === "write" ? t("pat.level.write") : t("pat.level.read");
119
+ return `${labelOf(p.domain)} (${lvl})`;
120
+ })
121
+ .join(" · ");
122
+ const d = isoDate(row.expiresAt);
123
+ const parts = [
124
+ scopeText,
125
+ d ? t("pat.list.validUntil", { date: d }) : t("pat.list.neverExpires"),
126
+ ];
127
+ const created = isoDate(row.createdAt);
128
+ if (created) parts.push(t("pat.list.created", { date: created }));
129
+ return parts.join(" · ");
79
130
  };
80
131
 
81
132
  return (
82
- <Section>
133
+ <div className="flex max-w-3xl flex-col gap-6 p-6">
83
134
  <Heading>{t("pat.title")}</Heading>
84
135
 
85
136
  {minted && (
86
137
  <Banner
87
138
  variant="info"
88
139
  actions={
89
- <Button variant="secondary" onClick={() => setMinted(null)}>
90
- {t("pat.created.dismiss")}
91
- </Button>
140
+ <>
141
+ <Button variant="primary" onClick={copy}>
142
+ {copied ? t("pat.created.copied") : t("pat.created.copy")}
143
+ </Button>
144
+ <Button variant="secondary" onClick={() => setMinted(null)}>
145
+ {t("pat.created.dismiss")}
146
+ </Button>
147
+ </>
92
148
  }
93
149
  >
94
- <Heading>{t("pat.created.title")}</Heading>
95
- <Text>{minted.token}</Text>
96
- <Text>{t("pat.created.hint")}</Text>
150
+ <div className="flex flex-col gap-2">
151
+ <span className="text-sm">{t("pat.created.hint")}</span>
152
+ <code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
153
+ {minted.token}
154
+ </code>
155
+ </div>
97
156
  </Banner>
98
157
  )}
99
158
 
100
159
  {error && <Banner variant="error">{error}</Banner>}
101
160
 
102
- <Section>
103
- <Heading>{t("pat.create.title")}</Heading>
161
+ <Form
162
+ title={t("pat.create.title")}
163
+ subtitle={t("pat.create.subtitle")}
164
+ onSubmit={create}
165
+ actions={
166
+ <Button type="submit" variant="primary" onClick={create} loading={busy} disabled={busy}>
167
+ {t("pat.create.submit")}
168
+ </Button>
169
+ }
170
+ >
104
171
  <Field id="pat-name" label={t("pat.create.name")} required>
105
172
  <Input
106
173
  kind="text"
@@ -112,39 +179,73 @@ export function PatTokensScreen(): ReactNode {
112
179
  disabled={busy}
113
180
  />
114
181
  </Field>
115
- <Text>{t("pat.create.scopes")}</Text>
116
- {scopes.map((s) => (
117
- <Button
118
- key={s.name}
119
- variant={selected.includes(s.name) ? "primary" : "secondary"}
120
- onClick={() => toggle(s.name)}
121
- disabled={busy}
122
- >
123
- {s.label}
124
- </Button>
182
+
183
+ {domains.map((d) => (
184
+ <Field key={d.name} id={`pat-scope-${d.name}`} label={d.label}>
185
+ <Input
186
+ kind="select"
187
+ id={`pat-scope-${d.name}`}
188
+ name={d.name}
189
+ value={levels[d.name] ?? "none"}
190
+ onChange={(v) => setLevel(d.name, v as Level)}
191
+ options={[
192
+ { value: "none", label: t("pat.level.none") },
193
+ { value: "read", label: t("pat.level.read") },
194
+ ...(d.canWrite ? [{ value: "write", label: t("pat.level.write") }] : []),
195
+ ]}
196
+ disabled={busy}
197
+ />
198
+ </Field>
125
199
  ))}
126
- <Button type="submit" variant="primary" onClick={create} loading={busy} disabled={busy}>
127
- {t("pat.create.submit")}
128
- </Button>
129
- </Section>
130
200
 
131
- <Section>
201
+ <Field id="pat-expiry" label={t("pat.create.expiry")}>
202
+ <Input
203
+ kind="select"
204
+ id="pat-expiry"
205
+ name="expiry"
206
+ value={expiry}
207
+ onChange={(v) => setExpiry(v as ExpiryKey)}
208
+ options={[
209
+ { value: "30d", label: t("pat.expiry.30d") },
210
+ { value: "90d", label: t("pat.expiry.90d") },
211
+ { value: "1y", label: t("pat.expiry.1y") },
212
+ { value: "never", label: t("pat.expiry.never") },
213
+ ]}
214
+ disabled={busy}
215
+ />
216
+ </Field>
217
+ </Form>
218
+
219
+ <div className="flex flex-col gap-3">
132
220
  <Heading>{t("pat.list.title")}</Heading>
133
- {tokens.length === 0 && <Text>{t("pat.list.empty")}</Text>}
134
- {tokens.map((row) => (
135
- <Card key={row.id}>
136
- <Text>
137
- {row.name} {row.prefix}… {status(row)}
138
- </Text>
139
- <Text>{row.scopes.join(", ")}</Text>
140
- {row.revokedAt === null && (
141
- <Button variant="danger" onClick={() => revoke(row.id)}>
142
- {t("pat.list.revoke")}
143
- </Button>
144
- )}
145
- </Card>
146
- ))}
147
- </Section>
148
- </Section>
221
+ {tokens.length === 0 && (
222
+ <span className="text-sm text-muted-foreground">{t("pat.list.empty")}</span>
223
+ )}
224
+ {tokens.map((row) => {
225
+ const revoked = row.revokedAt !== null;
226
+ return (
227
+ <Card
228
+ key={row.id}
229
+ options={{ padded: false }}
230
+ className={`p-4 ${revoked ? "opacity-60" : ""}`}
231
+ >
232
+ <div className="flex items-center justify-between gap-3">
233
+ <div className="flex min-w-0 flex-col gap-0.5">
234
+ <span className="truncate text-sm font-semibold">{row.name}</span>
235
+ <span className="truncate text-xs text-muted-foreground">
236
+ {revoked ? t("pat.list.revoked") : meta(row)}
237
+ </span>
238
+ </div>
239
+ {!revoked && (
240
+ <Button variant="danger" onClick={() => revoke(row.id)}>
241
+ {t("pat.list.revoke")}
242
+ </Button>
243
+ )}
244
+ </div>
245
+ </Card>
246
+ );
247
+ })}
248
+ </div>
249
+ </div>
149
250
  );
150
251
  }