web permissions2

This commit is contained in:
Priec
2026-08-11 14:33:24 +02:00
parent 5602140d05
commit 077d69d756
47 changed files with 2348 additions and 674 deletions

View File

@@ -17,7 +17,6 @@
{% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">Add rule</a>{% endif %}
{% if page.can_manage_permissions %}<a href="/admin/permissions">Permissions</a>{% endif %}
{% if page.can_export %}<a href="/admin/export">Export</a>{% endif %}
</div>
</section>

View File

@@ -1,107 +0,0 @@
{% extends "ui/base.html" %}
{% block title %}Roles and permissions{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Authorization</p>
<h1>Roles and permissions</h1>
<p>Manage data roles, their direct grants, inheritance, and user assignments.</p>
</div>
<div class="actions"><a href="/admin">← Admin panel</a></div>
</section>
{% if page.updated %}<p class="notice">Administration updated. The current session remains valid and all subsequent requests use the new state.</p>{% endif %}
{% if page.can_manage_roles %}
<section class="panel">
<h2>Data roles</h2>
<form method="get" action="/admin/permissions" class="form-grid">
<label>Role
<select name="role" onchange="this.form.submit()">
{% for role in page.editable_roles() %}
<option value="{{ role.name }}" {% if page.selected(role.name.as_str()) %}selected{% endif %}>{{ role.name }}{% if !role.parent.is_empty() %} → {{ role.parent }}{% endif %}</option>
{% endfor %}
</select>
</label>
</form>
<form hx-post="/admin/permissions/roles" hx-target="#permission-status" class="form-grid">
<label>New role<input name="name" required maxlength="50" placeholder="sales"></label>
<label>Inherits from
<select name="parent"><option value="">No parent</option>{% for role in page.editable_roles() %}<option value="{{ role.name }}">{{ role.name }}</option>{% endfor %}</select>
</label>
<button type="submit">Create role</button>
</form>
{% if page.selected_role_is_removable() %}
<form hx-post="/admin/permissions/roles/remove" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ page.selected_role }}">
<button type="submit" class="danger">Remove {{ page.selected_role }}</button>
</form>
{% endif %}
</section>
<section class="panel">
<h2>Grants for {{ page.selected_role }}</h2>
<p class="hint">Direct grants can be revoked here. “Inherited” permissions come from the roles parent.</p>
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>Object</th><th>Scope</th><th>Actions</th></tr></thead>
<tbody>
{% for object in page.grantable_objects %}
<tr>
<td><code>{{ object.object }}</code></td>
<td>{{ object.kind }}{% if !object.profile.is_empty() %} · {{ object.profile }}{% endif %}{% if !object.table.is_empty() %} / {{ object.table }}{% endif %}</td>
<td>
<div class="actions">
{% for action in object.allowed_actions %}
{% if page.direct(object.object.as_str(), action.as_str()) %}
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ page.selected_role }}"><input type="hidden" name="object" value="{{ object.object }}"><input type="hidden" name="action" value="{{ action }}">
<button type="submit" class="danger">{{ action }} ✓</button>
</form>
{% else if page.effective(object.object.as_str(), action.as_str()) %}
<span class="tag">{{ action }} · inherited</span>
{% else %}
<form hx-post="/admin/permissions/grant" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ page.selected_role }}"><input type="hidden" name="object" value="{{ object.object }}"><input type="hidden" name="action" value="{{ action }}">
<button type="submit" class="secondary">Grant {{ action }}</button>
</form>
{% endif %}
{% endfor %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% if page.can_manage_users %}
<section class="panel">
<h2>Users</h2>
<table class="builder-table">
<thead><tr><th>Username</th><th>Email</th><th>Current role</th><th>Assign role</th><th>Reset password</th></tr></thead>
<tbody>{% for user in page.users %}<tr>
<td>{{ user.username }}</td><td>{{ user.email }}</td><td>{{ user.role }}</td>
<td><form hx-post="/admin/permissions/users/role" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<select name="role">{% for role in page.assignable_roles() %}<option value="{{ role.name }}" {% if user.role == role.name %}selected{% endif %}>{{ role.name }}</option>{% endfor %}</select>
<button type="submit">Assign</button>
</form></td>
<td>{% if page.can_reset_password(user) %}<form hx-post="/admin/permissions/users/password" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm password" required>
<button type="submit">Reset</button>
</form>{% endif %}</td>
</tr>{% endfor %}</tbody>
</table>
</section>
{% endif %}
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -154,22 +154,26 @@
{% if !page.permission_object.is_empty() %}
<section class="panel">
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
<p class="hint">These grants cover this table family and take effect on subsequent requests without replacing the current session.</p>
<p class="hint">
These grants cover this table family and take effect on subsequent requests without replacing the
current session. Wider access — a whole profile, or every table at once — is on the
<a href="/permissions/grants">Permissions page</a>.
</p>
<table class="builder-table">
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
<tbody>{% for role in page.role_permissions %}<tr>
<td>{{ role.role }}</td>
<td><div class="actions">{% for permission in role.actions %}
{% if permission.direct %}
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="object" value="{{ page.permission_object }}"><input type="hidden" name="action" value="{{ permission.action }}">
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="revoke"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="danger">{{ permission.action }} ✓</button>
</form>
{% else if permission.effective %}
<span class="tag">{{ permission.action }} · inherited</span>
{% else %}
<form hx-post="/admin/permissions/grant" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="object" value="{{ page.permission_object }}"><input type="hidden" name="action" value="{{ permission.action }}">
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="grant"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="secondary">Grant {{ permission.action }}</button>
</form>
{% endif %}

