Grants that outlive what they name, and a rule you can read
sharing.forget_principal has existed since shares did, documented as the thing that stops a recycled id inheriting somebody's grant, and was called by nobody. Deleting a group left every grant naming it; deleting an account left both the grants to it and the grants of its own work -- that second half is the one nothing else could catch, since their rows cascade and the shares of those rows have nothing to cascade from. Both now run before the delete, while the rows are still findable, and a deleted resource forgets its own. library.share defaulted to False, which meant sharing shipped documented as done and unreachable: the panel only renders for somebody holding it, so out of the box nobody could share anything and nothing said why. It is on. The panel itself was checkboxes inside the resource's *save form*, listing every group and every account on the instance, unpaginated, on every detail page -- and a tick only took effect if you also saved the resource. It is its own routes now: search, one grant per POST, the panel re-rendered from what is stored. Anything already shared stays listed whatever the search says, or removing a grant would mean searching for the name it was given to. Reports join the shareable set and memories still do not: a finished piece of work is the thing somebody most wants to hand over, and a record about a person is not content to pass round. reports.visible became sharing.visible_to, which is the one line its own docstring predicted. Two things fell out: `owned` beside `get`, because sharing grants reading and deleting is the owner's alone; and reading somebody else's report no longer clears their unread dot. Permissions gained the answer to "what can this person actually do?" -- explain() is resolve()'s working shown rather than thrown away, naming admin, the baseline, or the groups that granted each one. That is the simulation the union rule exists to make unnecessary, and until now the only way to get it was to open every group and read the grids by eye. Users and groups are list-plus-detail, and membership is edited from one side: it was on both, and a full-form POST from either overwrote what the other had shown. Read and write are split for notes, memory and skills -- checked on the tool's declared risk, after the gate so it can only narrow, and defaulting on. Quotas are the union rule applied to numbers, with the corner that makes it interesting: zero means "no limit" and wins outright, or a group saying unlimited would count for less than one saying a million. Absent means "no opinion". _narrower folds a group's ceiling with the instance's and is deliberately not min, for the same reason. Five axes, enforced where each is knowable -- before a reply is built, before a second one starts, on an agent reply's clock, before a minute of GPU, and beside the helper cap -- and usage is recorded even for a reply that was stopped or errored, because an endpoint charges either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
757ab305ee
commit
9d7fb72bdb
@@ -0,0 +1,203 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "users" %}
|
||||
|
||||
{% block title %}{{ target.name }} - Users - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}{{ target.name }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
<a href="/admin/users">{{ icon("chevron-left", "icon--sm") }} All users</a>
|
||||
· {{ target.email }}
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/users/{{ target.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" id="name" name="name" value="{{ target.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="role">Role</label>
|
||||
<select class="input" id="role" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if target.role == role }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
An administrator bypasses every permission and every quota below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="active" value="true" {{ 'checked' if target.active }}>
|
||||
<span>Active</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Turning this off signs them out everywhere at once, rather than waiting
|
||||
for a cookie to expire.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- What they can actually do -------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">What this account can do</h2>
|
||||
<p class="card__lede">
|
||||
Read-only, and deliberately: every switch here is set somewhere else — in the
|
||||
<a href="/admin/groups">baseline</a> or in a named group — and a control on
|
||||
this page would be a third place to change one thing. What it adds is the
|
||||
<em>source</em>, which is the question the grids could not answer without
|
||||
opening every group by eye.
|
||||
</p>
|
||||
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
<div class="field">
|
||||
<span class="field__label">{{ section_name }}</span>
|
||||
{% for definition in defs %}
|
||||
{% set state = explained[definition.key] %}
|
||||
<div class="perm-row" style="display: flex; gap: var(--sp-3); align-items: baseline">
|
||||
{{ icon("check" if state.on else "x", "icon--sm") }}
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
{% if state.on %}
|
||||
<span class="perm-row__desc">
|
||||
from {{ state.source | join(", ") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="perm-row__desc faint">not granted</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{# --- Membership ----------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Groups</h2>
|
||||
<p class="card__lede">
|
||||
Edited from the group's own page. One control per value, so a save here
|
||||
cannot undo a save there.
|
||||
</p>
|
||||
{% if target.groups %}
|
||||
<div class="btn-row">
|
||||
{% for group in target.groups %}
|
||||
<a class="btn btn--sm" href="/admin/groups/{{ group.id }}">{{ group.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted text-sm">In no group. They get the baseline and nothing more.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# --- Quotas and usage ----------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">This month</h2>
|
||||
<p class="card__lede">
|
||||
Counted from the first of the month, UTC. Recorded for every reply including
|
||||
one that was stopped or failed — an endpoint charges for tokens it generated
|
||||
whether or not anybody wanted them.
|
||||
</p>
|
||||
<dl class="mode-list">
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Tokens</strong></dt>
|
||||
<dd>
|
||||
{{ "{:,}".format(usage.tokens) }}
|
||||
{% if limits.monthly_tokens %} of {{ "{:,}".format(limits.monthly_tokens) }}{% endif %}
|
||||
<span class="faint text-xs">
|
||||
({{ "{:,}".format(usage.prompt_tokens) }} prompt,
|
||||
{{ "{:,}".format(usage.completion_tokens) }} written)
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Replies</strong></dt>
|
||||
<dd>{{ usage.replies }}</dd>
|
||||
</div>
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>Images</strong></dt>
|
||||
<dd>
|
||||
{{ usage.images }} this month, {{ usage.images_today }} today
|
||||
{% if limits.images_per_day %} (limit {{ limits.images_per_day }} a day){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="section-title">Limits in force</h3>
|
||||
<p class="field__hint">
|
||||
Resolved across their groups by <strong>maximum</strong> — the union rule
|
||||
applied to numbers, so a second group can only ever grant more. Zero means no
|
||||
limit and wins outright, because a group saying “unlimited” must not count
|
||||
for less than one saying “a million”.
|
||||
</p>
|
||||
<dl class="mode-list">
|
||||
{% for key, label, description in limit_defs %}
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>{{ label }}</strong></dt>
|
||||
<dd>
|
||||
{% if limits[key] %}{{ "{:,}".format(limits[key]) }}{% else %}no limit{% endif %}
|
||||
<span class="perm-row__desc">{{ description }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{# --- Models --------------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Models they can use</h2>
|
||||
<p class="card__lede">
|
||||
Model access is separate from permissions: a permission says what somebody
|
||||
may do, this says what they may do it with.
|
||||
</p>
|
||||
{% if models %}
|
||||
<div class="btn-row">
|
||||
{% for model in models %}
|
||||
<a class="btn btn--sm" href="/admin/models/{{ model.id }}/edit">{{ model.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted text-sm">None. They cannot start a chat at all.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# --- Dangerous ------------------------------------------------------------ #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Password and removal</h2>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/password" class="btn-row">
|
||||
<input class="input" name="password" type="password" required
|
||||
placeholder="New password" aria-label="New password" style="flex: 1">
|
||||
<button class="btn" type="submit">Reset password</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Signs them out everywhere. An administrator resetting a password usually
|
||||
means the account is compromised or the person has gone.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
||||
data-confirm="Delete {{ target.email }}? Their chats, folders and library go with them."
|
||||
style="margin-top: var(--sp-4)">
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete this account
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Their chats, folders and library go too, and every share naming them or
|
||||
naming anything of theirs.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user