Address whole-project architecture + product reviews (todo §5): make readRoles transitive so group→role grants reach the JWT (matches the Roles 'Effective access' view + OPL model; per-login only), per the user's call; add a zero-JS server-rendered confirm step for delete user/group/role (views/admin/confirm.ejs + shared buildConfirmModel; the Delete control is now a GET link, the delete stays a CSRF-guarded POST); self-lockout guards — no self-delete/deactivate (Users), no self-revoke of the direct admin grant + no delete of the admin role (Roles), each → 400 + inline error (direct-grant paths incl. the seeded admin; group-only-admin lockout = robust last-effective-admin check deferred §9); extract the gate+CSRF preamble copied across the 3 admin handlers into admin-nav.ts requireAdmin/guardedForm; shellUser keeps the email (name = local part, full email beneath). Reviewers: architecture no Critical/High, product 2 Critical + 1 High (all fixed). Deferred (scoped): host route-table→§6, list/template dedup→§5 cleanup, success-flash/empty-states/dangling-refs→§5 polish/§8, safeUrl→§7, 413/https/§N-drift→§9. Tests-first (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual + auth-refresh E2E green; stability-reviewer APPROVE
This commit is contained in:
@@ -409,7 +409,7 @@ session cookie.
|
|||||||
```
|
```
|
||||||
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
||||||
Kratos verifies credentials
|
Kratos verifies credentials
|
||||||
└─► app reads the user's roles from Keto (Keto = source of truth)
|
└─► app reads the user's roles from Keto (direct + transitive via groups)
|
||||||
└─► app writes them as a derived projection on the identity (admin API)
|
└─► app writes them as a derived projection on the identity (admin API)
|
||||||
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
||||||
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
|
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
|
||||||
@@ -432,7 +432,11 @@ and the user can already read these coarse roles in their own JWT, so nothing is
|
|||||||
That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and
|
That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and
|
||||||
a stale one self-heals on the next login.
|
a stale one self-heals on the next login.
|
||||||
|
|
||||||
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
|
A role can be granted to a user directly or to a **group** the user belongs to; login
|
||||||
|
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the
|
||||||
|
JWT `roles` match what the admin **Effective access** view shows.
|
||||||
|
|
||||||
|
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
||||||
is cached, so even signature verification hits the network only on key rotation. The
|
is cached, so even signature verification hits the network only on key rotation. The
|
||||||
app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one
|
app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one
|
||||||
moment authz is recomputed from Keto.
|
moment authz is recomputed from Keto.
|
||||||
|
|||||||
@@ -7,12 +7,9 @@
|
|||||||
// building-block view models; `handleAdminGroups` is the imperative shell app.ts dispatches to — it
|
// building-block view models; `handleAdminGroups` is the imperative shell app.ts dispatches to — it
|
||||||
// gates (admin only), CSRF-guards every mutation, and maps each action to a RouteResult.
|
// gates (admin only), CSRF-guards every mutation, and maps each action to a RouteResult.
|
||||||
|
|
||||||
import { ADMIN_GROUPS_BASE, ADMIN_PERMISSION, adminNav } from "./admin-nav.ts";
|
import { ADMIN_GROUPS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
|
||||||
import type { RequestContext, User } from "./context.ts";
|
import type { RequestContext, User } from "./context.ts";
|
||||||
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
|
|
||||||
import { GuardError } from "./guards.ts";
|
|
||||||
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
|
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
import { parseListQuery } from "./list-query.ts";
|
import { parseListQuery } from "./list-query.ts";
|
||||||
@@ -334,21 +331,11 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
|
|||||||
const path = ctx.url.pathname;
|
const path = ctx.url.pathname;
|
||||||
if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null;
|
if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null;
|
||||||
|
|
||||||
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
|
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||||
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
|
||||||
|
|
||||||
const { keto, kratosAdmin, menu, render } = deps;
|
const { keto, kratosAdmin, menu, render } = deps;
|
||||||
const user = ctx.user;
|
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||||
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
|
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
|
||||||
|
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||||
let form: URLSearchParams | undefined;
|
|
||||||
if (method === "POST") {
|
|
||||||
form = await readFormBody(ctx.req);
|
|
||||||
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
|
||||||
throw new GuardError(403, "invalid CSRF token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderList = async (): Promise<RouteResult> => {
|
const renderList = async (): Promise<RouteResult> => {
|
||||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||||
@@ -397,6 +384,13 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
|
|||||||
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
|
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
|
||||||
return { redirect: base };
|
return { redirect: base };
|
||||||
}
|
}
|
||||||
|
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||||
|
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||||
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken,
|
||||||
|
current: "groups", menu, message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group", user,
|
||||||
|
}) }) };
|
||||||
|
}
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
||||||
return { redirect: ADMIN_GROUPS_BASE };
|
return { redirect: ADMIN_GROUPS_BASE };
|
||||||
|
|||||||
@@ -4,8 +4,13 @@
|
|||||||
// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home
|
// for a non-admin), and `adminNav()` is the in-screen sidebar each admin screen renders (a link home
|
||||||
// + the same section, with the active item marked `current`).
|
// + the same section, with the active item marked `current`).
|
||||||
|
|
||||||
|
import { readFormBody } from "./body.ts";
|
||||||
|
import type { RequestContext, User } from "./context.ts";
|
||||||
|
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
|
||||||
|
import { GuardError } from "./guards.ts";
|
||||||
import { type MenuConfig } from "./menu-config.ts";
|
import { type MenuConfig } from "./menu-config.ts";
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
import { buildShellContext } from "./shell-context.ts";
|
||||||
|
|
||||||
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
|
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
@@ -40,3 +45,45 @@ export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen
|
|||||||
adminSection(current),
|
adminSection(current),
|
||||||
]], menu.override, roles);
|
]], menu.override, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps
|
||||||
|
// (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on.
|
||||||
|
export function requireAdmin(ctx: RequestContext): User {
|
||||||
|
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
|
||||||
|
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
||||||
|
return ctx.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read + CSRF-verify a mutation's form body once. Every admin write is a first-party POST form, so a
|
||||||
|
// POST without a valid double-submit token is refused (GuardError → 403); non-POST ⇒ undefined.
|
||||||
|
export async function guardedForm(ctx: RequestContext, csrfSecret: string): Promise<URLSearchParams | undefined> {
|
||||||
|
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
|
||||||
|
const form = await readFormBody(ctx.req);
|
||||||
|
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
||||||
|
throw new GuardError(403, "invalid CSRF token");
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the model for the shared destructive-action confirm page (views/admin/confirm.ejs): a single
|
||||||
|
// danger action behind a deliberate second step, plus a cancel link. Reused by all three screens.
|
||||||
|
export function buildConfirmModel(opts: {
|
||||||
|
breadcrumbs: { href?: string; label: string }[];
|
||||||
|
cancelHref: string;
|
||||||
|
confirmAction: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
csrfToken: string;
|
||||||
|
current: AdminScreen;
|
||||||
|
menu: MenuConfig;
|
||||||
|
message: string;
|
||||||
|
title: string;
|
||||||
|
user: User | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
cancelHref: opts.cancelHref,
|
||||||
|
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
|
||||||
|
message: opts.message,
|
||||||
|
nav: adminNav(opts.user?.roles ?? [], opts.menu, opts.current),
|
||||||
|
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
// the user|group membership model of the Groups screen, so the pure helpers (parseSubject, member
|
// the user|group membership model of the Groups screen, so the pure helpers (parseSubject, member
|
||||||
// pickers, tuple paging) are reused from admin-groups. The one role-specific piece is the **effective
|
// pickers, tuple paging) are reused from admin-groups. The one role-specific piece is the **effective
|
||||||
// access** view: `keto.expand(Role:<name>#members)` returns the membership tree, which we flatten to
|
// access** view: `keto.expand(Role:<name>#members)` returns the membership tree, which we flatten to
|
||||||
// the distinct set of users who hold the role directly or transitively via a group. (The coarse JWT
|
// the distinct set of users who hold the role directly or transitively via a group. Login resolves
|
||||||
// projection reads only direct grants per the README's one-read-per-login design; this view is where
|
// the same transitive membership into the JWT `roles` (login.ts readRoles), so this view matches what
|
||||||
// group→role inheritance is surfaced.) Writes go only to Keto; Kratos is read only to label members.
|
// a user's token actually grants. Writes go only to Keto; Kratos is read only to label members.
|
||||||
// `handleAdminRoles` is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
|
// `handleAdminRoles` is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav } from "./admin-nav.ts";
|
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||||
import {
|
import {
|
||||||
type GroupView,
|
type GroupView,
|
||||||
groupsFromTuples,
|
groupsFromTuples,
|
||||||
@@ -23,10 +23,7 @@ import {
|
|||||||
safeDecode,
|
safeDecode,
|
||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
|
||||||
import type { RequestContext, User } from "./context.ts";
|
import type { RequestContext, User } from "./context.ts";
|
||||||
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
|
|
||||||
import { GuardError } from "./guards.ts";
|
|
||||||
import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
|
import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
import { parseListQuery } from "./list-query.ts";
|
import { parseListQuery } from "./list-query.ts";
|
||||||
@@ -298,21 +295,11 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
|||||||
const path = ctx.url.pathname;
|
const path = ctx.url.pathname;
|
||||||
if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null;
|
if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null;
|
||||||
|
|
||||||
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
|
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||||
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
|
||||||
|
|
||||||
const { keto, kratosAdmin, menu, render } = deps;
|
const { keto, kratosAdmin, menu, render } = deps;
|
||||||
const user = ctx.user;
|
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||||
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
|
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
|
||||||
|
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||||
let form: URLSearchParams | undefined;
|
|
||||||
if (method === "POST") {
|
|
||||||
form = await readFormBody(ctx.req);
|
|
||||||
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
|
||||||
throw new GuardError(403, "invalid CSRF token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderList = async (): Promise<RouteResult> => {
|
const renderList = async (): Promise<RouteResult> => {
|
||||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
||||||
@@ -322,12 +309,13 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
|||||||
const { options } = await memberCandidates(keto, kratosAdmin);
|
const { options } = await memberCandidates(keto, kratosAdmin);
|
||||||
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
|
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, user, ...extra }) }) };
|
||||||
};
|
};
|
||||||
const renderDetail = async (name: string): Promise<RouteResult> => {
|
const renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||||
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
const members = tuples.map((t) => memberView(t, emailById));
|
||||||
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
|
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
|
||||||
return { html: await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user }) }) };
|
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, role: { name }, user, ...(error ? { error } : {}) }) });
|
||||||
|
return error ? { html, status: 400 } : { html };
|
||||||
};
|
};
|
||||||
|
|
||||||
// /admin/roles — list (GET) · create (POST)
|
// /admin/roles — list (GET) · create (POST)
|
||||||
@@ -362,12 +350,26 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
|
|||||||
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups
|
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups
|
||||||
return { redirect: base };
|
return { redirect: base };
|
||||||
}
|
}
|
||||||
|
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||||
|
// Self-protection: deleting the admin role removes everyone's admin — refuse it outright.
|
||||||
|
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
||||||
|
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||||
|
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken,
|
||||||
|
current: "roles", menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role", user,
|
||||||
|
}) }) };
|
||||||
|
}
|
||||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||||
|
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
|
||||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
|
||||||
return { redirect: ADMIN_ROLES_BASE };
|
return { redirect: ADMIN_ROLES_BASE };
|
||||||
}
|
}
|
||||||
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
|
||||||
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim());
|
const member = (form!.get("member") ?? "").trim();
|
||||||
|
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
|
||||||
|
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
|
||||||
|
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
|
||||||
|
const tuple = roleMemberTuple(name, member);
|
||||||
if (tuple) await keto.deleteTuple(tuple);
|
if (tuple) await keto.deleteTuple(tuple);
|
||||||
return { redirect: base };
|
return { redirect: base };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
// imperative shell app.ts dispatches to — it gates (admin only), CSRF-guards every mutation, and
|
// imperative shell app.ts dispatches to — it gates (admin only), CSRF-guards every mutation, and
|
||||||
// maps each action to a RouteResult (render a page, or redirect after a write — PRG).
|
// maps each action to a RouteResult (render a page, or redirect after a write — PRG).
|
||||||
|
|
||||||
import { ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav } from "./admin-nav.ts";
|
import { ADMIN_USERS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
|
||||||
import type { RequestContext, User } from "./context.ts";
|
import type { RequestContext, User } from "./context.ts";
|
||||||
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
|
|
||||||
import { GuardError } from "./guards.ts";
|
|
||||||
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
|
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
|
||||||
import { KratosError } from "./kratos-public.ts";
|
import { KratosError } from "./kratos-public.ts";
|
||||||
import { parseListQuery } from "./list-query.ts";
|
import { parseListQuery } from "./list-query.ts";
|
||||||
@@ -306,23 +303,11 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
|
|||||||
const path = ctx.url.pathname;
|
const path = ctx.url.pathname;
|
||||||
if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null;
|
if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null;
|
||||||
|
|
||||||
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
|
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||||
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
|
||||||
|
|
||||||
const { kratosAdmin, menu, render } = deps;
|
const { kratosAdmin, menu, render } = deps;
|
||||||
const user = ctx.user;
|
|
||||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||||
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
|
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
|
||||||
|
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||||
// Every mutation is a first-party form → CSRF-guard it (the host doesn't gate plugin routes,
|
|
||||||
// but it owns these). Reads the body once; the action handlers reuse the parsed form.
|
|
||||||
let form: URLSearchParams | undefined;
|
|
||||||
if (method === "POST") {
|
|
||||||
form = await readFormBody(ctx.req);
|
|
||||||
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: deps.csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
|
||||||
throw new GuardError(403, "invalid CSRF token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderList = async (): Promise<RouteResult> => {
|
const renderList = async (): Promise<RouteResult> => {
|
||||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||||
@@ -370,12 +355,25 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seg.length === 2 && method === "POST") {
|
if (seg.length === 2) {
|
||||||
|
const isSelf = targetId === user.id; // self-protection: an admin must not lock themselves out
|
||||||
|
if (seg[1] === "delete" && method === "GET") {
|
||||||
|
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
||||||
|
const view = toUserView(identity);
|
||||||
|
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||||
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
|
||||||
|
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken,
|
||||||
|
current: "users", menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user", user,
|
||||||
|
}) }) };
|
||||||
|
}
|
||||||
|
if (method === "POST") {
|
||||||
if (seg[1] === "state") {
|
if (seg[1] === "state") {
|
||||||
|
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
|
||||||
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive"));
|
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive"));
|
||||||
return { redirect: back };
|
return { redirect: back };
|
||||||
}
|
}
|
||||||
if (seg[1] === "delete") {
|
if (seg[1] === "delete") {
|
||||||
|
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
||||||
await kratosAdmin.deleteIdentity(targetId);
|
await kratosAdmin.deleteIdentity(targetId);
|
||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
}
|
}
|
||||||
@@ -384,6 +382,7 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
|
|||||||
return renderForm({ identity, recovery });
|
return renderForm({ identity, recovery });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
|
|||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
||||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||||
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
const keto = stubKeto({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
||||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
||||||
|
|
||||||
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||||
@@ -408,7 +408,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||||
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
const keto = stubKeto({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
||||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string) => {
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string) => {
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -464,7 +464,7 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
|
|||||||
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
||||||
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
||||||
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
|
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
|
||||||
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" })];
|
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
|
||||||
let lastCreate: { traits?: unknown } | undefined;
|
let lastCreate: { traits?: unknown } | undefined;
|
||||||
const kratosAdmin = stubAdmin({
|
const kratosAdmin = stubAdmin({
|
||||||
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
|
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
|
||||||
@@ -528,11 +528,20 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.equal(rec.status, 200);
|
assert.equal(rec.status, 200);
|
||||||
assert.match(await rec.text(), /self-service\/recovery\?code=123456/);
|
assert.match(await rec.text(), /self-service\/recovery\?code=123456/);
|
||||||
|
|
||||||
// Delete: removes the identity, back to the list.
|
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
||||||
|
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
|
||||||
|
assert.match(confirm, /Cancel/);
|
||||||
|
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
|
||||||
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.ok(!store.some((x) => x.id === target.id));
|
assert.ok(!store.some((x) => x.id === target.id));
|
||||||
|
|
||||||
|
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
||||||
|
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
||||||
|
assert.ok(store.some((x) => x.id === "admin1"));
|
||||||
|
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
// Unknown id → 404.
|
// Unknown id → 404.
|
||||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
||||||
});
|
});
|
||||||
@@ -610,7 +619,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
// Delete the group: removes every member tuple, back to the list.
|
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
|
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/groups");
|
assert.equal(del.headers.get("location"), "/admin/groups");
|
||||||
@@ -706,12 +716,20 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// Delete the role: removes every member tuple, back to the list.
|
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
|
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/roles");
|
assert.equal(del.headers.get("location"), "/admin/roles");
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
||||||
|
|
||||||
|
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||||
|
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
|
||||||
|
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||||
|
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
||||||
|
|
||||||
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
||||||
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
|
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
|
||||||
|
|||||||
@@ -40,18 +40,29 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("readRoles reads direct Role memberships from Keto — paged, de-duped, sorted", async () => {
|
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
|
||||||
const calls: unknown[] = [];
|
const listQ: unknown[] = [];
|
||||||
|
const checked: string[] = [];
|
||||||
|
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
|
||||||
const keto = ketoStub({
|
const keto = ketoStub({
|
||||||
|
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
|
||||||
|
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
||||||
listRelations: async (q) => {
|
listRelations: async (q) => {
|
||||||
calls.push(q);
|
listQ.push(q);
|
||||||
if (!q?.pageToken) return { nextPageToken: "p2", tuples: [roleTuple("editor"), roleTuple("admin")] };
|
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
|
||||||
return { nextPageToken: null, tuples: [roleTuple("admin")] }; // duplicate across pages
|
return { nextPageToken: "p2", tuples: [
|
||||||
|
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||||
|
role("admin", { subject_id: `user:${ID}` }),
|
||||||
|
role("viewer", { subject_id: "user:stranger" }),
|
||||||
|
] };
|
||||||
},
|
},
|
||||||
|
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||||
|
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
||||||
});
|
});
|
||||||
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
||||||
assert.deepEqual(calls[0], { namespace: "Role", relation: "members", subject_id: `user:${ID}` });
|
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
|
||||||
assert.equal((calls[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
||||||
|
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||||
@@ -65,7 +76,7 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
||||||
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
||||||
|
|
||||||
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
||||||
@@ -90,7 +101,7 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
|
|||||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||||
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
||||||
|
|
||||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
||||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||||
|
|||||||
18
src/login.ts
18
src/login.ts
@@ -36,19 +36,23 @@ export interface CompletedLogin {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The coarse roles Keto grants a subject directly: `Role:<name>#members@user:<id>`. Returns
|
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
|
||||||
// the de-duped, sorted role names (the tuple `object`). One logical read, paged defensively.
|
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
||||||
// Group→role inheritance lands with the Groups screen (§5); MVP grants are direct.
|
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
||||||
|
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||||
|
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
||||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||||
const subject_id = `user:${identityId}`;
|
const subject_id = `user:${identityId}`;
|
||||||
const roles = new Set<string>();
|
const names = new Set<string>();
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
do {
|
do {
|
||||||
const page = await keto.listRelations({ namespace: "Role", relation: "members", subject_id, ...(pageToken ? { pageToken } : {}) });
|
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
|
||||||
for (const t of page.tuples) roles.add(t.object);
|
for (const t of page.tuples) names.add(t.object);
|
||||||
pageToken = page.nextPageToken ?? undefined;
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
} while (pageToken);
|
} while (pageToken);
|
||||||
return [...roles].sort();
|
const roles = [...names];
|
||||||
|
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
|
||||||
|
return roles.filter((_, i) => held[i]).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { buildShellContext, shellUser } from "./shell-context.ts";
|
|||||||
|
|
||||||
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
||||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||||
// Real user: name = email, initials = first two letters of the local part, upper-cased.
|
// Real user: name = email local part, email kept, initials = first two letters of the local part.
|
||||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "", initials: "AD", name: "ada@example.com" });
|
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", roles: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
|
test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
|
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
|
||||||
// shell user is the *real* signed-in identity (§4) — no hardcoded demo profile — and branding is
|
// shell user is the *real* signed-in identity (§4) — no hardcoded demo profile — and branding is
|
||||||
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
|
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
|
||||||
// the profile shows the email with initials derived from its local part; anonymous ⇒ "Guest".
|
// the profile shows the email's local part as the name with the full email beneath, initials from
|
||||||
|
// the local part; anonymous ⇒ "Guest".
|
||||||
|
|
||||||
import type { User } from "./context.ts";
|
import type { User } from "./context.ts";
|
||||||
import { type MenuConfig } from "./menu-config.ts";
|
import { type MenuConfig } from "./menu-config.ts";
|
||||||
@@ -25,7 +26,7 @@ export interface ShellModel {
|
|||||||
export function shellUser(user: User | null | undefined): ShellUser {
|
export function shellUser(user: User | null | undefined): ShellUser {
|
||||||
if (!user) return { email: "", initials: "G", name: "Guest" };
|
if (!user) return { email: "", initials: "G", name: "Guest" };
|
||||||
const local = user.email.split("@")[0] || user.email;
|
const local = user.email.split("@")[0] || user.email;
|
||||||
return { email: "", initials: (local.slice(0, 2) || "U").toUpperCase(), name: user.email };
|
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildShellContext(opts: {
|
export function buildShellContext(opts: {
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -97,7 +97,7 @@ everything via Docker.
|
|||||||
- [x] Groups: Keto subject sets — list/create/delete + membership management. → `src/admin-groups.ts`: pure view-model + Keto-tuple builders (`groupsFromTuples`, `parseSubject`/`memberTuple`, `memberView`, `isValidGroupName`, `buildGroups{List,Detail,Form}Model`) + `handleAdminGroups` (the imperative shell app.ts dispatches `/admin/groups*` to). A group is a Keto subject set `Group:<name>#members`; a member is a user (`subject_id=user:<uuid>`) or a nested group (`subject_set=Group:<other>#members`). Keto has no create-object, so a group exists while it has ≥1 member: **create** writes the first-member tuple (requires a member, rejects a duplicate/invalid name), **delete** removes every member tuple (one delete-by-partial-filter), **add/remove member** write/delete one tuple. Routes: `GET /admin/groups` (list — search/sort/paginate over one Keto namespace scan), `GET|POST /admin/groups/new`+`/` (create), `GET /admin/groups/:name` (membership detail — members by email, add a user/nested group, remove, delete-group), `POST …/members` · `…/members/delete` · `…/delete`. Writes go **only to Keto** (README "stateless"); Kratos is read only to label the member pickers by email. Gated **admin-only** (anon→/login, non-admin→403) and every mutation **CSRF-guarded**, same as Users; reuses the §1 building blocks around the shell. Extracted `src/admin-nav.ts` (shared Dashboard·Users·Groups sidebar nav) so the two screens can't drift; added a generic `rowHeader` `<th scope=row>` data-table cell (the group name links to its detail). Tests-first: `admin-groups.test.ts` (builder/validation/subject matrix), `app.test.ts` HTTP integration (gate/list/create/dup-reject/detail/add/remove/delete + CSRF + invalid-name & malformed-`%`→404), `data-table.test.ts` (rowHeader). Stability-reviewer (treated as a local PR): APPROVE; fixed its nits — symmetric subject validation (UUID-check the user id), "already exists" feedback on create, malformed-`%`→404 (`safeDecode`). typecheck + 237 units green. Boot-verified the core Keto interactions live (namespace listing, group-collapse counts, delete-group-by-filter, single-member removal). The full-stack groups-CRUD Playwright E2E is §8's scope (line 123), as with the Users screen. Roles/permissions + global-menu wiring are the next §5 items.
|
- [x] Groups: Keto subject sets — list/create/delete + membership management. → `src/admin-groups.ts`: pure view-model + Keto-tuple builders (`groupsFromTuples`, `parseSubject`/`memberTuple`, `memberView`, `isValidGroupName`, `buildGroups{List,Detail,Form}Model`) + `handleAdminGroups` (the imperative shell app.ts dispatches `/admin/groups*` to). A group is a Keto subject set `Group:<name>#members`; a member is a user (`subject_id=user:<uuid>`) or a nested group (`subject_set=Group:<other>#members`). Keto has no create-object, so a group exists while it has ≥1 member: **create** writes the first-member tuple (requires a member, rejects a duplicate/invalid name), **delete** removes every member tuple (one delete-by-partial-filter), **add/remove member** write/delete one tuple. Routes: `GET /admin/groups` (list — search/sort/paginate over one Keto namespace scan), `GET|POST /admin/groups/new`+`/` (create), `GET /admin/groups/:name` (membership detail — members by email, add a user/nested group, remove, delete-group), `POST …/members` · `…/members/delete` · `…/delete`. Writes go **only to Keto** (README "stateless"); Kratos is read only to label the member pickers by email. Gated **admin-only** (anon→/login, non-admin→403) and every mutation **CSRF-guarded**, same as Users; reuses the §1 building blocks around the shell. Extracted `src/admin-nav.ts` (shared Dashboard·Users·Groups sidebar nav) so the two screens can't drift; added a generic `rowHeader` `<th scope=row>` data-table cell (the group name links to its detail). Tests-first: `admin-groups.test.ts` (builder/validation/subject matrix), `app.test.ts` HTTP integration (gate/list/create/dup-reject/detail/add/remove/delete + CSRF + invalid-name & malformed-`%`→404), `data-table.test.ts` (rowHeader). Stability-reviewer (treated as a local PR): APPROVE; fixed its nits — symmetric subject validation (UUID-check the user id), "already exists" feedback on create, malformed-`%`→404 (`safeDecode`). typecheck + 237 units green. Boot-verified the core Keto interactions live (namespace listing, group-collapse counts, delete-group-by-filter, single-member removal). The full-stack groups-CRUD Playwright E2E is §8's scope (line 123), as with the Users screen. Roles/permissions + global-menu wiring are the next §5 items.
|
||||||
- [x] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. → `src/admin-roles.ts`: a role is a Keto subject set `Role:<name>#members` (OPL: members are users or groups, resolved transitively — the source of truth the §4 login projects into the JWT). Same shape as the Groups screen, so the pure membership helpers are reused from `admin-groups.ts` (`parseSubject`, `isValidGroupName`, `memberView`, `groupsFromTuples`, and now-exported `pagedTuples`/`memberCandidates`/`safeDecode`). Routes (`handleAdminRoles`, dispatched by app.ts): `GET /admin/roles` (list — search/sort/paginate over one Keto scan), `GET|POST /admin/roles/new`+`/` (create = assign first member; rejects invalid/duplicate name), `GET /admin/roles/:name` (detail), `POST …/members` (assign a user/group) · `…/members/delete` (revoke) · `…/delete` (remove all member tuples). The one role-specific piece is **effective access**: `keto.expand(Role:<name>#members, {maxDepth:50})` → `expandToEffectiveUsers` flattens the tree to the distinct users who hold the role directly *or transitively via a group* (the coarse JWT projection stays direct-only per the README's one-read-per-login design; this view is where group→role inheritance is surfaced). Writes go **only to Keto**; Kratos is read only to label members. Gated admin-only (anon→/login, non-admin→403) + CSRF-guarded, like Users/Groups. Added a "Roles" entry (`i-shield`) to the shared `admin-nav.ts`; new `.plain-list` CSS rule. Tests-first: `admin-roles.test.ts` (builders + expand-flatten matrix) + `app.test.ts` HTTP integration (gate/list/create/dup-reject/assign user&group/effective-access-via-expand/revoke/delete + CSRF + malformed-name→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its expand-depth nit (explicit `maxDepth`). 237→243 units + typecheck green. **Live boot-verify caught a real bug the tests missed:** Keto v26.2.0's expand nests the subject under `tuple` (`{type:"leaf",tuple:{subject_id}}`), not at the node top-level as the §4 `ExpandTree` type had guessed — fixed the type + walker + the (wrongly-shaped) fixtures, then re-verified live that a user reachable only through a group surfaces in effective access; torn down. Global-menu wiring is the next §5 item.
|
- [x] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. → `src/admin-roles.ts`: a role is a Keto subject set `Role:<name>#members` (OPL: members are users or groups, resolved transitively — the source of truth the §4 login projects into the JWT). Same shape as the Groups screen, so the pure membership helpers are reused from `admin-groups.ts` (`parseSubject`, `isValidGroupName`, `memberView`, `groupsFromTuples`, and now-exported `pagedTuples`/`memberCandidates`/`safeDecode`). Routes (`handleAdminRoles`, dispatched by app.ts): `GET /admin/roles` (list — search/sort/paginate over one Keto scan), `GET|POST /admin/roles/new`+`/` (create = assign first member; rejects invalid/duplicate name), `GET /admin/roles/:name` (detail), `POST …/members` (assign a user/group) · `…/members/delete` (revoke) · `…/delete` (remove all member tuples). The one role-specific piece is **effective access**: `keto.expand(Role:<name>#members, {maxDepth:50})` → `expandToEffectiveUsers` flattens the tree to the distinct users who hold the role directly *or transitively via a group* (the coarse JWT projection stays direct-only per the README's one-read-per-login design; this view is where group→role inheritance is surfaced). Writes go **only to Keto**; Kratos is read only to label members. Gated admin-only (anon→/login, non-admin→403) + CSRF-guarded, like Users/Groups. Added a "Roles" entry (`i-shield`) to the shared `admin-nav.ts`; new `.plain-list` CSS rule. Tests-first: `admin-roles.test.ts` (builders + expand-flatten matrix) + `app.test.ts` HTTP integration (gate/list/create/dup-reject/assign user&group/effective-access-via-expand/revoke/delete + CSRF + malformed-name→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its expand-depth nit (explicit `maxDepth`). 237→243 units + typecheck green. **Live boot-verify caught a real bug the tests missed:** Keto v26.2.0's expand nests the subject under `tuple` (`{type:"leaf",tuple:{subject_id}}`), not at the node top-level as the §4 `ExpandTree` type had guessed — fixed the type + walker + the (wrongly-shaped) fixtures, then re-verified live that a user reachable only through a group surfaces in effective access; torn down. Global-menu wiring is the next §5 item.
|
||||||
- [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green.
|
- [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green.
|
||||||
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles`→`roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101–102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security).
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
||||||
|
|
||||||
|
|||||||
17
views/admin/confirm.ejs
Normal file
17
views/admin/confirm.ejs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<%#
|
||||||
|
Admin destructive-action confirmation page (todo §5): the confirm body in the app shell. Model
|
||||||
|
from buildConfirmModel: { message, confirm:{action,label}, cancelHref, nav, shell }.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||||
|
const body = include("partials/confirm-body", { cancelHref: model.cancelHref, confirm: model.confirm, csrfToken: model.shell.csrfToken, message: model.message });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: model.shell.brand,
|
||||||
|
breadcrumbs: model.shell.breadcrumbs,
|
||||||
|
csrfToken: model.shell.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: model.shell.theme,
|
||||||
|
title: model.shell.title,
|
||||||
|
user: model.shell.user,
|
||||||
|
}) %>
|
||||||
17
views/partials/confirm-body.ejs
Normal file
17
views/partials/confirm-body.ejs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<%#
|
||||||
|
Destructive-action confirm body (todo §5), captured into the shell content slot. Zero-JS: the
|
||||||
|
delete is a deliberate second step (a POST form), with a cancel link back. Config:
|
||||||
|
message string
|
||||||
|
confirm { action, label } the danger POST endpoint + button label
|
||||||
|
cancelHref string
|
||||||
|
csrfToken
|
||||||
|
%>
|
||||||
|
<div class="form-page">
|
||||||
|
<section class="form-card admin-actions" aria-label="Confirm action">
|
||||||
|
<p><%= locals.message %></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
|
||||||
|
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -37,6 +37,6 @@
|
|||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Group actions">
|
<section class="form-card admin-actions" aria-label="Group actions">
|
||||||
<form method="post" action="<%= del.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</button></form>
|
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,6 @@
|
|||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Role actions">
|
<section class="form-card admin-actions" aria-label="Role actions">
|
||||||
<form method="post" action="<%= del.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</button></form>
|
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<section class="form-card admin-actions" aria-label="Account actions">
|
<section class="form-card admin-actions" aria-label="Account actions">
|
||||||
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Send recovery link</button></form>
|
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Send recovery link</button></form>
|
||||||
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||||
<form method="post" action="<%= edit.deleteAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</button></form>
|
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
|
||||||
</section>
|
</section>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user