View File

@@ -0,0 +1,140 @@
{#
GET /permissions/grants — crate::pages::permissions::grants::ui::GrantsTemplate
One matrix per profile: objects down the side, actions across the top. Every
button on the page — a single cell, a whole row, a whole profile, everything —
posts the same form to /permissions/grants/apply and differs only in the list
of object|action pairs it carries. The lists are built in Rust
(grants::state), so the page never asks the server to work out what "all"
meant.
#}
{% extends "ui/base.html" %}
{% block title %}Access{% endblock %}
{% macro apply(role, mode, pairs, label, style, hint) %}
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role }}">
<input type="hidden" name="mode" value="{{ mode }}">
{% for pair in pairs %}<input type="hidden" name="pair" value="{{ pair }}">{% endfor %}
<button type="submit" class="{{ style }}" title="{{ hint }}">{{ label }}</button>
</form>
{% endmacro %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Permissions</p>
<h1>Access</h1>
<p>What a role may do with the data. Everyone holding the role gets exactly this.</p>
</div>
</section>
{% include "pages/permissions/tabs.html" %}
{% if page.updated %}<p class="notice">Access updated. It applies to the next request every holder of the role makes.</p>{% endif %}
{% if page.roles.is_empty() %}
<section class="panel">
<h2>No role to edit</h2>
<p class="hint">There is no role here you outrank. <a href="/permissions/roles">Create one</a> first.</p>
</section>
{% else %}
<section class="panel">
<h2>Role</h2>
<form method="get" action="/permissions/grants" class="form-grid">
<label>Editing
<select name="role" onchange="this.form.submit()">
{% for role in page.roles %}
<option value="{{ role.name }}" {% if page.selected(role.name.as_str()) %}selected{% endif %}>{{ role.name }}{% if !role.parent.is_empty() %} → inherits {{ role.parent }}{% endif %}</option>
{% endfor %}
</select>
</label>
</form>
<p class="hint">
{% if !page.selected_parent.is_empty() %}
<strong>{{ page.selected_role }}</strong> inherits everything <strong>{{ page.selected_parent }}</strong> has. Inherited access shows below but is changed on the parent.
{% else %}
<strong>{{ page.selected_role }}</strong> inherits from nothing, so what you see below is all it has.
{% endif %}
{% if page.selected_is_structural %}
This role designs the system and may never write row data, so only the read column is open.
{% endif %}
</p>
<h3 class="panel-subhead">Shortcuts</h3>
<p class="hint">These grant the wildcard objects, so they keep covering profiles and tables created later.</p>
<div class="actions shortcut-row">
{% call apply(page.selected_role, "grant", page.everything_read_pairs(), "Read everything", "secondary", "Read on every profile, journal and exchange rate") %}{% endcall %}
{% call apply(page.selected_role, "grant", page.everything_pairs(), "Full access to everything", "secondary", "Every action the role may hold, on every profile") %}{% endcall %}
{% if page.has_direct() %}
{% call apply(page.selected_role, "revoke", page.direct_pairs(), "Remove all access", "danger", "Revoke every grant this role holds directly") %}{% endcall %}
{% endif %}
</div>
</section>
{% for group in page.groups %}
<section class="panel">
<h2>{% if group.global %}Everything, everywhere{% else %}Profile: {{ group.title }}{% endif %}<span class="count">{{ group.rows.len() }}</span></h2>
<div class="actions shortcut-row">
{% if group.has_wildcards() %}
{% call apply(page.selected_role, "grant", group.read_pairs(), "Read only", "secondary", "Read on everything in here") %}{% endcall %}
{% call apply(page.selected_role, "grant", group.all_pairs(), "Full access", "secondary", "Every action the role may hold, on everything in here") %}{% endcall %}
{% endif %}
{% if group.has_direct() %}
{% call apply(page.selected_role, "revoke", group.direct_pairs(), "Clear", "danger", "Revoke everything this role holds directly in here") %}{% endcall %}
{% endif %}
</div>
<div class="table-scroll">
<table class="builder-table grant-matrix">
<thead>
<tr>
<th>Object</th>
{% for action in page.actions() %}<th>{{ action }}</th>{% endfor %}
<th>Row</th>
</tr>
</thead>
<tbody>
{% for row in group.rows %}
<tr>
<td>
<span class="object-label">{{ row.label }}</span>
{% if !row.note.is_empty() %}<small>{{ row.note }}</small>{% endif %}
<code>{{ row.object }}</code>
</td>
{% for cell in row.cells %}
<td class="grant-cell">
{% if !cell.allowed %}
<span class="cell-closed" title="Not available for this role">·</span>
{% else if cell.direct %}
{% call apply(page.selected_role, "revoke", row.one_pair(cell.action.as_str()), "✓", "cell-on", "Held directly — click to revoke") %}{% endcall %}
{% else if cell.inherited %}
<span class="cell-inherited" title="inherited: covered by the parent role or a wider grant"></span>
{% else %}
{% call apply(page.selected_role, "grant", row.one_pair(cell.action.as_str()), "+", "cell-off", "Click to grant") %}{% endcall %}
{% endif %}
</td>
{% endfor %}
<td class="grant-cell">
<div class="actions">
{% call apply(page.selected_role, "grant", row.all_pairs(), "All", "secondary", "Grant every action available on this object") %}{% endcall %}
{% if row.has_direct() %}
{% call apply(page.selected_role, "revoke", row.direct_pairs(), "None", "danger", "Revoke this object's direct grants") %}{% endcall %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endfor %}
{% endif %}
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{# GET /permissions/roles — crate::pages::permissions::roles::ui::RolesTemplate #}
{% extends "ui/base.html" %}
{% block title %}Roles{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Permissions</p>
<h1>Roles</h1>
<p>A role is a named set of permissions. People are given roles; roles are given access.</p>
</div>
</section>
{% include "pages/permissions/tabs.html" %}
{% if page.updated %}<p class="notice">Roles updated. Signed-in sessions stay valid and pick up the change on their next request.</p>{% endif %}
<section class="panel">
<h2>New role</h2>
<p class="hint">
Inheriting from another role starts the new one with everything that role has, and keeps it in step
as that role changes. Starter access is a shortcut for the two common cases; anything narrower is a
few clicks on the Access tab.
</p>
<form hx-post="/permissions/roles/create" hx-target="#permission-status" class="form-grid">
<label>Name<input name="name" required maxlength="50" placeholder="sales"></label>
<label>Inherits from
<select name="parent">
<option value="">Nothing — starts empty</option>
{% for parent in page.parents %}<option value="{{ parent }}">{{ parent }}</option>{% endfor %}
</select>
</label>
<label>Starter access
<select name="access">
<option value="none">None — grant it on the Access tab</option>
<option value="read">Read everything</option>
<option value="full">Read and write everything</option>
</select>
</label>
<div class="form-actions"><button type="submit">Create role</button></div>
</form>
</section>
<section class="panel">
<h2>Existing roles<span class="count">{{ page.roles.len() }}</span></h2>
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>Role</th><th>Kind</th><th>Inherits from</th><th>People</th><th>Access</th><th>Remove</th></tr></thead>
<tbody>
{% for role in page.roles %}
<tr>
<td>{{ role.name }}{% if role.built_in %} <span class="tag">built in</span>{% endif %}</td>
<td>{{ role.kind }}</td>
<td>{% if role.parent.is_empty() %}<span class="hint"></span>{% else %}{{ role.parent }}{% endif %}</td>
<td>{% match role.users %}{% when Some with (count) %}{{ count }}{% when None %}<span class="hint"></span>{% endmatch %}</td>
<td>
{% if role.editable %}
<a class="link-action" href="/permissions/grants?role={{ role.name }}">Edit access</a>
{% else %}
<span class="hint">outranks you</span>
{% endif %}
</td>
<td>
{% if role.removable() %}
<form hx-post="/permissions/roles/remove" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.name }}">
<button type="submit" class="danger">Remove</button>
</form>
{% else %}
<span class="hint">{{ role.keeps_reason() }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{#
The section switcher shared by the three permission pages. Every one of them
carries a `page.tabs` (crate::pages::permissions::common::state::Tabs), which
says which section is open and which the signed-in role may open at all.
The three are deliberately separate pages: making a role, handing it to
someone, and deciding what it may do are three different decisions, and doing
them on one screen was what made the old page hard to read.
#}
<nav class="tabs" aria-label="permission sections">
{% if page.tabs.can_manage_roles %}
<a href="/permissions/roles" class="tab {% if page.tabs.is("roles") %}selected{% endif %}" {% if page.tabs.is("roles") %}aria-current="page"{% endif %}>
<span>Roles</span><small>Create roles and their inheritance</small>
</a>
{% endif %}
{% if page.tabs.can_manage_users %}
<a href="/permissions/users" class="tab {% if page.tabs.is("users") %}selected{% endif %}" {% if page.tabs.is("users") %}aria-current="page"{% endif %}>
<span>People</span><small>Give a user a role</small>
</a>
{% endif %}
{% if page.tabs.can_manage_roles %}
<a href="/permissions/grants" class="tab {% if page.tabs.is("grants") %}selected{% endif %}" {% if page.tabs.is("grants") %}aria-current="page"{% endif %}>
<span>Access</span><small>What a role may do</small>
</a>
{% endif %}
</nav>

View File

@@ -0,0 +1,64 @@
{# GET /permissions/users — crate::pages::permissions::users::ui::UsersTemplate #}
{% extends "ui/base.html" %}
{% block title %}People{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Permissions</p>
<h1>People</h1>
<p>Give a user a role. What that role may do is decided once, on the Access tab.</p>
</div>
</section>
{% include "pages/permissions/tabs.html" %}
{% if page.updated %}<p class="notice">Updated. The person's next request uses the new role; they do not have to sign in again.</p>{% endif %}
<section class="panel">
<h2>Users<span class="count">{{ page.users.len() }}</span></h2>
<p class="hint">
You can only change someone whose role you outrank — {{ page.editable_users() }} of {{ page.users.len() }} here.
</p>
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Change role</th><th>Reset password</th></tr></thead>
<tbody>
{% for user in page.users %}
<tr>
<td>{{ user.username }}</td>
<td>{% if user.email.is_empty() %}<span class="hint"></span>{% else %}{{ user.email }}{% endif %}</td>
<td>{{ user.role }}</td>
{% if user.editable %}
<td>
<form hx-post="/permissions/users/role" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<select name="role">
{% for role in page.roles %}<option value="{{ role }}" {% if user.role == role.as_str() %}selected{% endif %}>{{ role }}</option>{% endfor %}
</select>
<button type="submit">Assign</button>
</form>
</td>
<td>
<form hx-post="/permissions/users/password" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm" required>
<button type="submit">Reset</button>
</form>
</td>
{% else %}
<td colspan="2"><span class="hint">outranks you</span></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -23,6 +23,7 @@
<!-- Desktop Menu -->
<ul class="hidden items-center gap-4 md:flex">
{% if nav.can_admin %}<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
{% if nav.can_permissions %}<li><a href="/permissions" class="{% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Import</a></li>{% endif %}
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Export</a></li>{% endif %}
@@ -46,6 +47,7 @@
<!-- Mobile Menu -->
<ul x-cloak x-show="mobileMenuIsOpen" x-transition:enter="transition motion-reduce:transition-none ease-out duration-300" x-transition:enter-start="-translate-y-full" x-transition:enter-end="translate-y-0" x-transition:leave="transition motion-reduce:transition-none ease-out duration-300" x-transition:leave-start="translate-y-0" x-transition:leave-end="-translate-y-full" id="mobileMenu" class="fixed max-h-svh overflow-y-auto inset-x-0 top-0 z-10 flex flex-col divide-y divide-outline rounded-b-radius border-b border-outline bg-surface-alt px-6 pb-6 pt-20 dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt md:hidden">
{% if nav.can_admin %}<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
{% if nav.can_permissions %}<li class="py-4"><a href="/permissions" class="w-full text-lg {% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Import</a></li>{% endif %}
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Export</a></li>{% endif %}