@seedgrid/fe-apikeys 2026.7.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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @seedgrid/fe-apikeys
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40seedgrid%2Ffe-apikeys)](https://www.npmjs.com/package/@seedgrid/fe-apikeys)
4
+ [![npm downloads](https://img.shields.io/npm/dm/%40seedgrid%2Ffe-apikeys)](https://www.npmjs.com/package/@seedgrid/fe-apikeys)
5
+ [![TypeScript](https://img.shields.io/badge/types-TypeScript-blue)](https://www.typescriptlang.org/)
6
+
7
+ Módulo front de **API keys** do SeedGrid: gestão de chaves de integração criadas **por role**, com segredo exibido uma única vez na criação. Compõe sobre o motor de sessão do [`@seedgrid/fe-security`](https://www.npmjs.com/package/@seedgrid/fe-security).
8
+
9
+ ## Sumário
10
+
11
+ - [Funcionalidades](#funcionalidades)
12
+ - [Tela entregue pelo scaffold](#tela-entregue-pelo-scaffold)
13
+ - [Instalação](#instalação)
14
+ - [Uso rápido](#uso-rápido)
15
+ - [Backend correspondente](#backend-correspondente)
16
+ - [Suporte](#suporte)
17
+
18
+ ## Funcionalidades
19
+
20
+ - **Criar chave por role** — a resposta traz o segredo cru (`apiKey`) uma única vez; depois disso a API só devolve `prefix`/`masked`.
21
+ - **Listar chaves** — por role (visão de administração) ou do usuário corrente (`listMine`).
22
+ - **Revogar chave** — revogação com carimbo `revokedAt`; a listagem mostra também `lastUsedAt` e `expiresAt`.
23
+ - **Expiração opcional** — `expiresDays` na criação; ausente = sem expiração.
24
+
25
+ O pacote exporta:
26
+
27
+ - `ApiKeyPaths` — contrato dos endpoints REST (`/apikeys`, `/apikeys/role/{roleName}`, `/apikeys/{publicId}/revoke`).
28
+ - DTOs client-safe: `ApiKeyCreateRequest`, `ApiKeyCreateResponse`, `ApiKeyListItem`, `ApiKeyRevokeResponse`.
29
+ - `createApiKeysServer` (em `@seedgrid/fe-apikeys/server`, SERVER-ONLY) — engine tipado com `createForRole` / `listByRole` / `listMine` / `revoke`.
30
+
31
+ ## Tela entregue pelo scaffold
32
+
33
+ | Tela | Rota | O que faz |
34
+ | --- | --- | --- |
35
+ | Gestão de API keys | `/security/api-keys` | Lista as chaves por role, cria chave nova (exibindo o segredo uma única vez, com aviso pra copiar) e revoga chaves existentes |
36
+
37
+ O scaffold é copiado para dentro do app pelo SeedGrid CLI (`module.json`: `id: "api-keys"`, `requires: ["security"]`) — o app é dono do código e pode personalizar a tela.
38
+
39
+ ## Instalação
40
+
41
+ ```bash
42
+ pnpm add @seedgrid/fe-apikeys @seedgrid/fe-security @seedgrid/fe-core
43
+ ```
44
+
45
+ ## Uso rápido
46
+
47
+ ```ts
48
+ // server-only — compõe sobre a sessão do fe-security
49
+ import { createSecurityServer } from "@seedgrid/fe-security/server";
50
+ import { createApiKeysServer } from "@seedgrid/fe-apikeys/server";
51
+
52
+ const session = createSecurityServer({ /* ... */ });
53
+ export const apiKeys = createApiKeysServer(session);
54
+
55
+ // Em uma rota/action:
56
+ const nova = await apiKeys.createForRole("INTEGRATION", {
57
+ name: "Integração ERP",
58
+ expiresDays: 90,
59
+ });
60
+ console.log(nova.apiKey); // segredo completo — mostre UMA vez, não persista no front
61
+
62
+ const chaves = await apiKeys.listByRole("INTEGRATION");
63
+ await apiKeys.revoke(chaves[0].publicId);
64
+ ```
65
+
66
+ ## Backend correspondente
67
+
68
+ Extensão Quarkus `seedgrid-quarkus-ext-apikeys`. `ApiKeyPaths` é espelho do `RestControllerPath.java` da extensão — contrato único front/back.
69
+
70
+ ## Suporte
71
+
72
+ - Issues: https://github.com/SeedGrid/seedgrid-fe-components/issues
73
+ - Repositório: https://github.com/SeedGrid/seedgrid-fe-components
74
+
75
+ ## Licença
76
+
77
+ MIT
@@ -0,0 +1,35 @@
1
+ /** POST /apikeys/role/{roleName} (request). */
2
+ export interface ApiKeyCreateRequest {
3
+ name: string;
4
+ /** Validade em dias; ausente = sem expiração. */
5
+ expiresDays?: number;
6
+ }
7
+ /** Resposta da CRIAÇÃO — única vez que `apiKey` (segredo cru) é exposto. */
8
+ export interface ApiKeyCreateResponse {
9
+ publicId: string;
10
+ name: string;
11
+ prefix: string;
12
+ masked: string;
13
+ /** Segredo completo — mostre uma vez e não persista no front. */
14
+ apiKey: string;
15
+ expiresAt: string | null;
16
+ createdAt: string;
17
+ }
18
+ /** Item de listagem (nunca traz o segredo; só prefix/masked). */
19
+ export interface ApiKeyListItem {
20
+ publicId: string;
21
+ name: string;
22
+ prefix: string;
23
+ masked: string;
24
+ roleName: string;
25
+ expiresAt: string | null;
26
+ revokedAt: string | null;
27
+ lastUsedAt: string | null;
28
+ createdAt: string;
29
+ }
30
+ /** POST /apikeys/{publicId}/revoke (response). */
31
+ export interface ApiKeyRevokeResponse {
32
+ publicId: string;
33
+ revokedAt: string;
34
+ }
35
+ //# sourceMappingURL=apikeys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apikeys.d.ts","sourceRoot":"","sources":["../src/apikeys.ts"],"names":[],"mappings":"AAGA,+CAA+C;AAC/C,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,4EAA4E;AAC5E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,kDAAkD;AAClD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB"}
@@ -0,0 +1,3 @@
1
+ // DTOs de API keys (espelham seedgrid-quarkus-ext-apikeys). Client-safe.
2
+ // Chaves são criadas por ROLE; o segredo completo só aparece UMA vez, na criação.
3
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from "./apikeys";
2
+ export * from "./paths";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // Barrel client-safe (@seedgrid/fe-apikeys). Tipos + paths. Server helper em /server.
2
+ // A tela de gestão de API keys é SCAFFOLD (app owns), entregue pelo CLI.
3
+ export * from "./apikeys";
4
+ export * from "./paths";
@@ -0,0 +1,3 @@
1
+ import type { SeedGridCliModuleManifest } from "@seedgrid/fe-core";
2
+ export declare const module: SeedGridCliModuleManifest;
3
+ //# sourceMappingURL=module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AAWnE,eAAO,MAAM,MAAM,EAAE,yBAIpB,CAAC"}
package/dist/module.js ADDED
@@ -0,0 +1,14 @@
1
+ // Reference-adjacent implementation of the manifest read by the SeedGrid CLI
2
+ // (Rust). See seedgrid-cli/docs/adr/0004-module-manifest-contract.md.
3
+ //
4
+ // No provider/navigation/messages here: the api-keys menu entry and
5
+ // dashboard shortcut already live inside fe-security's own scaffold
6
+ // (`useSecurityAppShellSections.tsx`/`SecurityDashboard.tsx`), gated behind
7
+ // a `{{#if security_api_keys_enabled}}` conditional driven by
8
+ // `config.is_module_enabled("api-keys")`. This module only ships its own
9
+ // standalone screen.
10
+ export const module = {
11
+ id: "api-keys",
12
+ requires: ["security"],
13
+ configKey: "api-keys",
14
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "api-keys",
3
+ "requires": [
4
+ "security"
5
+ ],
6
+ "configKey": "api-keys"
7
+ }
@@ -0,0 +1,9 @@
1
+ export declare const ApiKeyPaths: {
2
+ /** GET — chaves do usuário corrente. */
3
+ readonly base: "/apikeys";
4
+ /** GET lista · POST cria chave para a role. */
5
+ readonly byRole: (roleName: string) => string;
6
+ /** POST — revoga a chave. */
7
+ readonly revoke: (publicId: string) => string;
8
+ };
9
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,WAAW;IACtB,wCAAwC;;IAExC,+CAA+C;gCAC5B,MAAM;IACzB,6BAA6B;gCACV,MAAM;CACjB,CAAC"}
package/dist/paths.js ADDED
@@ -0,0 +1,10 @@
1
+ // Paths de API keys (espelho de RestControllerPath.java em seedgrid-quarkus-ext-apikeys).
2
+ const seg = (value) => encodeURIComponent(value);
3
+ export const ApiKeyPaths = {
4
+ /** GET — chaves do usuário corrente. */
5
+ base: "/apikeys",
6
+ /** GET lista · POST cria chave para a role. */
7
+ byRole: (roleName) => `/apikeys/role/${seg(roleName)}`,
8
+ /** POST — revoga a chave. */
9
+ revoke: (publicId) => `/apikeys/${seg(publicId)}/revoke`,
10
+ };
@@ -0,0 +1,14 @@
1
+ import type { SecurityServer } from "@seedgrid/fe-security";
2
+ import type { ApiKeyCreateRequest, ApiKeyCreateResponse, ApiKeyListItem, ApiKeyRevokeResponse } from "../apikeys";
3
+ export interface ApiKeysServer {
4
+ /** Cria uma chave para a role — resposta traz o segredo cru (uma vez). */
5
+ createForRole(roleName: string, req: ApiKeyCreateRequest): Promise<ApiKeyCreateResponse>;
6
+ /** Lista as chaves de uma role. */
7
+ listByRole(roleName: string): Promise<ApiKeyListItem[]>;
8
+ /** Lista as chaves do usuário corrente. */
9
+ listMine(): Promise<ApiKeyListItem[]>;
10
+ /** Revoga uma chave. */
11
+ revoke(publicId: string): Promise<ApiKeyRevokeResponse>;
12
+ }
13
+ export declare function createApiKeysServer(session: SecurityServer): ApiKeysServer;
14
+ //# sourceMappingURL=create-apikeys-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-apikeys-server.d.ts","sourceRoot":"","sources":["../../src/server/create-apikeys-server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAG5D,OAAO,KAAK,EACV,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACd,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,aAAa,CACX,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,mBAAmB,GACvB,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,mCAAmC;IACnC,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACxD,2CAA2C;IAC3C,QAAQ,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACtC,wBAAwB;IACxB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CACzD;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,cAAc,GAAG,aAAa,CAgB1E"}
@@ -0,0 +1,19 @@
1
+ // Helpers server-side de API keys, sobre o motor de sessão do fe-security. SERVER-ONLY.
2
+ //
3
+ // const session = createSecurityServer({ ... });
4
+ // export const apiKeys = createApiKeysServer(session);
5
+ import { ApiKeyPaths } from "../paths";
6
+ export function createApiKeysServer(session) {
7
+ const P = ApiKeyPaths;
8
+ return {
9
+ createForRole: (roleName, req) => session.apiFetchSecurity(P.byRole(roleName), {
10
+ method: "POST",
11
+ body: JSON.stringify(req),
12
+ }),
13
+ listByRole: (roleName) => session.apiFetchSecurity(P.byRole(roleName)),
14
+ listMine: () => session.apiFetchSecurity(P.base),
15
+ revoke: (publicId) => session.apiFetchSecurity(P.revoke(publicId), {
16
+ method: "POST",
17
+ }),
18
+ };
19
+ }
@@ -0,0 +1,2 @@
1
+ export { createApiKeysServer, type ApiKeysServer, } from "./create-apikeys-server";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,mBAAmB,EACnB,KAAK,aAAa,GACnB,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,2 @@
1
+ // Barrel SERVER (@seedgrid/fe-apikeys/server). Roda em action/route.
2
+ export { createApiKeysServer, } from "./create-apikeys-server";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@seedgrid/fe-apikeys",
3
+ "version": "2026.7.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/SeedGrid/seedgrid-fe-components.git"
10
+ },
11
+ "homepage": "https://github.com/SeedGrid/seedgrid-fe-components",
12
+ "bugs": {
13
+ "url": "https://github.com/SeedGrid/seedgrid-fe-components/issues"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./server": {
21
+ "types": "./dist/server/index.d.ts",
22
+ "default": "./dist/server/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "scaffold"
28
+ ],
29
+ "peerDependencies": {
30
+ "@seedgrid/fe-core": "*",
31
+ "@seedgrid/fe-security": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@seedgrid/fe-core": "2026.7.1",
35
+ "@seedgrid/fe-security": "2026.7.2"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "postbuild": "node scripts/emit-module-manifest.mjs",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "test": "pnpm run build && node --test tests/module-manifest.test.mjs"
42
+ }
43
+ }
@@ -0,0 +1,418 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgCombobox,
8
+ SgConfirmationDialog,
9
+ SgDatatable,
10
+ SgInputText,
11
+ toast,
12
+ } from "@seedgrid/fe-components";
13
+
14
+ import { useI18n } from "@/i18n";
15
+ import {
16
+ InlineNotice,
17
+ PageFrame,
18
+ RouteGuard,
19
+ StatusBadge,
20
+ extractApiMessage,
21
+ securityConfig,
22
+ securityService,
23
+ useDebouncedSearch,
24
+ usePermission,
25
+ type ApiKeyListItem,
26
+ type RoleSummary,
27
+ type SortState,
28
+ } from "@/modules/security";
29
+ import { formatDateTime, isSearchReady } from "@/modules/security/utils";
30
+
31
+ const ACCESS_PERMISSIONS = ["APIKEY_READ", "APIKEY_CREATE", "APIKEY_REVOKE"];
32
+
33
+ export default function ApiKeysPage() {
34
+ return (
35
+ <RouteGuard permissions={ACCESS_PERMISSIONS} permissionMode="any">
36
+ <ApiKeysScreen />
37
+ </RouteGuard>
38
+ );
39
+ }
40
+
41
+ function ApiKeysScreen() {
42
+ const { t, locale } = useI18n();
43
+ const canRead = usePermission("APIKEY_READ");
44
+ const canCreate = usePermission("APIKEY_CREATE");
45
+ const canRevoke = usePermission("APIKEY_REVOKE");
46
+ const [roles, setRoles] = useState<RoleSummary[]>([]);
47
+ const [items, setItems] = useState<ApiKeyListItem[]>([]);
48
+ const [page, setPage] = useState(0);
49
+ // <number>: defaultPageSize e literal, sem anotacao o setRows(number)
50
+ // falharia (TS2345 SetStateAction<literal>).
51
+ const [rows, setRows] = useState<number>(securityConfig.pagination.defaultPageSize);
52
+ const [totalRecords, setTotalRecords] = useState(0);
53
+ const [loading, setLoading] = useState(false);
54
+ const [pendingCreate, setPendingCreate] = useState(false);
55
+ const [searchValue, setSearchValue] = useState("");
56
+ const [filterRole, setFilterRole] = useState("");
57
+ const [createRole, setCreateRole] = useState("");
58
+ const [createName, setCreateName] = useState("");
59
+ const [expiresDays, setExpiresDays] = useState("");
60
+ const [sort, setSort] = useState<SortState>({
61
+ sortField: "createdAt",
62
+ sortOrder: -1,
63
+ });
64
+ const [createdSecret, setCreatedSecret] = useState<string | null>(null);
65
+ const [revokeTarget, setRevokeTarget] = useState<ApiKeyListItem | null>(null);
66
+ const debouncedSearch = useDebouncedSearch(searchValue);
67
+ const effectiveSearch = isSearchReady(debouncedSearch) ? debouncedSearch : "";
68
+ const createNameInputProps = {
69
+ value: createName,
70
+ onChange: (event: { target: { value: string } }) =>
71
+ setCreateName(event.target.value),
72
+ };
73
+ const expiresDaysInputProps = {
74
+ value: expiresDays,
75
+ // `as const`: sem isso "numeric" alarga pra `string` e o inputProps
76
+ // (InputHTMLAttributes.inputMode e uma uniao literal) recusa (TS2322).
77
+ inputMode: "numeric" as const,
78
+ onChange: (event: { target: { value: string } }) =>
79
+ setExpiresDays(event.target.value),
80
+ };
81
+ const searchInputProps = {
82
+ value: searchValue,
83
+ onChange: (event: { target: { value: string } }) => {
84
+ setSearchValue(event.target.value);
85
+ setPage(0);
86
+ },
87
+ };
88
+ const roleOptions = useMemo(
89
+ () =>
90
+ roles.map((role) => ({
91
+ id: role.name,
92
+ value: role.name,
93
+ label: role.name,
94
+ })),
95
+ [roles]
96
+ );
97
+ const filterRoleOptions = useMemo(
98
+ () => [{ id: "", value: "", label: "-" }, ...roleOptions],
99
+ [roleOptions]
100
+ );
101
+
102
+ useEffect(() => {
103
+ let active = true;
104
+
105
+ void (async () => {
106
+ try {
107
+ const loadedRoles = await securityService.listIntegrationRoles();
108
+
109
+ if (!active) {
110
+ return;
111
+ }
112
+
113
+ setRoles(loadedRoles);
114
+ setCreateRole((current) => current || loadedRoles[0]?.name || "");
115
+ } catch (error) {
116
+ if (!active) {
117
+ return;
118
+ }
119
+
120
+ toast.error(extractApiMessage(error) ?? t("security.api_keys.form.error"));
121
+ }
122
+ })();
123
+
124
+ return () => {
125
+ active = false;
126
+ };
127
+ }, [t]);
128
+
129
+ useEffect(() => {
130
+ if (!canRead) {
131
+ return;
132
+ }
133
+
134
+ let active = true;
135
+ setLoading(true);
136
+
137
+ void (async () => {
138
+ try {
139
+ const response = await securityService.listApiKeysPage({
140
+ page,
141
+ size: rows,
142
+ search: effectiveSearch,
143
+ roleName: filterRole || undefined,
144
+ sort,
145
+ });
146
+
147
+ if (!active) {
148
+ return;
149
+ }
150
+
151
+ setItems(response.items);
152
+ setTotalRecords(response.total);
153
+ } catch (error) {
154
+ if (!active) {
155
+ return;
156
+ }
157
+
158
+ toast.error(
159
+ extractApiMessage(error) ?? t("security.api_keys.revoke_error")
160
+ );
161
+ } finally {
162
+ if (active) {
163
+ setLoading(false);
164
+ }
165
+ }
166
+ })();
167
+
168
+ return () => {
169
+ active = false;
170
+ };
171
+ }, [canRead, effectiveSearch, filterRole, page, rows, sort, t]);
172
+
173
+ const columns = useMemo(
174
+ () => [
175
+ {
176
+ field: "name",
177
+ header: t("security.api_keys.column.name"),
178
+ sortable: true,
179
+ },
180
+ {
181
+ field: "roleName",
182
+ header: t("security.api_keys.column.role"),
183
+ },
184
+ {
185
+ field: "active",
186
+ header: t("security.api_keys.column.status"),
187
+ body: (item: ApiKeyListItem) => (
188
+ <StatusBadge
189
+ active={Boolean(item.active ?? !item.revokedAt)}
190
+ activeLabel={t("security.common.enabled")}
191
+ inactiveLabel={t("security.common.disabled")}
192
+ />
193
+ ),
194
+ },
195
+ {
196
+ field: "expiresAt",
197
+ header: t("security.api_keys.column.expires_at"),
198
+ body: (item: ApiKeyListItem) => formatDateTime(item.expiresAt, locale),
199
+ },
200
+ {
201
+ field: "lastUsedAt",
202
+ header: t("security.api_keys.column.last_used_at"),
203
+ body: (item: ApiKeyListItem) => formatDateTime(item.lastUsedAt, locale),
204
+ },
205
+ {
206
+ header: t("security.common.actions"),
207
+ body: (item: ApiKeyListItem) => (
208
+ <div className="flex flex-wrap gap-2" data-sg-stop-row-select="true">
209
+ {canRevoke ? (
210
+ <SgButton
211
+ size="sm"
212
+ appearance="outline"
213
+ severity="danger"
214
+ label={t("security.api_keys.revoke")}
215
+ onClick={() => {
216
+ setRevokeTarget(item);
217
+ }}
218
+ />
219
+ ) : null}
220
+ </div>
221
+ ),
222
+ },
223
+ ],
224
+ [canRevoke, locale, t]
225
+ );
226
+
227
+ async function handleCreate() {
228
+ if (!createRole.trim()) {
229
+ return;
230
+ }
231
+
232
+ setPendingCreate(true);
233
+
234
+ try {
235
+ const response = await securityService.createApiKey({
236
+ roleName: createRole,
237
+ name: createName.trim(),
238
+ expiresDays: expiresDays.trim() ? Number(expiresDays) : null,
239
+ });
240
+
241
+ setCreatedSecret(response.key ?? null);
242
+ setCreateName("");
243
+ setExpiresDays("");
244
+ toast.success(t("security.api_keys.form.success"));
245
+
246
+ if (canRead) {
247
+ const refreshed = await securityService.listApiKeysPage({
248
+ page,
249
+ size: rows,
250
+ search: effectiveSearch,
251
+ roleName: filterRole || undefined,
252
+ sort,
253
+ });
254
+ setItems(refreshed.items);
255
+ setTotalRecords(refreshed.total);
256
+ }
257
+ } catch (error) {
258
+ toast.error(extractApiMessage(error) ?? t("security.api_keys.form.error"));
259
+ } finally {
260
+ setPendingCreate(false);
261
+ }
262
+ }
263
+
264
+ async function handleRevoke() {
265
+ if (!revokeTarget) {
266
+ return;
267
+ }
268
+
269
+ try {
270
+ await securityService.revokeApiKey(revokeTarget.publicId);
271
+ toast.success(t("security.api_keys.revoke_success"));
272
+ setRevokeTarget(null);
273
+
274
+ if (canRead) {
275
+ const refreshed = await securityService.listApiKeysPage({
276
+ page,
277
+ size: rows,
278
+ search: effectiveSearch,
279
+ roleName: filterRole || undefined,
280
+ sort,
281
+ });
282
+ setItems(refreshed.items);
283
+ setTotalRecords(refreshed.total);
284
+ }
285
+ } catch (error) {
286
+ toast.error(
287
+ extractApiMessage(error) ?? t("security.api_keys.revoke_error")
288
+ );
289
+ }
290
+ }
291
+
292
+ return (
293
+ <PageFrame
294
+ title={t("security.api_keys.title")}
295
+ description={t("security.api_keys.description")}
296
+ >
297
+ <div className="space-y-6">
298
+ {canCreate ? (
299
+ <div className="grid gap-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,14rem)_auto] lg:items-end">
300
+ <SgInputText
301
+ id="security-api-key-name"
302
+ label={t("security.api_keys.form.name")}
303
+ inputProps={createNameInputProps}
304
+ />
305
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
306
+ <SgCombobox
307
+ id="security-api-key-role"
308
+ width="100%"
309
+ label={t("security.api_keys.form.role")}
310
+ source={roleOptions}
311
+ value={createRole}
312
+ onValueChange={(value) => {
313
+ setCreateRole(String(value ?? ""));
314
+ }}
315
+ />
316
+ <SgInputText
317
+ id="security-api-key-expires-days"
318
+ label={t("security.api_keys.form.expires_days")}
319
+ inputProps={expiresDaysInputProps}
320
+ />
321
+ </div>
322
+ <SgButton
323
+ appearance="solid"
324
+ severity="primary"
325
+ shape="rounded"
326
+ label={t("security.api_keys.form.create")}
327
+ loading={pendingCreate}
328
+ disabled={pendingCreate || !createRole.trim() || !createName.trim()}
329
+ onClick={handleCreate}
330
+ />
331
+ </div>
332
+ ) : null}
333
+
334
+ {createdSecret ? (
335
+ <InlineNotice tone="success">
336
+ <div className="space-y-3">
337
+ <div>{t("security.api_keys.form.secret")}</div>
338
+ <div className="rounded-2xl border border-emerald-200 bg-white px-4 py-3 font-mono text-sm break-all">
339
+ {createdSecret}
340
+ </div>
341
+ </div>
342
+ </InlineNotice>
343
+ ) : null}
344
+
345
+ {canRead ? (
346
+ <div className="space-y-4">
347
+ <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
348
+ <SgInputText
349
+ id="security-api-key-search"
350
+ label={t("security.common.search")}
351
+ inputProps={searchInputProps}
352
+ placeholder={t("security.api_keys.search_placeholder")}
353
+ />
354
+ <SgCombobox
355
+ id="security-api-key-filter-role"
356
+ width="100%"
357
+ label={t("security.api_keys.filter_role")}
358
+ source={filterRoleOptions}
359
+ value={filterRole}
360
+ onValueChange={(value) => {
361
+ setFilterRole(String(value ?? ""));
362
+ setPage(0);
363
+ }}
364
+ />
365
+ </div>
366
+
367
+ {!isSearchReady(searchValue) && searchValue.trim().length > 0 ? (
368
+ <InlineNotice>{t("security.common.search_hint")}</InlineNotice>
369
+ ) : null}
370
+
371
+ <SgDatatable<ApiKeyListItem>
372
+ title=" "
373
+ value={items}
374
+ columns={columns}
375
+ dataKey="publicId"
376
+ lazy
377
+ paginator
378
+ rows={rows}
379
+ first={page * rows}
380
+ totalRecords={totalRecords}
381
+ sortField={sort.sortField}
382
+ sortOrder={sort.sortOrder}
383
+ loading={loading}
384
+ emptyMessage={t("security.api_keys.empty")}
385
+ onPage={(event) => {
386
+ setRows(event.rows ?? securityConfig.pagination.defaultPageSize);
387
+ setPage(event.page ?? 0);
388
+ }}
389
+ onSort={(event) => {
390
+ setPage(0);
391
+ setSort({
392
+ sortField: event.sortField ?? "createdAt",
393
+ sortOrder:
394
+ event.sortOrder === 1 || event.sortOrder === -1
395
+ ? event.sortOrder
396
+ : -1,
397
+ });
398
+ }}
399
+ />
400
+ </div>
401
+ ) : (
402
+ <InlineNotice>{t("security.guard.unauthorized.message")}</InlineNotice>
403
+ )}
404
+ </div>
405
+
406
+ <SgConfirmationDialog
407
+ open={Boolean(revokeTarget)}
408
+ severity="danger"
409
+ title={t("security.api_keys.revoke_title")}
410
+ message={t("security.api_keys.revoke_message")}
411
+ onCancel={() => {
412
+ setRevokeTarget(null);
413
+ }}
414
+ onConfirm={handleRevoke}
415
+ />
416
+ </PageFrame>
417
+ );
418
+ }