ankka-console 0.10.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 +32 -0
- package/dist/auth/oidc.d.ts +64 -0
- package/dist/auth/oidc.js +199 -0
- package/dist/client/control-plane.d.ts +67 -0
- package/dist/client/control-plane.js +208 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/errors.js +59 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +3 -0
- package/dist/client/schemas.d.ts +365 -0
- package/dist/client/schemas.js +221 -0
- package/dist/context.d.ts +83 -0
- package/dist/context.js +156 -0
- package/dist/extensions/render.d.ts +20 -0
- package/dist/extensions/render.js +49 -0
- package/dist/extensions/types.d.ts +44 -0
- package/dist/extensions/types.js +30 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.js +48 -0
- package/dist/options.d.ts +45 -0
- package/dist/options.js +39 -0
- package/dist/routes/auth.callback.d.ts +11 -0
- package/dist/routes/auth.callback.js +43 -0
- package/dist/routes/auth.sign-in.d.ts +11 -0
- package/dist/routes/auth.sign-in.js +29 -0
- package/dist/routes/auth.sign-out.d.ts +19 -0
- package/dist/routes/auth.sign-out.js +37 -0
- package/dist/routes/front.d.ts +41 -0
- package/dist/routes/front.js +25 -0
- package/dist/routes/logs.d.ts +67 -0
- package/dist/routes/logs.js +72 -0
- package/dist/routes/members.d.ts +45 -0
- package/dist/routes/members.js +59 -0
- package/dist/routes/organization-new.d.ts +14 -0
- package/dist/routes/organization-new.js +30 -0
- package/dist/routes/organization.d.ts +43 -0
- package/dist/routes/organization.js +82 -0
- package/dist/routes/project-new.d.ts +27 -0
- package/dist/routes/project-new.js +27 -0
- package/dist/routes/project.d.ts +59 -0
- package/dist/routes/project.js +61 -0
- package/dist/routes/service-apply.d.ts +44 -0
- package/dist/routes/service-apply.js +65 -0
- package/dist/routes/service.d.ts +71 -0
- package/dist/routes/service.js +73 -0
- package/dist/routes/stream.project.d.ts +3 -0
- package/dist/routes/stream.project.js +25 -0
- package/dist/routes/stream.service.d.ts +9 -0
- package/dist/routes/stream.service.js +68 -0
- package/dist/routes/tokens.d.ts +47 -0
- package/dist/routes/tokens.js +50 -0
- package/dist/routes.d.ts +28 -0
- package/dist/routes.js +48 -0
- package/dist/runtime.d.ts +28 -0
- package/dist/runtime.js +77 -0
- package/dist/server/create-console-server.d.ts +33 -0
- package/dist/server/create-console-server.js +203 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +13 -0
- package/dist/server/transport.d.ts +25 -0
- package/dist/server/transport.js +97 -0
- package/dist/session/cookie-store.d.ts +51 -0
- package/dist/session/cookie-store.js +66 -0
- package/dist/session/cookies.d.ts +15 -0
- package/dist/session/cookies.js +33 -0
- package/dist/session/seal.d.ts +12 -0
- package/dist/session/seal.js +38 -0
- package/dist/session/token-cache.d.ts +21 -0
- package/dist/session/token-cache.js +47 -0
- package/dist/session/token-source.d.ts +41 -0
- package/dist/session/token-source.js +63 -0
- package/dist/stream/log-follow.d.ts +18 -0
- package/dist/stream/log-follow.js +40 -0
- package/dist/stream/registry.d.ts +6 -0
- package/dist/stream/registry.js +34 -0
- package/dist/stream/sse.d.ts +14 -0
- package/dist/stream/sse.js +87 -0
- package/dist/styles.css +481 -0
- package/dist/testing/fake-control-plane.d.ts +161 -0
- package/dist/testing/fake-control-plane.js +715 -0
- package/dist/testing/fake-issuer.d.ts +66 -0
- package/dist/testing/fake-issuer.js +303 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/scenarios.d.ts +6 -0
- package/dist/testing/scenarios.js +44 -0
- package/dist/ui/console.d.ts +60 -0
- package/dist/ui/console.js +73 -0
- package/dist/ui/errors.d.ts +6 -0
- package/dist/ui/errors.js +30 -0
- package/dist/ui/refused.d.ts +6 -0
- package/dist/ui/refused.js +12 -0
- package/dist/ui/status.d.ts +5 -0
- package/dist/ui/status.js +23 -0
- package/dist/ui/use-stream.d.ts +59 -0
- package/dist/ui/use-stream.js +67 -0
- package/package.json +79 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OpenID Connect provider in one process, shaped like the platform's Keycloak realm as far as a
|
|
3
|
+
* client can tell: discovery under `/realms/ankka`, a login form with `username` and `password`
|
|
4
|
+
* fields, the authorization code grant with PKCE, refresh tokens that rotate but stay valid until
|
|
5
|
+
* they expire (Keycloak's default), revocation, RP-initiated logout, and an SSO session in a cookie
|
|
6
|
+
* so a second sign-in in the same browser needs no form.
|
|
7
|
+
*
|
|
8
|
+
* Users are `dev` (a platform administrator), `owner`, `member` and `outsider`; each one's password
|
|
9
|
+
* is its name and its email is `<name>@example.test`, verified. Tests steer it with
|
|
10
|
+
* `expireSession()` (the next refresh is refused as Keycloak refuses an ended session),
|
|
11
|
+
* `failNext()` (the next backchannel call fails as an unreachable issuer would) and `sessions`.
|
|
12
|
+
*/
|
|
13
|
+
import { type Server } from "node:http";
|
|
14
|
+
export interface FakeUser {
|
|
15
|
+
username: string;
|
|
16
|
+
name: string;
|
|
17
|
+
email: string;
|
|
18
|
+
roles: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare const fakeUsers: Record<string, FakeUser>;
|
|
21
|
+
export interface FakeIssuerOptions {
|
|
22
|
+
clientId?: string;
|
|
23
|
+
clientSecret?: string;
|
|
24
|
+
audience?: string;
|
|
25
|
+
accessTokenSeconds?: number;
|
|
26
|
+
sessionIdleSeconds?: number;
|
|
27
|
+
port?: number;
|
|
28
|
+
/**
|
|
29
|
+
* The origin the issuer names in its tokens and discovery document, when it is not the address it
|
|
30
|
+
* listens on — the cluster's situation, where Keycloak is reached in-cluster and named externally.
|
|
31
|
+
*/
|
|
32
|
+
publicUrl?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Keycloak's own behaviour when it is configured with a host name only: the scheme and port of the
|
|
35
|
+
* issuer come from each request, from its forwarded headers when it has them and from the address
|
|
36
|
+
* it was reached on when it does not. Set, discovery and tokens name the issuer of the request.
|
|
37
|
+
*/
|
|
38
|
+
hostOnly?: string;
|
|
39
|
+
}
|
|
40
|
+
interface Session {
|
|
41
|
+
id: string;
|
|
42
|
+
sub: string;
|
|
43
|
+
ended: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface FakeIssuer {
|
|
46
|
+
/** The realm's issuer, e.g. `http://127.0.0.1:1234/realms/ankka`. */
|
|
47
|
+
issuer: string;
|
|
48
|
+
url: string;
|
|
49
|
+
server: Server;
|
|
50
|
+
/** Verifies an access token as the control plane would, offline. */
|
|
51
|
+
verifyAccessToken(token: string): Promise<Record<string, unknown> | null>;
|
|
52
|
+
/** End every SSO session of this user: their next refresh is refused with `invalid_grant`. */
|
|
53
|
+
expireSession(sub: string): void;
|
|
54
|
+
/** The next token, revocation or discovery request is answered as an unreachable issuer would be. */
|
|
55
|
+
failNext(): void;
|
|
56
|
+
sessions: Map<string, Session>;
|
|
57
|
+
/** Mints a token pair for a user without a browser, for tests that need a bearer. */
|
|
58
|
+
mint(username: string): Promise<{
|
|
59
|
+
accessToken: string;
|
|
60
|
+
refreshToken: string;
|
|
61
|
+
idToken: string;
|
|
62
|
+
}>;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
export declare function fakeIssuer(options?: FakeIssuerOptions): Promise<FakeIssuer>;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OpenID Connect provider in one process, shaped like the platform's Keycloak realm as far as a
|
|
3
|
+
* client can tell: discovery under `/realms/ankka`, a login form with `username` and `password`
|
|
4
|
+
* fields, the authorization code grant with PKCE, refresh tokens that rotate but stay valid until
|
|
5
|
+
* they expire (Keycloak's default), revocation, RP-initiated logout, and an SSO session in a cookie
|
|
6
|
+
* so a second sign-in in the same browser needs no form.
|
|
7
|
+
*
|
|
8
|
+
* Users are `dev` (a platform administrator), `owner`, `member` and `outsider`; each one's password
|
|
9
|
+
* is its name and its email is `<name>@example.test`, verified. Tests steer it with
|
|
10
|
+
* `expireSession()` (the next refresh is refused as Keycloak refuses an ended session),
|
|
11
|
+
* `failNext()` (the next backchannel call fails as an unreachable issuer would) and `sessions`.
|
|
12
|
+
*/
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { exportJWK, generateKeyPair, jwtVerify, SignJWT } from "jose";
|
|
16
|
+
export const fakeUsers = {
|
|
17
|
+
dev: { username: "dev", name: "Dev User", email: "dev@example.test", roles: ["platform-admin"] },
|
|
18
|
+
owner: { username: "owner", name: "Olive Owner", email: "owner@example.test", roles: [] },
|
|
19
|
+
member: { username: "member", name: "Max Member", email: "member@example.test", roles: [] },
|
|
20
|
+
outsider: { username: "outsider", name: "Otto Outsider", email: "outsider@example.test", roles: [] },
|
|
21
|
+
};
|
|
22
|
+
const sha256b64 = (s) => createHash("sha256").update(s).digest("base64url");
|
|
23
|
+
function html(res, status, body) {
|
|
24
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(body);
|
|
25
|
+
}
|
|
26
|
+
function json(res, status, body) {
|
|
27
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }).end(JSON.stringify(body));
|
|
28
|
+
}
|
|
29
|
+
async function formBody(req) {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
for await (const c of req)
|
|
32
|
+
chunks.push(c);
|
|
33
|
+
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
|
|
34
|
+
}
|
|
35
|
+
function cookie(req, name) {
|
|
36
|
+
for (const part of (req.headers.cookie ?? "").split(";")) {
|
|
37
|
+
const [k, ...v] = part.trim().split("=");
|
|
38
|
+
if (k === name)
|
|
39
|
+
return v.join("=");
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const escape = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
44
|
+
export async function fakeIssuer(options = {}) {
|
|
45
|
+
const clientId = options.clientId ?? "ankka-console";
|
|
46
|
+
const clientSecret = options.clientSecret ?? "dev";
|
|
47
|
+
const audience = options.audience ?? "ankka-controlplane";
|
|
48
|
+
const accessSeconds = options.accessTokenSeconds ?? 300;
|
|
49
|
+
const idleSeconds = options.sessionIdleSeconds ?? 1800;
|
|
50
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
|
51
|
+
const jwk = { ...(await exportJWK(publicKey)), kid: "fake-1", alg: "RS256", use: "sig" };
|
|
52
|
+
const sessions = new Map();
|
|
53
|
+
const codes = new Map();
|
|
54
|
+
const refreshes = new Map();
|
|
55
|
+
let failures = 0;
|
|
56
|
+
let issuer = "";
|
|
57
|
+
let listeningPort = 0;
|
|
58
|
+
/** The issuer as this request would make Keycloak name it; the configured one unless `hostOnly`. */
|
|
59
|
+
const issuerOf = (req) => {
|
|
60
|
+
if (!options.hostOnly)
|
|
61
|
+
return issuer;
|
|
62
|
+
const proto = String(req.headers["x-forwarded-proto"] ?? "http");
|
|
63
|
+
const port = String(req.headers["x-forwarded-port"] ?? listeningPort);
|
|
64
|
+
const shown = (proto === "https" && port === "443") || (proto === "http" && port === "80") ? "" : `:${port}`;
|
|
65
|
+
return `${proto}://${options.hostOnly}${shown}/realms/ankka`;
|
|
66
|
+
};
|
|
67
|
+
const sign = (claims, seconds, iss = issuer, key = privateKey) => new SignJWT(claims)
|
|
68
|
+
.setProtectedHeader({ alg: "RS256", kid: "fake-1", typ: "JWT" })
|
|
69
|
+
.setIssuer(iss)
|
|
70
|
+
.setIssuedAt()
|
|
71
|
+
.setExpirationTime(Math.floor(Date.now() / 1000) + seconds)
|
|
72
|
+
.sign(key);
|
|
73
|
+
async function tokensFor(sub, sessionId, nonce, iss = issuer) {
|
|
74
|
+
const user = fakeUsers[sub];
|
|
75
|
+
const identity = { name: user.name, email: user.email, email_verified: true, preferred_username: user.username };
|
|
76
|
+
const accessToken = await sign({ ...identity, sub, aud: [audience], azp: clientId, sid: sessionId, realm_access: { roles: user.roles }, typ: "Bearer" }, accessSeconds, iss);
|
|
77
|
+
const idToken = await sign({ ...identity, sub, aud: clientId, azp: clientId, sid: sessionId, ...(nonce ? { nonce } : {}) }, accessSeconds, iss);
|
|
78
|
+
// Keycloak's refresh tokens are signed JWTs of several hundred bytes; so are these, so a cookie
|
|
79
|
+
// that carries one is measured at a realistic size.
|
|
80
|
+
const refreshToken = await sign({ sub, sid: sessionId, typ: "Refresh", aud: issuer, jti: randomBytes(16).toString("hex") }, idleSeconds, iss);
|
|
81
|
+
refreshes.set(refreshToken, { sessionId, sub, exp: Date.now() + idleSeconds * 1000, revoked: false });
|
|
82
|
+
return { accessToken, idToken, refreshToken };
|
|
83
|
+
}
|
|
84
|
+
const tokenResponse = (t) => ({
|
|
85
|
+
access_token: t.accessToken,
|
|
86
|
+
id_token: t.idToken,
|
|
87
|
+
refresh_token: t.refreshToken,
|
|
88
|
+
token_type: "Bearer",
|
|
89
|
+
expires_in: accessSeconds,
|
|
90
|
+
refresh_expires_in: idleSeconds,
|
|
91
|
+
scope: "openid",
|
|
92
|
+
});
|
|
93
|
+
function clientAuthenticated(req, form) {
|
|
94
|
+
const header = req.headers.authorization ?? "";
|
|
95
|
+
if (header.startsWith("Basic ")) {
|
|
96
|
+
const [id, secret] = Buffer.from(header.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent);
|
|
97
|
+
return id === clientId && secret === clientSecret;
|
|
98
|
+
}
|
|
99
|
+
return form.get("client_id") === clientId && form.get("client_secret") === clientSecret;
|
|
100
|
+
}
|
|
101
|
+
function loginPage(params, error) {
|
|
102
|
+
const hidden = [...params.entries()]
|
|
103
|
+
.map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
|
|
104
|
+
.join("");
|
|
105
|
+
return `<!doctype html><html lang="en"><head><title>Sign in to ankka</title></head><body>
|
|
106
|
+
<main><h1>Sign in to your account</h1>${error ? `<p id="input-error" role="alert">${escape(error)}</p>` : ""}
|
|
107
|
+
<form id="kc-form-login" method="post" action="${escape(issuer)}/protocol/openid-connect/auth">${hidden}
|
|
108
|
+
<label for="username">Username or email</label><input id="username" name="username" autocomplete="username">
|
|
109
|
+
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password">
|
|
110
|
+
<button id="kc-login" type="submit">Sign In</button></form></main></body></html>`;
|
|
111
|
+
}
|
|
112
|
+
function redirectWithCode(res, params, session) {
|
|
113
|
+
const code = randomBytes(24).toString("base64url");
|
|
114
|
+
codes.set(code, {
|
|
115
|
+
sub: session.sub,
|
|
116
|
+
sessionId: session.id,
|
|
117
|
+
redirectUri: params.get("redirect_uri") ?? "",
|
|
118
|
+
nonce: params.get("nonce") ?? undefined,
|
|
119
|
+
challenge: params.get("code_challenge") ?? undefined,
|
|
120
|
+
expires: Date.now() + 60_000,
|
|
121
|
+
});
|
|
122
|
+
const target = new URL(params.get("redirect_uri"));
|
|
123
|
+
target.searchParams.set("code", code);
|
|
124
|
+
if (params.get("state"))
|
|
125
|
+
target.searchParams.set("state", params.get("state"));
|
|
126
|
+
target.searchParams.set("iss", issuer);
|
|
127
|
+
res
|
|
128
|
+
.writeHead(302, {
|
|
129
|
+
location: target.toString(),
|
|
130
|
+
"set-cookie": `FAKE_SSO=${session.id}; Path=/realms/ankka; HttpOnly; SameSite=Lax`,
|
|
131
|
+
})
|
|
132
|
+
.end();
|
|
133
|
+
}
|
|
134
|
+
const realm = "/realms/ankka";
|
|
135
|
+
const server = createServer(async (req, res) => {
|
|
136
|
+
const url = new URL(req.url ?? "/", "http://fake");
|
|
137
|
+
const path = url.pathname;
|
|
138
|
+
try {
|
|
139
|
+
if (path === `${realm}/.well-known/openid-configuration`) {
|
|
140
|
+
if (failures > 0)
|
|
141
|
+
return (failures--, void req.socket.destroy());
|
|
142
|
+
return json(res, 200, {
|
|
143
|
+
issuer: issuerOf(req),
|
|
144
|
+
authorization_endpoint: `${issuerOf(req)}/protocol/openid-connect/auth`,
|
|
145
|
+
token_endpoint: `${issuerOf(req)}/protocol/openid-connect/token`,
|
|
146
|
+
jwks_uri: `${issuerOf(req)}/protocol/openid-connect/certs`,
|
|
147
|
+
revocation_endpoint: `${issuerOf(req)}/protocol/openid-connect/revoke`,
|
|
148
|
+
end_session_endpoint: `${issuerOf(req)}/protocol/openid-connect/logout`,
|
|
149
|
+
response_types_supported: ["code"],
|
|
150
|
+
subject_types_supported: ["public"],
|
|
151
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
152
|
+
code_challenge_methods_supported: ["S256"],
|
|
153
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
154
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (path === `${realm}/protocol/openid-connect/certs`)
|
|
158
|
+
return json(res, 200, { keys: [jwk] });
|
|
159
|
+
if (path === `${realm}/protocol/openid-connect/auth` && req.method === "GET") {
|
|
160
|
+
const params = url.searchParams;
|
|
161
|
+
if (params.get("client_id") !== clientId)
|
|
162
|
+
return html(res, 400, "<p>Client not found.</p>");
|
|
163
|
+
if (!params.get("redirect_uri"))
|
|
164
|
+
return html(res, 400, "<p>Invalid parameter: redirect_uri</p>");
|
|
165
|
+
const sso = sessions.get(cookie(req, "FAKE_SSO") ?? "");
|
|
166
|
+
if (sso && !sso.ended)
|
|
167
|
+
return redirectWithCode(res, params, sso);
|
|
168
|
+
return html(res, 200, loginPage(params));
|
|
169
|
+
}
|
|
170
|
+
if (path === `${realm}/protocol/openid-connect/auth` && req.method === "POST") {
|
|
171
|
+
const form = await formBody(req);
|
|
172
|
+
const username = form.get("username") ?? "";
|
|
173
|
+
const user = fakeUsers[username] ?? Object.values(fakeUsers).find((u) => u.email === username);
|
|
174
|
+
const params = new URLSearchParams([...form.entries()].filter(([k]) => k !== "username" && k !== "password"));
|
|
175
|
+
if (!user || form.get("password") !== user.username)
|
|
176
|
+
return html(res, 200, loginPage(params, "Invalid username or password."));
|
|
177
|
+
const session = { id: randomBytes(12).toString("hex"), sub: user.username, ended: false };
|
|
178
|
+
sessions.set(session.id, session);
|
|
179
|
+
return redirectWithCode(res, params, session);
|
|
180
|
+
}
|
|
181
|
+
if (path === `${realm}/protocol/openid-connect/token` && req.method === "POST") {
|
|
182
|
+
if (failures > 0)
|
|
183
|
+
return (failures--, void req.socket.destroy());
|
|
184
|
+
const form = await formBody(req);
|
|
185
|
+
if (!clientAuthenticated(req, form))
|
|
186
|
+
return json(res, 401, { error: "unauthorized_client", error_description: "Invalid client or Invalid client credentials" });
|
|
187
|
+
const grant = form.get("grant_type");
|
|
188
|
+
if (grant === "authorization_code") {
|
|
189
|
+
const code = codes.get(form.get("code") ?? "");
|
|
190
|
+
codes.delete(form.get("code") ?? "");
|
|
191
|
+
if (!code || code.expires < Date.now())
|
|
192
|
+
return json(res, 400, { error: "invalid_grant", error_description: "Code not valid" });
|
|
193
|
+
if (code.redirectUri !== form.get("redirect_uri"))
|
|
194
|
+
return json(res, 400, { error: "invalid_grant", error_description: "Incorrect redirect_uri" });
|
|
195
|
+
if (code.challenge && sha256b64(form.get("code_verifier") ?? "") !== code.challenge)
|
|
196
|
+
return json(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
|
|
197
|
+
const session = sessions.get(code.sessionId);
|
|
198
|
+
if (!session || session.ended)
|
|
199
|
+
return json(res, 400, { error: "invalid_grant", error_description: "Session not active" });
|
|
200
|
+
return json(res, 200, tokenResponse(await tokensFor(code.sub, code.sessionId, code.nonce, issuerOf(req))));
|
|
201
|
+
}
|
|
202
|
+
if (grant === "refresh_token") {
|
|
203
|
+
const token = form.get("refresh_token") ?? "";
|
|
204
|
+
const record = refreshes.get(token);
|
|
205
|
+
const session = record ? sessions.get(record.sessionId) : undefined;
|
|
206
|
+
if (!record || record.revoked || record.exp < Date.now() || !session || session.ended)
|
|
207
|
+
return json(res, 400, { error: "invalid_grant", error_description: "Session not active" });
|
|
208
|
+
return json(res, 200, tokenResponse(await tokensFor(record.sub, record.sessionId, undefined, issuerOf(req))));
|
|
209
|
+
}
|
|
210
|
+
return json(res, 400, { error: "unsupported_grant_type" });
|
|
211
|
+
}
|
|
212
|
+
if (path === `${realm}/protocol/openid-connect/revoke` && req.method === "POST") {
|
|
213
|
+
if (failures > 0)
|
|
214
|
+
return (failures--, void req.socket.destroy());
|
|
215
|
+
const form = await formBody(req);
|
|
216
|
+
if (!clientAuthenticated(req, form))
|
|
217
|
+
return json(res, 401, { error: "unauthorized_client" });
|
|
218
|
+
const record = refreshes.get(form.get("token") ?? "");
|
|
219
|
+
if (record)
|
|
220
|
+
record.revoked = true;
|
|
221
|
+
return void res.writeHead(200).end();
|
|
222
|
+
}
|
|
223
|
+
// Keycloak's server-to-server logout: a confidential client posts the session's refresh token
|
|
224
|
+
// and the user session ends, with no browser and no identity token.
|
|
225
|
+
if (path === `${realm}/protocol/openid-connect/logout` && req.method === "POST") {
|
|
226
|
+
if (failures > 0)
|
|
227
|
+
return (failures--, void req.socket.destroy());
|
|
228
|
+
const form = await formBody(req);
|
|
229
|
+
if (!clientAuthenticated(req, form))
|
|
230
|
+
return json(res, 401, { error: "unauthorized_client" });
|
|
231
|
+
const record = refreshes.get(form.get("refresh_token") ?? "");
|
|
232
|
+
if (!record)
|
|
233
|
+
return json(res, 400, { error: "invalid_grant", error_description: "Invalid refresh token" });
|
|
234
|
+
const session = sessions.get(record.sessionId);
|
|
235
|
+
if (session)
|
|
236
|
+
session.ended = true;
|
|
237
|
+
return void res.writeHead(204).end();
|
|
238
|
+
}
|
|
239
|
+
if (path === `${realm}/protocol/openid-connect/logout`) {
|
|
240
|
+
const hint = url.searchParams.get("id_token_hint");
|
|
241
|
+
let sid = cookie(req, "FAKE_SSO");
|
|
242
|
+
if (hint) {
|
|
243
|
+
try {
|
|
244
|
+
const { payload } = await jwtVerify(hint, publicKey, { issuer });
|
|
245
|
+
sid = String(payload.sid);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// An expired hint still names the session in Keycloak; decode without verifying time.
|
|
249
|
+
sid = String(JSON.parse(Buffer.from(hint.split(".")[1], "base64url").toString()).sid);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const session = sessions.get(sid ?? "");
|
|
253
|
+
if (session)
|
|
254
|
+
session.ended = true;
|
|
255
|
+
const after = url.searchParams.get("post_logout_redirect_uri");
|
|
256
|
+
res.setHeader("set-cookie", "FAKE_SSO=; Path=/realms/ankka; Max-Age=0");
|
|
257
|
+
if (after)
|
|
258
|
+
return void res.writeHead(302, { location: after }).end();
|
|
259
|
+
return html(res, 200, "<p>You are logged out.</p>");
|
|
260
|
+
}
|
|
261
|
+
html(res, 404, "<p>Not found</p>");
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
json(res, 500, { error: "server_error", error_description: String(e) });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
await new Promise((resolve) => server.listen(options.port ?? 0, "127.0.0.1", resolve));
|
|
268
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
269
|
+
listeningPort = server.address().port;
|
|
270
|
+
issuer = `${options.publicUrl ?? base}${realm}`;
|
|
271
|
+
return {
|
|
272
|
+
issuer,
|
|
273
|
+
url: base,
|
|
274
|
+
server,
|
|
275
|
+
sessions,
|
|
276
|
+
async verifyAccessToken(token) {
|
|
277
|
+
try {
|
|
278
|
+
const { payload } = await jwtVerify(token, publicKey, { issuer, audience });
|
|
279
|
+
return payload;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
expireSession(sub) {
|
|
286
|
+
for (const s of sessions.values())
|
|
287
|
+
if (s.sub === sub)
|
|
288
|
+
s.ended = true;
|
|
289
|
+
},
|
|
290
|
+
failNext() {
|
|
291
|
+
failures += 1;
|
|
292
|
+
},
|
|
293
|
+
async mint(username) {
|
|
294
|
+
const session = { id: randomBytes(12).toString("hex"), sub: username, ended: false };
|
|
295
|
+
sessions.set(session.id, session);
|
|
296
|
+
return tokensFor(username, session.id);
|
|
297
|
+
},
|
|
298
|
+
close: () => new Promise((resolve) => {
|
|
299
|
+
server.closeAllConnections();
|
|
300
|
+
server.close(() => resolve());
|
|
301
|
+
}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { fakeControlPlane } from "./fake-control-plane.ts";
|
|
2
|
+
export type { FakeClaims, FakeControlPlane, FakeControlPlaneOptions, FakeSeed } from "./fake-control-plane.ts";
|
|
3
|
+
export { fakeIssuer, fakeUsers } from "./fake-issuer.ts";
|
|
4
|
+
export type { FakeIssuer, FakeIssuerOptions, FakeUser } from "./fake-issuer.ts";
|
|
5
|
+
export { scenarios } from "./scenarios.ts";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every acceptance scenario the browser suite must exercise, by the id the specification gives it.
|
|
3
|
+
* A Playwright test covers a scenario by starting its title with the id: `test("US1-3 sign-out …")`.
|
|
4
|
+
* The suite's own scenarios test fails when an id here has no test (SC-011).
|
|
5
|
+
*/
|
|
6
|
+
export declare const scenarios: Record<string, string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every acceptance scenario the browser suite must exercise, by the id the specification gives it.
|
|
3
|
+
* A Playwright test covers a scenario by starting its title with the id: `test("US1-3 sign-out …")`.
|
|
4
|
+
* The suite's own scenarios test fails when an id here has no test (SC-011).
|
|
5
|
+
*/
|
|
6
|
+
export const scenarios = {
|
|
7
|
+
"US1-1": "a deep link sends a stranger to sign in and back to the deep link",
|
|
8
|
+
"US1-2": "no token in any cookie, storage or response body; the cookie is HttpOnly, same-site, small",
|
|
9
|
+
"US1-3": "sign-out ends the console's session and the identity provider's",
|
|
10
|
+
"US1-4": "an ended session sends the person to sign in and back",
|
|
11
|
+
"US1-5": "a callback with a state the console did not issue is refused",
|
|
12
|
+
"US1-6": "a return-to naming another site lands on the front page",
|
|
13
|
+
"US1-7": "a session works on every instance and survives an instance restarting",
|
|
14
|
+
"US1-8": "an unreachable identity provider is explained, and the session is kept",
|
|
15
|
+
"US2-1": "a member sees their organizations with their role, and nothing else",
|
|
16
|
+
"US2-2": "a platform administrator sees every organization",
|
|
17
|
+
"US2-3": "creating an organization lands on it, read by id",
|
|
18
|
+
"US2-4": "an owner renames an organization; a member is not offered it",
|
|
19
|
+
"US2-5": "an empty organization is deleted; a non-empty one is refused with the reason",
|
|
20
|
+
"US2-6": "a deleted id cannot be reused, and the page says so",
|
|
21
|
+
"US2-7": "projects are created, renamed and deleted; one with services cannot be deleted",
|
|
22
|
+
"US2-8": "where only administrators create organizations, the refusal names the sign-up address",
|
|
23
|
+
"US3-1": "a project lists its services with state, instances, image, generation and hostname",
|
|
24
|
+
"US3-2": "a service shows every status field and its attributed history",
|
|
25
|
+
"US3-3": "a descriptor is applied, and a refused one shows every problem",
|
|
26
|
+
"US3-4": "pause, resume, restart, expose and unexpose take effect and show the control plane's state",
|
|
27
|
+
"US3-4a": "a service page and a listing follow the control plane's changes live",
|
|
28
|
+
"US3-5": "an exposed service's hostname is a link",
|
|
29
|
+
"US3-6": "logs with the CLI's choices, followed live",
|
|
30
|
+
"US3-7": "a deleted service is gone, and applying the name again continues its generation",
|
|
31
|
+
"US3-8": "a suspended service reads Suspended and its operations are refused, not hidden",
|
|
32
|
+
"US4-1": "an owner sees members and pending invitations",
|
|
33
|
+
"US4-2": "an invitation is claimed when the invited person signs in",
|
|
34
|
+
"US4-3": "roles change and members are removed; the last owner cannot be",
|
|
35
|
+
"US4-4": "a member sees the list and no controls; a machine is shown as one",
|
|
36
|
+
"US4-5": "a deploy token's secret is shown once, and not after a reload",
|
|
37
|
+
"US4-6": "a revoked token is refused",
|
|
38
|
+
"US4-7": "an administrator disables and enables an organization",
|
|
39
|
+
"US4-8": "an administrator sets and clears a quota",
|
|
40
|
+
"US7-1": "every page is rendered before any script runs",
|
|
41
|
+
"US7-2": "navigation with scripts does not reload the document",
|
|
42
|
+
"US7-3": "every operation works with scripts disabled",
|
|
43
|
+
"US7-4": "a second submission while the first is in flight performs nothing",
|
|
44
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pieces every page shares: where the package is mounted, who is signed in, the host's
|
|
3
|
+
* extensions, links and forms that stay under the mount, and a submit button that cannot be pressed
|
|
4
|
+
* twice while its form is in flight.
|
|
5
|
+
*/
|
|
6
|
+
import { type ReactNode } from "react";
|
|
7
|
+
import { type FormProps, type LinkProps } from "react-router";
|
|
8
|
+
import type { ConsoleExtensions, Operation } from "../extensions/types.ts";
|
|
9
|
+
import type { ConsolePageData } from "../context.ts";
|
|
10
|
+
/**
|
|
11
|
+
* Hands the host's extensions to the pages. Rendered in the host's layout, around its `<Outlet />`,
|
|
12
|
+
* with the same `extensions` object given to `consoleMiddleware`, so panels render in the browser as
|
|
13
|
+
* well as on the server.
|
|
14
|
+
*/
|
|
15
|
+
export declare function ConsoleProvider({ extensions, children }: {
|
|
16
|
+
extensions?: ConsoleExtensions;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
|
+
export interface UseConsole extends ConsolePageData {
|
|
20
|
+
extensions: ConsoleExtensions;
|
|
21
|
+
href(path?: string): string;
|
|
22
|
+
shows(operation: Operation): boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The console's page data, from whichever package page is matched; usable in the host's layout too. */
|
|
25
|
+
export declare function useConsole(): UseConsole;
|
|
26
|
+
/** A link under the mount, fetched ahead when the pointer or focus shows intent. */
|
|
27
|
+
export declare function ConsoleLink({ to, ...rest }: Omit<LinkProps, "to"> & {
|
|
28
|
+
to: string;
|
|
29
|
+
}): import("react").JSX.Element;
|
|
30
|
+
/**
|
|
31
|
+
* A form posting to a path under the mount (the current page when `to` is absent), carrying the
|
|
32
|
+
* operation's name as `intent`. It works as plain HTML with scripts off; with scripts on, its submit
|
|
33
|
+
* control is disabled while it is in flight so a second press sends nothing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ConsoleForm({ to, intent, children, ...rest }: Omit<FormProps, "action"> & {
|
|
36
|
+
to?: string;
|
|
37
|
+
intent?: string;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
40
|
+
export declare function Submit({ intent, children, danger }: {
|
|
41
|
+
intent?: string;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
danger?: boolean;
|
|
44
|
+
}): import("react").JSX.Element;
|
|
45
|
+
/** A labelled field; a refusal about it is announced with it. */
|
|
46
|
+
export declare function Field({ label, name, hint, error, ...input }: {
|
|
47
|
+
label: string;
|
|
48
|
+
name: string;
|
|
49
|
+
hint?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
} & React.InputHTMLAttributes<HTMLInputElement>): import("react").JSX.Element;
|
|
52
|
+
export interface Crumb {
|
|
53
|
+
label: string;
|
|
54
|
+
to?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Where the page sits in the installation: organization, project, service. */
|
|
57
|
+
export declare function Breadcrumbs({ trail }: {
|
|
58
|
+
trail: Crumb[];
|
|
59
|
+
}): import("react").JSX.Element;
|
|
60
|
+
export declare function when(date: string | undefined): string;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The pieces every page shares: where the package is mounted, who is signed in, the host's
|
|
4
|
+
* extensions, links and forms that stay under the mount, and a submit button that cannot be pressed
|
|
5
|
+
* twice while its form is in flight.
|
|
6
|
+
*/
|
|
7
|
+
import { createContext, useContext } from "react";
|
|
8
|
+
import { Form, Link, useMatches, useNavigation } from "react-router";
|
|
9
|
+
const ExtensionsContext = createContext({});
|
|
10
|
+
/**
|
|
11
|
+
* Hands the host's extensions to the pages. Rendered in the host's layout, around its `<Outlet />`,
|
|
12
|
+
* with the same `extensions` object given to `consoleMiddleware`, so panels render in the browser as
|
|
13
|
+
* well as on the server.
|
|
14
|
+
*/
|
|
15
|
+
export function ConsoleProvider({ extensions, children }) {
|
|
16
|
+
return _jsx(ExtensionsContext.Provider, { value: extensions ?? {}, children: children });
|
|
17
|
+
}
|
|
18
|
+
/** The console's page data, from whichever package page is matched; usable in the host's layout too. */
|
|
19
|
+
export function useConsole() {
|
|
20
|
+
const matches = useMatches();
|
|
21
|
+
const extensions = useContext(ExtensionsContext);
|
|
22
|
+
let page;
|
|
23
|
+
for (const m of matches) {
|
|
24
|
+
const d = m.loaderData;
|
|
25
|
+
if (d && typeof d === "object" && d.console)
|
|
26
|
+
page = d.console;
|
|
27
|
+
}
|
|
28
|
+
const mount = page?.mount ?? "/";
|
|
29
|
+
const hidden = new Set([...(page?.hidden ?? []), ...(extensions.hidden ?? [])]);
|
|
30
|
+
return {
|
|
31
|
+
mount,
|
|
32
|
+
principal: page?.principal ?? null,
|
|
33
|
+
hidden: [...hidden],
|
|
34
|
+
extensions,
|
|
35
|
+
href: (path = "") => mount + path.replace(/^\/+/, ""),
|
|
36
|
+
shows: (operation) => !hidden.has(operation),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A link under the mount, fetched ahead when the pointer or focus shows intent. */
|
|
40
|
+
export function ConsoleLink({ to, ...rest }) {
|
|
41
|
+
const { href } = useConsole();
|
|
42
|
+
return _jsx(Link, { to: href(to), prefetch: "intent", ...rest });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A form posting to a path under the mount (the current page when `to` is absent), carrying the
|
|
46
|
+
* operation's name as `intent`. It works as plain HTML with scripts off; with scripts on, its submit
|
|
47
|
+
* control is disabled while it is in flight so a second press sends nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function ConsoleForm({ to, intent, children, ...rest }) {
|
|
50
|
+
const { href } = useConsole();
|
|
51
|
+
return (_jsxs(Form, { method: "post", action: to === undefined ? undefined : href(to), ...rest, children: [intent ? _jsx("input", { type: "hidden", name: "intent", value: intent }) : null, children] }));
|
|
52
|
+
}
|
|
53
|
+
export function Submit({ intent, children, danger }) {
|
|
54
|
+
const navigation = useNavigation();
|
|
55
|
+
const busy = navigation.state === "submitting" && (intent === undefined || navigation.formData?.get("intent") === intent);
|
|
56
|
+
return (_jsx("button", { type: "submit", className: danger ? "ac-button ac-button-danger" : "ac-button", disabled: busy, "aria-busy": busy || undefined, children: children }));
|
|
57
|
+
}
|
|
58
|
+
/** A labelled field; a refusal about it is announced with it. */
|
|
59
|
+
export function Field({ label, name, hint, error, ...input }) {
|
|
60
|
+
const hintId = hint ? `${name}-hint` : undefined;
|
|
61
|
+
const errorId = error ? `${name}-error` : undefined;
|
|
62
|
+
return (_jsxs("div", { className: "ac-field", children: [_jsx("label", { htmlFor: name, children: label }), _jsx("input", { id: name, name: name, "aria-describedby": [hintId, errorId].filter(Boolean).join(" ") || undefined, "aria-invalid": error ? true : undefined, ...input }), hint ? _jsx("p", { className: "ac-hint", id: hintId, children: hint }) : null, error ? _jsx("p", { className: "ac-field-error", id: errorId, children: error }) : null] }));
|
|
63
|
+
}
|
|
64
|
+
/** Where the page sits in the installation: organization, project, service. */
|
|
65
|
+
export function Breadcrumbs({ trail }) {
|
|
66
|
+
return (_jsx("nav", { "aria-label": "Breadcrumb", className: "ac-crumbs", children: _jsx("ol", { children: trail.map((c, i) => (_jsx("li", { children: c.to && i < trail.length - 1 ? _jsx(ConsoleLink, { to: c.to, children: c.label }) : _jsx("span", { "aria-current": i === trail.length - 1 ? "page" : undefined, children: c.label }) }, i))) }) }));
|
|
67
|
+
}
|
|
68
|
+
export function when(date) {
|
|
69
|
+
if (!date)
|
|
70
|
+
return "—";
|
|
71
|
+
const d = new Date(date);
|
|
72
|
+
return Number.isNaN(d.getTime()) ? date : d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }) + " UTC";
|
|
73
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** A refusal from the control plane, shown beside the thing refused, in the control plane's words. */
|
|
2
|
+
export declare function Refusal({ reason, problems }: {
|
|
3
|
+
reason?: string;
|
|
4
|
+
problems?: string[];
|
|
5
|
+
}): import("react").JSX.Element | null;
|
|
6
|
+
export declare function ConsoleErrorBoundary(): import("react").JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* What a page shows instead of itself when it cannot be shown: access lost, a refusal, something
|
|
4
|
+
* unavailable. Every package route exports `ErrorBoundary` from here.
|
|
5
|
+
*/
|
|
6
|
+
import { isRouteErrorResponse, useRouteError } from "react-router";
|
|
7
|
+
import { ConsoleLink } from "./console.js";
|
|
8
|
+
/** A refusal from the control plane, shown beside the thing refused, in the control plane's words. */
|
|
9
|
+
export function Refusal({ reason, problems }) {
|
|
10
|
+
if (!reason && (!problems || problems.length === 0))
|
|
11
|
+
return null;
|
|
12
|
+
return (_jsx("div", { className: "ac-refusal", role: "alert", children: problems && problems.length > 1 ? (_jsxs(_Fragment, { children: [_jsx("p", { children: "The control plane refused this:" }), _jsx("ul", { children: problems.map((p) => (_jsx("li", { children: p }, p))) })] })) : (_jsx("p", { children: problems?.[0] ?? reason })) }));
|
|
13
|
+
}
|
|
14
|
+
export function ConsoleErrorBoundary() {
|
|
15
|
+
const error = useRouteError();
|
|
16
|
+
if (isRouteErrorResponse(error) && error.data && typeof error.data === "object" && "kind" in error.data) {
|
|
17
|
+
const failure = error.data;
|
|
18
|
+
if (failure.kind === "lost") {
|
|
19
|
+
return (_jsxs("section", { className: "ac-page ac-failure", children: [_jsx("h1", { children: "You no longer have access to this" }), _jsx("p", { children: "It was deleted, or you are no longer a member of the organization it belongs to." }), _jsx("p", { children: _jsx(ConsoleLink, { to: "", children: "Go to your organizations" }) })] }));
|
|
20
|
+
}
|
|
21
|
+
if (failure.kind === "unavailable") {
|
|
22
|
+
return (_jsxs("section", { className: "ac-page ac-failure", children: [_jsxs("h1", { children: ["The ", failure.what, " is not answering"] }), _jsx("p", { children: "Your session is kept. Try again in a few seconds." }), _jsx("p", { className: "ac-hint", children: failure.reason }), _jsx("p", { children: _jsx("a", { className: "ac-button", href: "", children: "Try again" }) })] }));
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs("section", { className: "ac-page ac-failure", children: [_jsx("h1", { children: "This was refused" }), _jsx(Refusal, { reason: failure.reason }), _jsx("p", { children: _jsx(ConsoleLink, { to: "", children: "Go to your organizations" }) })] }));
|
|
25
|
+
}
|
|
26
|
+
if (isRouteErrorResponse(error) && error.status === 404) {
|
|
27
|
+
return (_jsxs("section", { className: "ac-page ac-failure", children: [_jsx("h1", { children: "There is no page here" }), _jsx("p", { children: _jsx(ConsoleLink, { to: "", children: "Go to your organizations" }) })] }));
|
|
28
|
+
}
|
|
29
|
+
return (_jsxs("section", { className: "ac-page ac-failure", children: [_jsx("h1", { children: "The console could not show this page" }), _jsx("p", { children: "Reload to try again. If it keeps happening, the console's own log says why." })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ActionRefusal } from "../context.ts";
|
|
2
|
+
/** The refusal for this form's intent, if the last action was refused; the page shows it beside the form. */
|
|
3
|
+
export declare function useRefusal(intent: string): ActionRefusal | undefined;
|
|
4
|
+
export declare function Refused({ intent }: {
|
|
5
|
+
intent: string;
|
|
6
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useActionData } from "react-router";
|
|
3
|
+
import { Refusal } from "./errors.js";
|
|
4
|
+
/** The refusal for this form's intent, if the last action was refused; the page shows it beside the form. */
|
|
5
|
+
export function useRefusal(intent) {
|
|
6
|
+
const result = useActionData();
|
|
7
|
+
return result && typeof result === "object" && "intent" in result && result.intent === intent && "reason" in result ? result : undefined;
|
|
8
|
+
}
|
|
9
|
+
export function Refused({ intent }) {
|
|
10
|
+
const refusal = useRefusal(intent);
|
|
11
|
+
return refusal ? _jsx(Refusal, { reason: refusal.reason, problems: refusal.problems }) : null;
|
|
12
|
+
}
|