@agilesyndrome/cf-genai-base 0.1.5 → 0.2.5
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/CONTRACT.md +11 -2
- package/README.md +8 -2
- package/migrations/0001_authorization.sql +32 -0
- package/package.json +7 -3
- package/src/authorization.js +70 -0
- package/src/index.js +83 -1
- package/src/ui/index.js +51 -0
- package/src/ui/styles.css +9 -0
package/CONTRACT.md
CHANGED
|
@@ -4,7 +4,7 @@ Every site built from this foundation follows the same edge contract.
|
|
|
4
4
|
|
|
5
5
|
## Worker entrypoint
|
|
6
6
|
|
|
7
|
-
`createWorker({ fetch, features?, middleware?, auth?, scheduled?, security? })` owns the Worker lifecycle. Features run in declaration order and may call `next()` or return a response. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
|
|
7
|
+
`createWorker({ fetch, features?, middleware?, auth?, authorize?, scheduled?, security? })` owns the Worker lifecycle and reserved admin boundary. Features run in declaration order and may call `next()` or return a response. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
|
|
8
8
|
is optional and must use `ctx.waitUntil` for background work.
|
|
9
9
|
|
|
10
10
|
## Routes
|
|
@@ -12,7 +12,12 @@ is optional and must use `ctx.waitUntil` for background work.
|
|
|
12
12
|
- `GET /health` returns `{ ok, version, build_number }` and is cache-disabled.
|
|
13
13
|
- `GET /api/me` returns `{ user: null | { sub, email, name, ...roles } }`.
|
|
14
14
|
- `/auth/login`, `/auth/callback`, and `/auth/logout` are reserved for auth.
|
|
15
|
-
-
|
|
15
|
+
- `/admin` and `/admin/*` are browser admin routes; `/api/admin` and `/api/admin/*` are admin API routes.
|
|
16
|
+
- Admin routes use `AUTH_STRATEGY`; omitted or empty means `http_basic`. Basic auth accepts username `admin` and the value of `ADMIN_TOKEN` (with `admin_token` supported for compatibility). Missing token means all admin routes return 401.
|
|
17
|
+
- `AUTH_STRATEGY=oauth` delegates identity establishment to the configured auth provider and uses `authorize` for admin policy.
|
|
18
|
+
- `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
|
|
19
|
+
- Base provides `/api/admin/users`, `/api/admin/scopes`, and `/api/admin/users/:id/scopes` for platform administrators when the authorization migration is installed.
|
|
20
|
+
- Public APIs must be explicitly listed in provider-specific auth configuration.
|
|
16
21
|
- Mutating `/api/*` requests require a same-origin `Origin` header.
|
|
17
22
|
|
|
18
23
|
## Environment and bindings
|
|
@@ -32,6 +37,10 @@ Standard bindings:
|
|
|
32
37
|
|
|
33
38
|
Build metadata is optional: `BUILD_SHA` and `BUILD_NUMBER`.
|
|
34
39
|
|
|
40
|
+
The package includes `migrations/0001_authorization.sql`; each site must apply
|
|
41
|
+
the equivalent migration to its own D1 database before enabling the generic
|
|
42
|
+
user/scope APIs.
|
|
43
|
+
|
|
35
44
|
D1 migrations are committed with the site, applied by Wrangler, and are the
|
|
36
45
|
source of truth for schema changes. R2 stores binary data; metadata and access
|
|
37
46
|
control remain in D1.
|
package/README.md
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Opinionated startup boilerplate for small Cloudflare Workers.
|
|
4
4
|
|
|
5
|
-
The base is
|
|
6
|
-
queries, R2 keys, and scheduled jobs. `createWorker` composes ordered feature middleware, normalizes uncaught failures, and applies baseline response headers.
|
|
5
|
+
The base owns the shared security boundary as well as Worker lifecycle concerns. It reserves `/admin` and `/api/admin` routes, authenticates them using `AUTH_STRATEGY` (default `http_basic`, or `oauth` when an auth provider is supplied), and applies the optional `authorize` policy. Sites still own their router, HTML, D1 queries, R2 keys, and scheduled jobs.
|
|
7
6
|
Use D1 bindings for durable application data and R2 bindings for binary assets;
|
|
8
7
|
do not put either into module-level state.
|
|
9
8
|
|
|
9
|
+
Base also provides provider-neutral authorization helpers and browser components
|
|
10
|
+
through `@agilesyndrome/cf-genai-base/authorization` and
|
|
11
|
+
`@agilesyndrome/cf-genai-base/ui`. Applications declare their scope manifest,
|
|
12
|
+
while base owns the user, scope, and grant records plus the generic user-access
|
|
13
|
+
API. The UI components are themeable with CSS custom properties and do not
|
|
14
|
+
contain application-specific components.
|
|
15
|
+
|
|
10
16
|
```js
|
|
11
17
|
import { createWorker, healthResponse } from "@agilesyndrome/cf-genai-base";
|
|
12
18
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS auth_users (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
provider TEXT NOT NULL,
|
|
4
|
+
subject TEXT NOT NULL,
|
|
5
|
+
email TEXT NOT NULL DEFAULT '',
|
|
6
|
+
display_name TEXT NOT NULL DEFAULT '',
|
|
7
|
+
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1)),
|
|
8
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
9
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
UNIQUE(provider, subject)
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS auth_users_email_idx ON auth_users(email COLLATE NOCASE);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS auth_scopes (
|
|
16
|
+
name TEXT PRIMARY KEY,
|
|
17
|
+
label TEXT NOT NULL,
|
|
18
|
+
description TEXT NOT NULL DEFAULT '',
|
|
19
|
+
system INTEGER NOT NULL DEFAULT 0 CHECK (system IN (0,1)),
|
|
20
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
21
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS auth_user_scopes (
|
|
25
|
+
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
|
26
|
+
scope_name TEXT NOT NULL REFERENCES auth_scopes(name) ON DELETE CASCADE,
|
|
27
|
+
granted_by TEXT,
|
|
28
|
+
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
PRIMARY KEY (user_id, scope_name)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS auth_user_scopes_scope_idx ON auth_user_scopes(scope_name);
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilesyndrome/cf-genai-base",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
".": "./src/index.js"
|
|
6
|
+
".": "./src/index.js",
|
|
7
|
+
"./authorization": "./src/authorization.js",
|
|
8
|
+
"./ui": "./src/ui/index.js",
|
|
9
|
+
"./ui/styles.css": "./src/ui/styles.css"
|
|
7
10
|
},
|
|
8
11
|
"description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
|
|
9
12
|
"license": "MIT",
|
|
@@ -13,6 +16,7 @@
|
|
|
13
16
|
},
|
|
14
17
|
"files": [
|
|
15
18
|
"src",
|
|
19
|
+
"migrations",
|
|
16
20
|
"README.md",
|
|
17
21
|
"CONTRACT.md",
|
|
18
22
|
"LICENSE"
|
|
@@ -24,7 +28,7 @@
|
|
|
24
28
|
"homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
|
|
25
29
|
"scripts": {
|
|
26
30
|
"check": "node --check src/index.js",
|
|
27
|
-
"test": "node --
|
|
31
|
+
"test": "node --test tests/*.test.mjs",
|
|
28
32
|
"build": "npm run check && npm test && npm pack --dry-run"
|
|
29
33
|
}
|
|
30
34
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const AUTH_USER_TABLE = "auth_users";
|
|
2
|
+
export const AUTH_SCOPE_TABLE = "auth_scopes";
|
|
3
|
+
export const AUTH_GRANT_TABLE = "auth_user_scopes";
|
|
4
|
+
|
|
5
|
+
export function normalizeScopes(scopes = []) {
|
|
6
|
+
return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
|
|
7
|
+
.filter((scope) => scope && /^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(String(scope.name || "")))
|
|
8
|
+
.map((scope) => ({ name: String(scope.name), label: String(scope.label || scope.name), description: String(scope.description || ""), system: Boolean(scope.system) }));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ensureScopes(env, scopes = []) {
|
|
12
|
+
if (!env?.DB) return;
|
|
13
|
+
for (const scope of normalizeScopes(scopes)) {
|
|
14
|
+
await env.DB.prepare(`INSERT INTO ${AUTH_SCOPE_TABLE} (name,label,description,system) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET label=excluded.label,description=excluded.description,system=excluded.system`).bind(scope.name, scope.label, scope.description, scope.system ? 1 : 0).run();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function ensureUser(env, user) {
|
|
19
|
+
if (!env?.DB || !user?.sub) return null;
|
|
20
|
+
const provider = String(user.auth_strategy || "oauth");
|
|
21
|
+
const subject = String(user.sub);
|
|
22
|
+
const email = String(user.email || "").trim().toLowerCase();
|
|
23
|
+
const existing = await env.DB.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
|
|
24
|
+
const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
|
|
25
|
+
if (existing) {
|
|
26
|
+
await env.DB.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0, existing.id).run();
|
|
27
|
+
return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrap.has(email)) };
|
|
28
|
+
}
|
|
29
|
+
const id = await stableId(`${provider}:${subject}`);
|
|
30
|
+
await env.DB.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0).run();
|
|
31
|
+
return await env.DB.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function hasScope(env, user, scope) {
|
|
35
|
+
if (user?.auth_strategy === "http_basic") return true;
|
|
36
|
+
const authUser = await ensureUser(env, user);
|
|
37
|
+
if (!authUser) return false;
|
|
38
|
+
if (Boolean(authUser.is_admin)) return true;
|
|
39
|
+
return Boolean(await env.DB.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function listAuthorizationUsers(env) {
|
|
43
|
+
const { results } = await env.DB.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
|
|
44
|
+
return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id)).map((grant) => grant.scope_name) })));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function listAuthorizationScopes(env) {
|
|
48
|
+
const { results } = await env.DB.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function listUserGrants(env, userId) {
|
|
53
|
+
const { results } = await env.DB.prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function replaceUserGrants(env, userId, scopes, grantedBy) {
|
|
58
|
+
const valid = new Set((await listAuthorizationScopes(env)).map((scope) => scope.name));
|
|
59
|
+
const requested = [...new Set(scopes)].filter((scope) => valid.has(scope));
|
|
60
|
+
await env.DB.batch([
|
|
61
|
+
env.DB.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId),
|
|
62
|
+
...requested.map((scope) => env.DB.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))
|
|
63
|
+
]);
|
|
64
|
+
return requested;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function stableId(value) {
|
|
68
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
69
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
70
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* Lean, opinionated Worker composition for Cloudflare sites.
|
|
3
3
|
* Site code owns domain routes and data; this owns lifecycle and edge concerns.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listUserGrants, replaceUserGrants } from "./authorization.js";
|
|
6
|
+
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
|
|
6
7
|
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
8
|
+
const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
|
|
7
9
|
const chain = [
|
|
10
|
+
(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }),
|
|
8
11
|
...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
|
|
9
12
|
...middleware,
|
|
10
13
|
...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
|
|
@@ -39,6 +42,85 @@ export function createWorker({ fetch, scheduled, auth, middleware = [], features
|
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
|
|
46
|
+
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }) {
|
|
47
|
+
const url = new URL(request.url);
|
|
48
|
+
if (!isAdminPath(url.pathname)) return next(request);
|
|
49
|
+
const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
|
|
50
|
+
if (strategy === "http_basic") {
|
|
51
|
+
const user = basicUser(request, env);
|
|
52
|
+
if (!user) return adminUnauthorized(request);
|
|
53
|
+
state.user = user;
|
|
54
|
+
} else if (strategy === "oauth") {
|
|
55
|
+
const user = provider?.getUser ? await provider.getUser(request, env) : null;
|
|
56
|
+
if (!user) return oauthUnauthorized(request, url);
|
|
57
|
+
state.user = user;
|
|
58
|
+
} else {
|
|
59
|
+
return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
|
|
60
|
+
}
|
|
61
|
+
await ensureScopes(env, scopes);
|
|
62
|
+
state.authUser = await ensureUser(env, state.user);
|
|
63
|
+
const requiredScope = requiredScopeFor(url.pathname, scopeRoutes);
|
|
64
|
+
const scopeAllowed = !requiredScope || await hasScope(env, state.user, requiredScope);
|
|
65
|
+
if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) {
|
|
66
|
+
return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
67
|
+
}
|
|
68
|
+
const platformResponse = await authorizationApi(request, env, url, state);
|
|
69
|
+
return platformResponse || next(request);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requiredScopeFor(pathname, routes) {
|
|
73
|
+
const route = routes.find((entry) => typeof entry.match === "function" ? entry.match(pathname) : pathname === entry.path || pathname.startsWith(String(entry.path || "") + "/"));
|
|
74
|
+
return route && route.scope ? route.scope : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function authorizationApi(request, env, url, state) {
|
|
78
|
+
const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
|
|
79
|
+
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || Boolean(grantsMatch);
|
|
80
|
+
if (!platformPath) return null;
|
|
81
|
+
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
82
|
+
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env) });
|
|
83
|
+
if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env) });
|
|
84
|
+
if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1])) });
|
|
85
|
+
if (grantsMatch && request.method === "PUT") {
|
|
86
|
+
const body = await request.json().catch(() => null);
|
|
87
|
+
if (!body || !Array.isArray(body.scopes)) return Response.json({ error: "scopes must be an array" }, { status: 400 });
|
|
88
|
+
const grants = await replaceUserGrants(env, decodeURIComponent(grantsMatch[1]), body.scopes, state.authUser && state.authUser.id);
|
|
89
|
+
return Response.json({ grants });
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isAdminPath(pathname) {
|
|
95
|
+
return pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/api/admin" || pathname.startsWith("/api/admin/");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function basicUser(request, env) {
|
|
99
|
+
const token = String(env?.ADMIN_TOKEN || env?.admin_token || "");
|
|
100
|
+
if (!token) return null;
|
|
101
|
+
const header = request.headers.get("Authorization") || "";
|
|
102
|
+
if (!header.toLowerCase().startsWith("basic ")) return null;
|
|
103
|
+
let decoded;
|
|
104
|
+
try { decoded = atob(header.slice(6).trim()); } catch { return null; }
|
|
105
|
+
const separator = decoded.indexOf(":");
|
|
106
|
+
if (separator < 0) return null;
|
|
107
|
+
if (!constantTimeEqual(decoded.slice(0, separator), "admin") || !constantTimeEqual(decoded.slice(separator + 1), token)) return null;
|
|
108
|
+
return { sub: "basic:admin", email: "", name: "admin", roles: ["admin"], auth_strategy: "http_basic" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function adminUnauthorized(request) {
|
|
112
|
+
const headers = { "Cache-Control": "no-store", "WWW-Authenticate": "Basic realm=\"admin\", charset=\"UTF-8\"" };
|
|
113
|
+
return new URL(request.url).pathname.startsWith("/api/") ? Response.json({ error: "Authentication is required." }, { status: 401, headers }) : new Response("Authentication is required.", { status: 401, headers });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function oauthUnauthorized(request, url) {
|
|
117
|
+
if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } });
|
|
118
|
+
return Response.redirect(url.origin + "/auth/login?return_to=" + encodeURIComponent(safeReturnTo(url.pathname + url.search)), 302);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
|
|
122
|
+
function constantTimeEqual(a, b) { const aa = new TextEncoder().encode(a), bb = new TextEncoder().encode(b); let n = aa.length ^ bb.length; for (let i = 0; i < Math.max(aa.length, bb.length); i++) n |= (aa[i] || 0) ^ (bb[i] || 0); return n === 0; }
|
|
123
|
+
|
|
42
124
|
export function validateBoot(env, { bindings = [], required = [] } = {}) {
|
|
43
125
|
const missingBindings = bindings.filter((name) => !env?.[name]);
|
|
44
126
|
const missingValues = required.filter((name) => !env?.[name] || String(env[name]).startsWith("replace-with-"));
|
package/src/ui/index.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#20231f; --cf-ui-muted:#687067; --cf-ui-border:#d8ddd5; --cf-ui-primary:#2f6f52; color:var(--cf-ui-text); font:15px/1.45 system-ui,sans-serif } *,*::before,*::after{box-sizing:border-box}.shell{display:grid;gap:1rem}.nav{display:flex;flex-wrap:wrap;gap:.5rem;border-bottom:1px solid var(--cf-ui-border);padding-bottom:.75rem}.nav a{color:var(--cf-ui-text);padding:.45rem .7rem;border-radius:.4rem;text-decoration:none}.nav a:hover,.nav a[aria-current=page]{background:var(--cf-ui-surface);color:var(--cf-ui-primary)}.card{background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.6rem;padding:1rem;overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:.65rem;border-bottom:1px solid var(--cf-ui-border);text-align:left;vertical-align:top}th{color:var(--cf-ui-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}button{border:1px solid var(--cf-ui-border);border-radius:.4rem;background:var(--cf-ui-bg);color:inherit;padding:.45rem .65rem;cursor:pointer}.scope-list{display:grid;gap:.3rem;min-width:14rem}.scope-list label{display:flex;gap:.4rem;align-items:center}.status{color:var(--cf-ui-muted);min-height:1.4em}`;
|
|
2
|
+
|
|
3
|
+
export class CfAdminShell extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
const active = this.getAttribute("active") || "";
|
|
6
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><div class="shell"><nav class="nav" part="navigation"><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Admin</a><a href="/admin/users" ${active === "users" ? 'aria-current="page"' : ""}>Users</a><a href="/admin/scopes" ${active === "scopes" ? 'aria-current="page"' : ""}>Scopes</a></nav><slot></slot></div>`;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class CfScopeBadge extends HTMLElement {
|
|
11
|
+
connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}.badge{display:inline-block;border:1px solid var(--cf-ui-border);border-radius:999px;padding:.15rem .5rem;color:var(--cf-ui-primary);background:var(--cf-ui-surface);font-size:.85rem}</style><span class="badge" part="badge"></span>`; this.shadowRoot.querySelector(".badge").textContent = this.getAttribute("scope") || this.textContent || ""; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CfUserManagement extends HTMLElement {
|
|
15
|
+
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
|
|
16
|
+
get status() { return this.shadowRoot.querySelector(".status"); }
|
|
17
|
+
async load() { const [usersResponse, scopesResponse] = await Promise.all([fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/scopes", { credentials: "same-origin" })]); if (!usersResponse.ok || !scopesResponse.ok) throw new Error("Unable to load user access."); const users = (await usersResponse.json()).users || []; const scopes = (await scopesResponse.json()).scopes || []; const body = this.shadowRoot.querySelector("tbody"); body.replaceChildren(...users.map((user) => this.row(user, scopes))); this.shadowRoot.querySelector("table").hidden = false; this.status.textContent = `${users.length} user${users.length === 1 ? "" : "s"}`; }
|
|
18
|
+
row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); label.append(input, document.createTextNode(scope.label || scope.name)); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const selected = [...list.querySelectorAll("input:checked")].map((input) => input.value); const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selected }) }); button.disabled = false; this.status.textContent = response.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, grants, action); return row; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);
|
|
22
|
+
if (!customElements.get("cf-scope-badge")) customElements.define("cf-scope-badge", CfScopeBadge);
|
|
23
|
+
if (!customElements.get("cf-user-management")) customElements.define("cf-user-management", CfUserManagement);
|
|
24
|
+
|
|
25
|
+
export class CfScopeCatalog extends HTMLElement {
|
|
26
|
+
async connectedCallback() {
|
|
27
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card" part="panel"><h2>Available scopes</h2><p class="status" part="status">Loading scopes…</p><div class="scope-list" hidden part="list"></div></section>`;
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch("/api/admin/scopes", { credentials: "same-origin" });
|
|
30
|
+
if (!response.ok) throw new Error("Unable to load scopes.");
|
|
31
|
+
const scopes = (await response.json()).scopes || [];
|
|
32
|
+
const list = this.shadowRoot.querySelector(".scope-list");
|
|
33
|
+
list.replaceChildren(...scopes.map((scope) => {
|
|
34
|
+
const item = document.createElement("div");
|
|
35
|
+
const badge = document.createElement("cf-scope-badge");
|
|
36
|
+
badge.setAttribute("scope", scope.name);
|
|
37
|
+
const description = document.createElement("span");
|
|
38
|
+
description.textContent = scope.description || scope.label || scope.name;
|
|
39
|
+
item.append(badge, description);
|
|
40
|
+
return item;
|
|
41
|
+
}));
|
|
42
|
+
list.hidden = false;
|
|
43
|
+
this.shadowRoot.querySelector(".status").textContent = `${scopes.length} scope${scopes.length === 1 ? "" : "s"}`;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
this.shadowRoot.querySelector(".status").textContent = error.message || "Unable to load scopes.";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|
|
51
|
+
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|