Rename the admin example plugin to auth-admin, and let config/menu.ts hide or rename a profile-menu entry
CI / full-gate (push) Successful in 3m32s
CI / full-gate (push) Successful in 3m32s
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
# Admin — the system-administration plugin
|
||||
# auth-admin — the plugin that administers users, groups, permissions and OAuth2 clients
|
||||
|
||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
||||
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
||||
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
|
||||
(it keeps the id and mount path `auth-admin`, so the screens live at `/auth-admin/*`) and restart:
|
||||
|
||||
```bash
|
||||
cp -r examples/plugins/admin plugins/admin
|
||||
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
+5
-5
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
|
||||
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
||||
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
||||
|
||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
||||
const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "OAuth2 clients");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "app-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
||||
assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00");
|
||||
|
||||
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
|
||||
const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
|
||||
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.title, "Register client");
|
||||
assert.equal(m.form.action, "/admin/clients");
|
||||
assert.equal(m.form.action, "/auth-admin/clients");
|
||||
assert.equal(m.form.submitLabel, "Register client");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
|
||||
|
||||
const plain = buildClientDetailModel({ client });
|
||||
assert.equal(plain.title, "Acme");
|
||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
||||
assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete");
|
||||
assert.equal(plain.created, false);
|
||||
assert.equal(plain.secret, undefined);
|
||||
|
||||
+6
-6
@@ -257,13 +257,13 @@ const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionNam
|
||||
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
||||
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
|
||||
|
||||
// GET /admin/clients — the list.
|
||||
// GET /auth-admin/clients — the list.
|
||||
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
|
||||
});
|
||||
|
||||
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
||||
// POST /auth-admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
||||
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
|
||||
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||
const input = readClientInput((await guardedForm(ctx))!);
|
||||
@@ -280,13 +280,13 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||
});
|
||||
|
||||
// GET /admin/clients/new — the register form.
|
||||
// GET /auth-admin/clients/new — the register form.
|
||||
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
||||
|
||||
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||
// GET /auth-admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
||||
|
||||
// GET /admin/clients/:id/delete — the deliberate confirm step.
|
||||
// GET /auth-admin/clients/:id/delete — the deliberate confirm step.
|
||||
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||
const base = detailHref(id);
|
||||
const name = toClientView(client).name;
|
||||
@@ -298,7 +298,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||
}) }, view: "confirm" });
|
||||
}, "write");
|
||||
|
||||
// POST /admin/clients/:id/delete — perform it.
|
||||
// POST /auth-admin/clients/:id/delete — perform it.
|
||||
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
await hydra.deleteClient(id);
|
||||
+2
-2
@@ -36,8 +36,8 @@ test("grantDiff ignores anything the plugins don't declare, in both directions",
|
||||
});
|
||||
|
||||
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||
assert.equal(picker.action, "/admin/users/u1/permissions");
|
||||
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||
assert.equal(picker.action, "/auth-admin/users/u1/permissions");
|
||||
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
||||
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
||||
assert.equal(picker.choices[0]?.description, "View users");
|
||||
+8
-8
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
|
||||
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
||||
|
||||
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
||||
const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "Groups");
|
||||
// The group name is the row header, linking to its detail page.
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "team-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/groups/team-00");
|
||||
assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00");
|
||||
|
||||
// Search narrows + shows a pill.
|
||||
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
|
||||
const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// Sort by members descending puts the biggest group first.
|
||||
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
|
||||
const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" });
|
||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.title, "New group");
|
||||
assert.equal(m.form.action, "/admin/groups");
|
||||
assert.equal(m.form.action, "/auth-admin/groups");
|
||||
assert.equal(m.form.submitLabel, "Create group");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.name, "name");
|
||||
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
|
||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
||||
assert.equal(m.title, "eng");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
||||
assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/auth-admin/groups/eng/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
||||
assert.equal(m.delete.action, "/auth-admin/groups/eng/delete");
|
||||
});
|
||||
+9
-9
@@ -331,13 +331,13 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
|
||||
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
||||
};
|
||||
|
||||
// GET /admin/groups — the list.
|
||||
// GET /auth-admin/groups — the list.
|
||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
||||
});
|
||||
|
||||
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||
// POST /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||
export const groupsCreate = withGroups(async (deps) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
@@ -353,7 +353,7 @@ export const groupsCreate = withGroups(async (deps) => {
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/groups/new — the create form.
|
||||
// GET /auth-admin/groups/new — the create form.
|
||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||
|
||||
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
||||
@@ -379,11 +379,11 @@ async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: str
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
|
||||
}
|
||||
|
||||
// GET /admin/groups/:name — the detail + membership page.
|
||||
// GET /auth-admin/groups/:name — the detail + membership page.
|
||||
export const groupsDetail = withGroupName((deps, name) =>
|
||||
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
|
||||
|
||||
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||
// POST /auth-admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
||||
// the documented instant-revoke tradeoff for anything held through a group.
|
||||
export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||
@@ -406,7 +406,7 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||
@@ -414,7 +414,7 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
||||
// GET /auth-admin/groups/:name/delete — the deliberate confirm step.
|
||||
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||
const lost = await lostOnDelete(deps, name);
|
||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||
@@ -427,7 +427,7 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||
}) }, view: "confirm" };
|
||||
}, "write");
|
||||
|
||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||
// POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||
export const groupsDelete = withGroupName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
@@ -444,7 +444,7 @@ export const groupsDelete = withGroupName(async (deps, name) => {
|
||||
return { redirect: ADMIN_GROUPS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/groups/:name/members/delete — remove one member.
|
||||
// POST /auth-admin/groups/:name/members/delete — remove one member.
|
||||
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
+1
-1
@@ -68,7 +68,7 @@ function valueLabel(setting: SettingSummary, t: Translate): string {
|
||||
return setting.value ?? t("admin.pluginSettings.unset");
|
||||
}
|
||||
|
||||
// GET /admin/plugin-settings
|
||||
// GET /auth-admin/plugin-settings
|
||||
export const pluginSettingsList: RouteHandler = (ctx) => {
|
||||
requirePermission(ctx, "plugin-settings");
|
||||
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
||||
+3
-3
@@ -15,7 +15,7 @@ const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:rea
|
||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||
|
||||
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL("http://localhost/admin/users");
|
||||
const url = new URL("http://localhost/auth-admin/users");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
req.method = opts.method ?? "GET";
|
||||
return {
|
||||
@@ -36,7 +36,7 @@ test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own re
|
||||
assert.equal(ADMIN_NAV.permission, undefined);
|
||||
assert.equal(ADMIN_NAV.href, undefined);
|
||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||
@@ -64,7 +64,7 @@ test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
|
||||
// ---- auth gates ----
|
||||
|
||||
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fauth-admin%2Fusers"); // bounce remembers the page
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
||||
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
||||
+4
-4
@@ -9,10 +9,10 @@ import enUS from "./i18n/en-US.ts";
|
||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
||||
export const ADMIN_USERS_BASE = "/auth-admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/auth-admin/groups";
|
||||
export const ADMIN_CLIENTS_BASE = "/auth-admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings";
|
||||
|
||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||
+10
-10
@@ -38,42 +38,42 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
|
||||
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
||||
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
||||
|
||||
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
||||
const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "Users");
|
||||
|
||||
// Search narrows to one and shows a pill.
|
||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
||||
const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// Status filter keeps only inactive identities.
|
||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
|
||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" });
|
||||
assert.equal(inactive.pagination.summary.total, 15);
|
||||
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
||||
|
||||
// Sort by email descending reverses the order.
|
||||
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
||||
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
|
||||
const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" });
|
||||
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
||||
|
||||
// Edit link points at the per-identity route.
|
||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/auth-admin/users/${people[0]!.id}$`));
|
||||
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
||||
});
|
||||
|
||||
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
|
||||
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
|
||||
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
|
||||
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
|
||||
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
|
||||
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!;
|
||||
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-admin/users/u1"]);
|
||||
});
|
||||
|
||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.title, "New user");
|
||||
assert.equal(m.form.action, "/admin/users");
|
||||
assert.equal(m.form.action, "/auth-admin/users");
|
||||
assert.equal(m.form.submitLabel, "Create user");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
const names = m.form.fields.map((f) => f.name);
|
||||
@@ -85,7 +85,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
|
||||
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||
const m = buildUserFormModel({ identity: identity(3) });
|
||||
assert.equal(m.title, "Edit user");
|
||||
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
||||
assert.equal(m.form.action, `/auth-admin/users/${id(3)}`);
|
||||
assert.equal(m.form.submitLabel, "Save changes");
|
||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||
assert.equal(email.value, "user3@example.com");
|
||||
@@ -109,7 +109,7 @@ test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a
|
||||
for (const [opts, title, crumb] of cases) {
|
||||
const m = buildUserFormModel(opts);
|
||||
assert.equal(m.title, title);
|
||||
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
|
||||
assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]);
|
||||
}
|
||||
});
|
||||
|
||||
+10
-10
@@ -288,13 +288,13 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) =>
|
||||
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
||||
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
||||
|
||||
// GET /admin/users — the filtered/sorted/paged list.
|
||||
// GET /auth-admin/users — the filtered/sorted/paged list.
|
||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
||||
});
|
||||
|
||||
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||
// POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
const input = readUserInput((await guardedForm(ctx))!);
|
||||
try {
|
||||
@@ -307,10 +307,10 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
});
|
||||
|
||||
// GET /admin/users/new — the empty create form.
|
||||
// GET /auth-admin/users/new — the empty create form.
|
||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
||||
|
||||
// GET /admin/users/:id — the edit form, prefilled.
|
||||
// GET /auth-admin/users/:id — the edit form, prefilled.
|
||||
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
|
||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||
@@ -342,7 +342,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string,
|
||||
};
|
||||
}
|
||||
|
||||
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||
// POST /auth-admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
||||
// revokes their live tokens so it lands now rather than at the next re-mint.
|
||||
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
@@ -376,7 +376,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||
// POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin } = deps;
|
||||
const input = readUserInput((await guardedForm(ctx))!);
|
||||
@@ -390,7 +390,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
|
||||
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||
@@ -402,7 +402,7 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
});
|
||||
|
||||
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||
@@ -415,7 +415,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||
}) }, view: "confirm" });
|
||||
}, "write");
|
||||
|
||||
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
||||
@@ -425,7 +425,7 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||
// POST /auth-admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
@@ -1,5 +1,5 @@
|
||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
// this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start.
|
||||
//
|
||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
||||
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
@@ -41,7 +41,7 @@ export default definePlugin({
|
||||
],
|
||||
|
||||
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
|
||||
profileMenu: [{ href: MINE_PATH, icon: "i-cal", label: "scheduling.nav.mine", session: true }],
|
||||
profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
|
||||
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
|
||||
Reference in New Issue
Block a user