List grants no plugin declares with a Remove, and save a permission change as one Keto transaction
CI / full-gate (push) Successful in 3m26s
CI / full-gate (push) Successful in 3m26s
This commit is contained in:
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
||||
import type { KetoClient } from "@plainpages/plugin-api";
|
||||
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
|
||||
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, removeUndeclaredGrant, userSubject } from "./admin-grants.ts";
|
||||
|
||||
const declared: PermissionDecl[] = [
|
||||
{ description: "View users", name: "users:read" },
|
||||
@@ -106,24 +106,55 @@ test("isStale refuses a missing or old token, but only for a save that would cha
|
||||
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
|
||||
});
|
||||
|
||||
test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
|
||||
const log: string[] = [];
|
||||
const keto = (failOn?: string): KetoClient => ({
|
||||
test("applyGrants sends the whole change as one Keto transaction, and a failure applies none of it", async () => {
|
||||
const calls: { delete: string[]; insert: string[] }[] = [];
|
||||
const keto = (fail = false): KetoClient => ({
|
||||
check: async () => false,
|
||||
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
|
||||
deleteTuple: async () => { throw new Error("unused"); },
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
|
||||
transactTuples: async (c) => {
|
||||
if (fail) throw new Error("boom");
|
||||
calls.push({ delete: c.delete.map((tp) => tp.object), insert: c.insert.map((tp) => tp.object) });
|
||||
},
|
||||
writeTuple: async () => { throw new Error("unused"); },
|
||||
});
|
||||
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
|
||||
|
||||
const ok = await applyGrants(keto(), userSubject("u1"), diff);
|
||||
assert.deepEqual(ok, { applied: diff });
|
||||
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
|
||||
|
||||
log.length = 0;
|
||||
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
|
||||
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
|
||||
assert.ok(failed.error instanceof Error);
|
||||
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
|
||||
await applyGrants(keto(), userSubject("u1"), diff);
|
||||
assert.deepEqual(calls, [{ delete: ["c:read"], insert: ["a:read", "b:read"] }]);
|
||||
await assert.rejects(applyGrants(keto(true), userSubject("u1"), diff), /boom/);
|
||||
});
|
||||
|
||||
// grantDiff never touches a held name nobody declares, so the picker lists them apart and offers a
|
||||
// Remove for each. A reader is told what is held but offered no button.
|
||||
test("buildPermissionPicker lists direct grants no plugin declares, with a remove action only for a writer", () => {
|
||||
const direct = ["zed:read", "users:read", "old:write"];
|
||||
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct });
|
||||
assert.deepEqual(picker.undeclared?.names, ["old:write", "zed:read"]);
|
||||
assert.equal(picker.undeclared?.action, "/auth-admin/users/u1/permissions/remove");
|
||||
assert.ok(picker.undeclared?.remove);
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct, readOnly: true }).undeclared?.remove, undefined);
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).undeclared, undefined);
|
||||
assert.equal(picker.baseline, buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).baseline, "an undeclared grant doesn't make a form stale");
|
||||
});
|
||||
|
||||
test("removeUndeclaredGrant deletes one held, undeclared grant and refuses anything else", async () => {
|
||||
const subject = userSubject("u1");
|
||||
const tuples = [grantTuple("old:write", subject), grantTuple("users:read", subject), grantTuple("old:write", userSubject("u2"))];
|
||||
const deleted: string[] = [];
|
||||
const keto: KetoClient = {
|
||||
check: async () => false,
|
||||
deleteTuple: async (tp) => { deleted.push(`${tp.object} ${tp.subject_id}`); },
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => tp.subject_id === q.subject_id) }),
|
||||
transactTuples: async () => { throw new Error("unused"); },
|
||||
writeTuple: async () => { throw new Error("unused"); },
|
||||
};
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "old:write"), "old:write");
|
||||
assert.deepEqual(deleted, ["old:write user:u1"]);
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "users:read"), undefined, "a declared name is the picker's to change");
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "never:held"), undefined);
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, null), undefined);
|
||||
assert.deepEqual(deleted, ["old:write user:u1"]);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate }
|
||||
const PERMISSION_NS = "Permission";
|
||||
const GRANTED = "granted";
|
||||
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
||||
export const REMOVE_FIELD = "remove"; // the hidden field naming the undeclared grant a Remove form drops
|
||||
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
|
||||
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
|
||||
|
||||
@@ -73,6 +74,18 @@ export interface PermissionPicker {
|
||||
pending: string | undefined;
|
||||
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
|
||||
submit: string;
|
||||
undeclared: UndeclaredGrants | undefined; // set when the subject holds a name no installed plugin declares
|
||||
}
|
||||
|
||||
// Direct grants of names nobody declares (their plugin was uninstalled, or the name was written to
|
||||
// Keto by hand). grantDiff never touches them, so they get their own list and a Remove per name.
|
||||
export interface UndeclaredGrants {
|
||||
action: string; // where each Remove posts
|
||||
field: string;
|
||||
hint: string;
|
||||
legend: string;
|
||||
names: string[];
|
||||
remove: string | undefined; // the button label; unset for a reader, who is shown the names only
|
||||
}
|
||||
|
||||
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
|
||||
@@ -89,6 +102,7 @@ export function buildPermissionPicker(opts: {
|
||||
}): PermissionPicker {
|
||||
const t = opts.t ?? ((k: string) => k);
|
||||
const directSet = new Set(opts.direct);
|
||||
const undeclared = undeclaredHeld(opts.declared, opts.direct);
|
||||
const effectiveSet = new Set(opts.effective ?? opts.direct);
|
||||
const choices = opts.declared.map((decl) => ({
|
||||
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
|
||||
@@ -110,9 +124,22 @@ export function buildPermissionPicker(opts: {
|
||||
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
|
||||
readOnly: opts.readOnly === true,
|
||||
submit: t("admin.grants.save"),
|
||||
undeclared: undeclared.length === 0 ? undefined : {
|
||||
action: `${opts.action}/remove`,
|
||||
field: REMOVE_FIELD,
|
||||
hint: t("admin.grants.undeclaredHint"),
|
||||
legend: t("admin.grants.undeclaredLegend"),
|
||||
names: undeclared,
|
||||
remove: opts.readOnly === true ? undefined : t("admin.grants.remove"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const undeclaredHeld = (declared: readonly PermissionDecl[], held: readonly string[]): string[] => {
|
||||
const offered = new Set(declared.map((d) => d.name));
|
||||
return held.filter((name) => !offered.has(name)).sort();
|
||||
};
|
||||
|
||||
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
||||
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
||||
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
||||
@@ -126,6 +153,15 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
|
||||
};
|
||||
}
|
||||
|
||||
// Deletes one held grant of a name no plugin declares; returns the name, or undefined when there was
|
||||
// nothing to do (a declared name, which the picker owns, or one already gone, as after a double click).
|
||||
export async function removeUndeclaredGrant(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[], name: string | null): Promise<string | undefined> {
|
||||
if (name === null || declared.some((d) => d.name === name)) return undefined;
|
||||
if (!(await heldPermissions(keto, subject)).includes(name)) return undefined;
|
||||
await keto.deleteTuple(grantTuple(name, subject));
|
||||
return name;
|
||||
}
|
||||
|
||||
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
|
||||
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
|
||||
// stale page, not a secret, since anyone who can read the page can compute it.
|
||||
@@ -142,16 +178,6 @@ export const isStale = (form: { get(name: string): string | null }, declared: re
|
||||
|
||||
export interface GrantChange { grant: string[]; revoke: string[] }
|
||||
|
||||
// KetoClient writes one tuple per call, so the writes and deletes run one by one and stop at the first
|
||||
// failure. What was applied by then is returned beside the error, so the caller can still revoke live
|
||||
// tokens and tell the admin the save is only partly done.
|
||||
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<{ applied: GrantChange; error?: unknown }> {
|
||||
const applied: GrantChange = { grant: [], revoke: [] };
|
||||
try {
|
||||
for (const name of diff.grant) { await keto.writeTuple(grantTuple(name, subject)); applied.grant.push(name); }
|
||||
for (const name of diff.revoke) { await keto.deleteTuple(grantTuple(name, subject)); applied.revoke.push(name); }
|
||||
} catch (error) {
|
||||
return { applied, error };
|
||||
}
|
||||
return { applied };
|
||||
}
|
||||
// One Keto transaction, so a failure leaves the subject's grants exactly as they were.
|
||||
export const applyGrants = (keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<void> =>
|
||||
keto.transactTuples({ delete: diff.revoke.map((name) => grantTuple(name, subject)), insert: diff.grant.map((name) => grantTuple(name, subject)) });
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// each returning a RouteResult.
|
||||
|
||||
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
const GROUP_NS = "Group";
|
||||
@@ -395,17 +395,22 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
|
||||
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
|
||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
|
||||
const { applied, error } = await applyGrants(keto, subject, diff);
|
||||
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
||||
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
|
||||
}
|
||||
if (error) {
|
||||
ctx.log.error("admin: group permissions only partly saved", { actor: user.id, error: String(error), group: name });
|
||||
return partialSave(ctx, async () => ({ ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.partial") })), status: 502 }));
|
||||
await applyGrants(keto, subject, diff);
|
||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
|
||||
}
|
||||
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /auth-admin/groups/:name/permissions/remove — drop one grant no installed plugin declares.
|
||||
// No self-lockout guard: this screen's own gates are declared, so they are never what is removed here.
|
||||
export const groupsPermissionsRemove = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const removed = await removeUndeclaredGrant(keto, groupSubject(name), ctx.declaredPermissions, form.get(REMOVE_FIELD));
|
||||
if (removed) ctx.log.info("admin: undeclared group permission removed", { actor: user.id, group: name, removed });
|
||||
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// 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))!;
|
||||
|
||||
@@ -85,16 +85,6 @@ export function unavailable(ctx: RequestContext, what: string): RouteResult {
|
||||
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
||||
}
|
||||
|
||||
// The page a partial permission save reports on reads Keto again. If that read fails too, the report
|
||||
// stands alone on the notice page, so the admin still hears that part of the save landed.
|
||||
export async function partialSave(ctx: RequestContext, render: () => Promise<RouteResult>): Promise<RouteResult> {
|
||||
try {
|
||||
return await render();
|
||||
} catch {
|
||||
return { data: { chrome: ctx.chrome, message: ctx.t("admin.grants.partial"), title: ctx.t("admin.unavailable.title") }, status: 502, view: "notice" };
|
||||
}
|
||||
}
|
||||
|
||||
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
||||
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
||||
export function buildConfirmModel(opts: {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// a shared `withUser` gate.
|
||||
|
||||
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
|
||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -364,14 +364,23 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
|
||||
}
|
||||
const { applied, error } = await applyGrants(keto, subject, diff);
|
||||
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
||||
await applyGrants(keto, subject, diff);
|
||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
|
||||
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
|
||||
}
|
||||
if (error) {
|
||||
ctx.log.error("admin: user permissions only partly saved", { actor: user.id, error: String(error), target: id });
|
||||
return partialSave(ctx, () => refuse(ctx.t("admin.grants.partial"), 502));
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /auth-admin/users/:id/permissions/remove — drop one direct grant no installed plugin declares.
|
||||
// No self-lockout guard: this screen's own gates are declared, so they are never what is removed here.
|
||||
export const usersPermissionsRemove = withTarget(async ({ ctx, keto, revoke, user }, _identity, id) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||
const removed = await removeUndeclaredGrant(keto, userSubject(id), ctx.declaredPermissions, form.get(REMOVE_FIELD));
|
||||
if (removed) {
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: undeclared user permission removed", { actor: user.id, removed, target: id });
|
||||
}
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
||||
});
|
||||
|
||||
@@ -53,12 +53,14 @@ const messages = {
|
||||
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
||||
"admin.grants.legend": "Permissions",
|
||||
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||
"admin.grants.partial": "Saving stopped part-way because the permission service failed, so only some changes were applied. Check the permissions and make any remaining changes again.",
|
||||
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||
"admin.grants.remove": "Remove",
|
||||
"admin.grants.save": "Save permissions",
|
||||
"admin.grants.saved": "Permissions saved.",
|
||||
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
||||
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
|
||||
"admin.grants.undeclaredHint": "Held here, but no installed plugin declares them, so the list above can't change them. Installing a plugin that gates on one would activate it again. Remove any you don't recognise.",
|
||||
"admin.grants.undeclaredLegend": "Permissions no installed plugin declares",
|
||||
|
||||
"admin.groups.actions": "Group actions",
|
||||
"admin.groups.addMember": "Add a member",
|
||||
|
||||
@@ -53,12 +53,14 @@ const messages: AdminMessages = {
|
||||
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
||||
"admin.grants.legend": "Behörigheter",
|
||||
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||
"admin.grants.partial": "Sparandet avbröts halvvägs eftersom behörighetstjänsten gav fel, så bara en del av ändringarna genomfördes. Kontrollera behörigheterna och gör de återstående ändringarna igen.",
|
||||
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||
"admin.grants.remove": "Ta bort",
|
||||
"admin.grants.save": "Spara behörigheter",
|
||||
"admin.grants.saved": "Behörigheterna är sparade.",
|
||||
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
||||
"admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
|
||||
"admin.grants.undeclaredHint": "Innehas här, men ingen installerad plugin deklarerar dem, så listan ovan kan inte ändra dem. Installerar du en plugin som spärrar på någon av dem blir den aktiv igen. Ta bort dem du inte känner igen.",
|
||||
"admin.grants.undeclaredLegend": "Behörigheter som ingen installerad plugin deklarerar",
|
||||
|
||||
"admin.groups.actions": "Gruppåtgärder",
|
||||
"admin.groups.addMember": "Lägg till en medlem",
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsPermissionsRemove, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersPermissionsRemove, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||
|
||||
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
||||
@@ -54,6 +54,7 @@ export default definePlugin({
|
||||
users("POST", "/users/:id/delete", usersDelete),
|
||||
users("POST", "/users/:id/recovery", usersRecovery),
|
||||
users("POST", "/users/:id/permissions", usersPermissions),
|
||||
users("POST", "/users/:id/permissions/remove", usersPermissionsRemove),
|
||||
// Groups
|
||||
groups("GET", "/groups", groupsList),
|
||||
groups("POST", "/groups", groupsCreate),
|
||||
@@ -64,6 +65,7 @@ export default definePlugin({
|
||||
groups("POST", "/groups/:name/delete", groupsDelete),
|
||||
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
||||
groups("POST", "/groups/:name/permissions/remove", groupsPermissionsRemove),
|
||||
// OAuth2 clients
|
||||
clients("GET", "/clients", clientsList),
|
||||
clients("POST", "/clients", clientsCreate),
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
||||
into an accidental revoke, because grantDiff compares against the direct grants only.
|
||||
|
||||
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
|
||||
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit, undeclared }).
|
||||
|
||||
`undeclared` lists direct grants of names no plugin declares, outside the form above because grantDiff
|
||||
never revokes one. Each has its own Remove form, and a reader gets the names without the button.
|
||||
%>
|
||||
<section class="form-card" aria-labelledby="permissions-h">
|
||||
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||
@@ -50,5 +53,25 @@
|
||||
<% if (permissions.pending) { -%>
|
||||
<p class="cell-muted"><%= permissions.pending %></p>
|
||||
<% } -%>
|
||||
<% } -%>
|
||||
<% if (permissions.undeclared) { const u = permissions.undeclared; -%>
|
||||
<h3 class="card-title" id="undeclared-h"><%= u.legend %></h3>
|
||||
<p class="cell-muted"><%= u.hint %></p>
|
||||
<ul class="plain-list" aria-labelledby="undeclared-h">
|
||||
<% u.names.forEach((name) => { -%>
|
||||
<li>
|
||||
<% if (u.remove) { -%>
|
||||
<form method="post" action="<%= localeHref(u.action + '#permissions-h') %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<input type="hidden" name="<%= u.field %>" value="<%= name %>">
|
||||
<span class="cell-muted"><%= name %></span>
|
||||
<button class="btn btn-danger" type="submit"><%= u.remove %><span class="sr-only"> <%= name %></span></button>
|
||||
</form>
|
||||
<% } else { -%>
|
||||
<span class="cell-muted"><%= name %></span>
|
||||
<% } -%>
|
||||
</li>
|
||||
<% }) -%>
|
||||
</ul>
|
||||
<% } -%>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user