@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 +77 -0
- package/dist/apikeys.d.ts +35 -0
- package/dist/apikeys.d.ts.map +1 -0
- package/dist/apikeys.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +14 -0
- package/dist/module.json +7 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +10 -0
- package/dist/server/create-apikeys-server.d.ts +14 -0
- package/dist/server/create-apikeys-server.d.ts.map +1 -0
- package/dist/server/create-apikeys-server.js +19 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/package.json +43 -0
- package/scaffold/src/app/security/api-keys/page.tsx +418 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @seedgrid/fe-apikeys
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@seedgrid/fe-apikeys)
|
|
4
|
+
[](https://www.npmjs.com/package/@seedgrid/fe-apikeys)
|
|
5
|
+
[](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"}
|
package/dist/apikeys.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/module.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/module.json
ADDED
package/dist/paths.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|
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
|
+
}
|