Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); a Remove confirms with its own notice, the hint no longer says to remove, the example README names the list
This commit is contained in:
@@ -42,6 +42,7 @@ Admin section entirely.
|
||||
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
||||
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
||||
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
|
||||
A grant of a name no installed plugin declares is listed apart on the same page, with a Remove.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -15,9 +15,14 @@ export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two form
|
||||
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
|
||||
export const REMOVED_NOTICE = "permission-removed"; // the `info-msg` a Remove redirects with; its own words, since the checkboxes were not saved
|
||||
|
||||
// Only that exact value counts, so a crafted link can't put words on the page.
|
||||
export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
|
||||
// Only these exact values count, so a crafted link can't put words on the page.
|
||||
export function grantsNotice(url: URL, t: Translate): string | undefined {
|
||||
const msg = url.searchParams.get("info-msg");
|
||||
if (msg === SAVED_NOTICE) return t("admin.grants.saved");
|
||||
return msg === REMOVED_NOTICE ? t("admin.grants.removed") : undefined;
|
||||
}
|
||||
|
||||
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||
|
||||
@@ -153,8 +158,9 @@ 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).
|
||||
// Deletes one held grant of a name no plugin declares and returns the name. Deletes nothing for a
|
||||
// declared name, which the picker owns (so a Remove can't take away this plugin's own gates and needs
|
||||
// no self-lockout guard), or for one not held, 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;
|
||||
@@ -178,6 +184,5 @@ export const isStale = (form: { get(name: string): string | null }, declared: re
|
||||
|
||||
export interface GrantChange { grant: string[]; revoke: string[] }
|
||||
|
||||
// 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,7 +7,7 @@
|
||||
// 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, REMOVE_FIELD, removeUndeclaredGrant, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, 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";
|
||||
|
||||
@@ -381,7 +381,7 @@ async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: str
|
||||
|
||||
// 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") } : {}));
|
||||
groupDetailPage(deps, name, { notice: grantsNotice(deps.ctx.url, deps.ctx.t) ?? "" }));
|
||||
|
||||
// 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 —
|
||||
@@ -403,12 +403,11 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||
});
|
||||
|
||||
// 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}` };
|
||||
return { redirect: `${detailHref(name)}?info-msg=${REMOVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 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, REMOVE_FIELD, removeUndeclaredGrant, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, 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
|
||||
@@ -312,7 +312,7 @@ export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx
|
||||
|
||||
// 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);
|
||||
const permissions = await userPermissionPicker(deps, id, undefined, grantsNotice(deps.ctx.url, deps.ctx.t));
|
||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||
});
|
||||
|
||||
@@ -373,7 +373,6 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
});
|
||||
|
||||
// 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"));
|
||||
@@ -382,7 +381,7 @@ export const usersPermissionsRemove = withTarget(async ({ ctx, keto, revoke, use
|
||||
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}` };
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${REMOVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||
|
||||
@@ -55,11 +55,12 @@ const messages = {
|
||||
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||
"admin.grants.remove": "Remove",
|
||||
"admin.grants.removed": "Permission removed.",
|
||||
"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.undeclaredHint": "These permissions are held here, but no installed plugin declares them, so the list above can't change them. A plugin may still check one without declaring it.",
|
||||
"admin.grants.undeclaredLegend": "Permissions no installed plugin declares",
|
||||
|
||||
"admin.groups.actions": "Group actions",
|
||||
|
||||
@@ -55,11 +55,12 @@ const messages: AdminMessages = {
|
||||
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||
"admin.grants.remove": "Ta bort",
|
||||
"admin.grants.removed": "Behörigheten är borttagen.",
|
||||
"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.undeclaredHint": "Behörigheterna innehas här, men ingen installerad plugin deklarerar dem, så listan ovan kan inte ändra dem. En plugin kan ändå kontrollera någon av dem utan att deklarera den.",
|
||||
"admin.grants.undeclaredLegend": "Behörigheter som ingen installerad plugin deklarerar",
|
||||
|
||||
"admin.groups.actions": "Gruppåtgärder",
|
||||
|
||||
Reference in New Issue
Block a user