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
20bb569b00
commit
1b8c9f948c
@@ -0,0 +1,141 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{% set section = "groups" %}
|
||||
|
||||
{% block title %}{{ group.name }} - Groups - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}{{ group.name }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
<a href="/admin/groups">{{ icon("chevron-left", "icon--sm") }} All groups</a>
|
||||
· A group only ever <em>adds</em>. Anything already in the baseline is shown
|
||||
below as such, so a tick here that changes nothing looks like one.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/groups/{{ group.id }}" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Name</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" id="name" name="name" value="{{ group.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">What it is for</label>
|
||||
<input class="input" id="description" name="description"
|
||||
value="{{ group.description }}" maxlength="1000">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Permissions this group adds</h2>
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
<div class="field">
|
||||
<span class="field__label">{{ section_name }}</span>
|
||||
{% for definition in defs %}
|
||||
<label class="checkbox perm-row">
|
||||
<input type="checkbox" name="permission" value="{{ definition.key }}"
|
||||
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
{% if baseline[definition.key] %}
|
||||
<span class="badge">already in the baseline</span>
|
||||
{% endif %}
|
||||
<span class="perm-row__desc">{{ definition.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{#
|
||||
Quotas. Every one is zero-for-no-limit, and an *empty* box is different from
|
||||
a zero: empty is "this group has no opinion" and contributes nothing to the
|
||||
resolution, zero is "unlimited" and wins outright. Saying that here is the
|
||||
only place somebody will read it.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Quotas</h2>
|
||||
<p class="card__lede">
|
||||
Resolved across a person's groups by <strong>maximum</strong> — the union
|
||||
rule applied to numbers, so a second group can only grant more.
|
||||
<strong>Leave a box empty</strong> for “no opinion”, and use
|
||||
<strong>0</strong> for “no limit”, which beats any number another group
|
||||
sets. Administrators are unlimited whatever is here.
|
||||
</p>
|
||||
<div class="field-row">
|
||||
{% for key, label, description in limit_defs %}
|
||||
<div class="field">
|
||||
<label class="field__label" for="limit-{{ key }}">{{ label }}</label>
|
||||
<input class="input" id="limit-{{ key }}" name="limit_{{ key }}"
|
||||
type="number" min="0" step="1"
|
||||
value="{{ limits.get(key, '') }}" placeholder="no opinion">
|
||||
<p class="field__hint">{{ description }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#
|
||||
Membership lives here and only here. It used to be on the user page as well,
|
||||
and a full-form POST from either side overwrote what the other had shown.
|
||||
#}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Members</h2>
|
||||
<p class="card__lede">
|
||||
The one place membership is edited. A user's own page links here rather
|
||||
than offering a second control for the same value.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
{% for person in users %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="user_ids" value="{{ person.id }}"
|
||||
{{ 'checked' if person in group.users }}>
|
||||
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Models this group unlocks</h2>
|
||||
<p class="card__lede">
|
||||
A model marked public is available to everyone; one that is not is
|
||||
available to the groups named here. Model access is separate from
|
||||
permissions — one says what somebody may do, the other what with.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
{% for model in models %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="model_ids" value="{{ model.id }}"
|
||||
{{ 'checked' if model in group.models }}>
|
||||
<span>{{ model_avatar(model, "model-avatar model-avatar--sm") }} {{ model.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save group</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Remove</h2>
|
||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||
data-confirm="Delete {{ group.name }}? Its members keep their accounts.">
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete this group
|
||||
</button>
|
||||
</form>
|
||||
<p class="field__hint">
|
||||
Members keep their accounts and lose whatever this group granted them. Every
|
||||
share naming this group goes too — nothing cascades to those, so they are
|
||||
deleted explicitly.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "groups" %}
|
||||
|
||||
{% block title %}Groups & permissions - {{ brand.name }}{% endblock %}
|
||||
@@ -9,8 +9,9 @@
|
||||
<p class="admin-lede">
|
||||
Permissions are a <strong>union</strong>: everyone starts with the baseline
|
||||
below, and each group they belong to can add more. A group never takes
|
||||
something away, so being in a second group can only widen what someone can do.
|
||||
Administrators bypass all of it.
|
||||
something away, so being in a second group can only widen what someone can do —
|
||||
which is what keeps “why can this person not do X?” answerable without
|
||||
simulating every group they are in. Administrators bypass all of it.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Baseline permissions</h2>
|
||||
<p class="text-sm muted" style="margin-bottom: var(--sp-4)">
|
||||
<p class="card__lede">
|
||||
What every signed-in user can do before any group is considered. Turn
|
||||
something off here and grant it through a group to make it opt-in.
|
||||
</p>
|
||||
@@ -48,7 +49,39 @@
|
||||
Groups <span class="badge">{{ groups|length }}</span>
|
||||
</h2>
|
||||
|
||||
<section class="card">
|
||||
{#
|
||||
Rows, not a form each. The old page rendered every group's full permission
|
||||
grid, every member and every model on one screen -- fine for two groups and
|
||||
unreadable at ten, which is the list-plus-detail rule the model admin already
|
||||
follows.
|
||||
#}
|
||||
<div class="model-rows">
|
||||
{% for group in groups %}
|
||||
<a class="model-row" href="/admin/groups/{{ group.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">{{ group.name }}</span>
|
||||
{% if group.description %}
|
||||
<span class="model-row__id">{{ group.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="model-row__meta">
|
||||
<span class="badge">{{ group.users | length }} member{{ '' if group.users|length == 1 else 's' }}</span>
|
||||
{% if granted[group.id] %}
|
||||
<span class="badge badge--leaf">+{{ granted[group.id] }} permission{{ '' if granted[group.id] == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
{% if group.limits_json %}<span class="badge">quotas</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">
|
||||
No groups yet. Everybody gets the baseline above and nothing more.
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-6)">
|
||||
<form method="post" action="/admin/groups" class="btn-row">
|
||||
<input class="input" name="name" placeholder="New group name" required
|
||||
aria-label="New group name">
|
||||
@@ -57,114 +90,4 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if not groups %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
{{ icon("users", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No groups yet. Create one to grant extra permissions, or to restrict a model
|
||||
to a subset of users.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for group in groups %}
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/groups/{{ group.id }}">
|
||||
<div class="card__header">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span class="text-xs faint">
|
||||
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
|
||||
{{ group.models|length }} model{{ '' if group.models|length == 1 else 's' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="gn-{{ group.id }}">Name</label>
|
||||
<input class="input" id="gn-{{ group.id }}" name="name" value="{{ group.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="gd-{{ group.id }}">Description</label>
|
||||
<input class="input" id="gd-{{ group.id }}" name="description"
|
||||
value="{{ group.description }}" placeholder="What is this group for?">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Grants</span>
|
||||
<p class="field__hint" style="margin-bottom: var(--sp-2)">
|
||||
Anything already in the baseline stays on regardless — these only add.
|
||||
</p>
|
||||
{% for section_name, defs in permission_groups.items() %}
|
||||
{% for definition in defs %}
|
||||
<label class="checkbox perm-row">
|
||||
<input type="checkbox" name="permission" value="{{ definition.key }}"
|
||||
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
|
||||
<span>
|
||||
<strong>{{ definition.label }}</strong>
|
||||
<span class="perm-row__desc">
|
||||
{{ definition.description }}
|
||||
{% if baseline[definition.key] %}<em>(already in the baseline)</em>{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Members</span>
|
||||
{% if users %}
|
||||
<div class="checkbox-row">
|
||||
{% for account in users %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="user_ids" value="{{ account.id }}"
|
||||
{{ 'checked' if account in group.users }}>
|
||||
<span>{{ account.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="field__hint">No users yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Model access</span>
|
||||
<p class="field__hint" style="margin-bottom: var(--sp-2)">
|
||||
Models marked “available to everyone” are reachable regardless. These
|
||||
grant access to the restricted ones.
|
||||
</p>
|
||||
{% if models %}
|
||||
<div class="checkbox-row">
|
||||
{% for model in models %}
|
||||
<label class="checkbox {{ 'is-muted' if model.public }}">
|
||||
<input type="checkbox" name="model_ids" value="{{ model.id }}"
|
||||
{{ 'checked' if model in group.models }}>
|
||||
<span>{{ model.label }}{% if model.public %} <em>(public)</em>{% endif %}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="field__hint">No models yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card__footer">
|
||||
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||
data-confirm="Delete the group “{{ group.name }}”? Its members keep their accounts."
|
||||
data-confirm-title="Delete group">
|
||||
<button class="btn btn--sm btn--danger" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -7,157 +7,103 @@
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Everyone with an account on this instance. Administrators bypass every
|
||||
permission; ordinary users get the baseline permissions plus whatever their
|
||||
groups add.
|
||||
Every account on this instance. Open one to see what it can actually do and
|
||||
where each of those permissions came from. Group membership is edited from the
|
||||
<a href="/admin/groups">group's</a> own page — one control per value, so a save
|
||||
on one screen cannot undo a save on another.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="/admin/users" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search by name or email" aria-label="Search users">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
|
||||
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
|
||||
</form>
|
||||
<div class="filter-bar">
|
||||
<form class="filter-form" method="get" action="/admin/users">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search by name or email…" aria-label="Search users">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card__title" style="cursor: pointer">Add a user</summary>
|
||||
<form method="post" action="/admin/users" style="margin-top: var(--sp-4)">
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-name">Name</label>
|
||||
<input class="input" id="nu-name" name="name" required>
|
||||
<div class="model-rows">
|
||||
{% for person in users %}
|
||||
<a class="model-row" href="/admin/users/{{ person.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">
|
||||
{{ person.name }}
|
||||
{% if person.role == "admin" %}<span class="badge badge--leaf">admin</span>{% endif %}
|
||||
{% if person.role == "pending" %}<span class="badge">pending</span>{% endif %}
|
||||
{% if not person.active %}<span class="badge">disabled</span>{% endif %}
|
||||
</span>
|
||||
<span class="model-row__id">{{ person.email }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-email">Email</label>
|
||||
<input class="input" id="nu-email" name="email" type="email" required>
|
||||
<div class="model-row__meta">
|
||||
{% if person.groups %}
|
||||
<span class="text-xs faint">
|
||||
{{ person.groups | map(attribute="name") | join(", ") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% set spent = usage[person.id] %}
|
||||
{% if spent.tokens %}
|
||||
<span class="badge" title="Tokens this month">{{ "{:,}".format(spent.tokens) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-password">Password</label>
|
||||
<input class="input" id="nu-password" name="password" type="password"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">
|
||||
At least 8 characters. Tell them to change it — you will know it otherwise.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-role">Role</label>
|
||||
<select class="select" id="nu-role" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if role == 'user' }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">{{ "Nobody matches that." if q else "No accounts yet." }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pager.pages > 1 %}
|
||||
<div class="btn-row" style="margin-top: var(--sp-5)">
|
||||
{% if pager.page > 1 %}
|
||||
<a class="btn btn--sm" href="/admin/users?page={{ pager.page - 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
|
||||
{{ icon("chevron-left", "icon--sm") }} Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="text-sm faint">Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} accounts</span>
|
||||
{% if pager.page < pager.pages %}
|
||||
<a class="btn btn--sm" href="/admin/users?page={{ pager.page + 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
|
||||
Next {{ icon("chevron-right", "icon--sm") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card" style="margin-top: var(--sp-8)">
|
||||
<h2 class="card__title">Add an account</h2>
|
||||
<p class="card__lede">
|
||||
Without going through registration — useful when sign-up is closed.
|
||||
</p>
|
||||
<form method="post" action="/admin/users" class="form-grid">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-email">Email</label>
|
||||
<input class="input" id="new-email" name="email" type="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">Password</label>
|
||||
<input class="input" id="new-password" name="password" type="password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-role">Role</label>
|
||||
<select class="input" id="new-role" name="role">
|
||||
{% for role in roles %}<option value="{{ role }}">{{ role }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<h2 class="admin-section-title">
|
||||
Accounts <span class="badge">{{ users|length }}</span>
|
||||
</h2>
|
||||
|
||||
{% for account in users %}
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/users/{{ account.id }}">
|
||||
<div class="card__header">
|
||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
|
||||
<strong class="truncate">{{ account.name }}</strong>
|
||||
<code class="text-xs faint">{{ account.email }}</code>
|
||||
{% if account.is_admin %}<span class="badge badge--leaf">admin</span>{% endif %}
|
||||
{% if not account.active %}<span class="badge badge--danger">deactivated</span>{% endif %}
|
||||
{% if account.id == user.id %}<span class="badge">you</span>{% endif %}
|
||||
</div>
|
||||
<span class="text-xs faint">
|
||||
{% if account.last_login_at %}
|
||||
last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }}
|
||||
{% else %}
|
||||
never signed in
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="un-{{ account.id }}">Name</label>
|
||||
<input class="input" id="un-{{ account.id }}" name="name" value="{{ account.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="ur-{{ account.id }}">Role</label>
|
||||
<select class="select" id="ur-{{ account.id }}" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if role == account.role }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
<strong>admin</strong> can do everything, including this page.
|
||||
<strong>user</strong> is an ordinary account.
|
||||
<strong>pending</strong> cannot sign in until promoted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="active" value="true" {{ 'checked' if account.active }}>
|
||||
<span>Active — may sign in</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Deactivating signs them out everywhere immediately, rather than waiting
|
||||
for their session to expire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="field">
|
||||
<span class="field__label">Groups</span>
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="group_ids" value="{{ group.id }}"
|
||||
{{ 'checked' if group in account.groups }}>
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if account.is_admin and admin_count <= 1 %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>
|
||||
The only administrator. Promote someone else before demoting or
|
||||
deactivating this account — an instance with no admin can only be
|
||||
recovered with <code>lembas create-admin</code>.
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
|
||||
</form>
|
||||
|
||||
<div class="card__footer">
|
||||
<form method="post" action="/admin/users/{{ account.id }}/password" class="btn-row">
|
||||
<input class="input" type="password" name="password" minlength="8"
|
||||
placeholder="Set a new password" autocomplete="new-password" required
|
||||
aria-label="New password for {{ account.email }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon("key", "icon--sm") }} Reset</button>
|
||||
</form>
|
||||
|
||||
{% if account.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ account.id }}/delete"
|
||||
data-confirm="Delete {{ account.email }} and all their chats? This cannot be undone."
|
||||
data-confirm-title="Delete account">
|
||||
<button class="btn btn--sm btn--danger" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Create account
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,63 +1,30 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The share panel on a detail page.
|
||||
The share panel's placeholder on a detail page.
|
||||
|
||||
Sharing grants *reading*. Two people editing one note with no history and no
|
||||
merge is worse than the inconvenience of copying it, so there is no "can edit"
|
||||
here and the copy is deliberate rather than missing.
|
||||
It fetches `_share_panel.html` on load rather than being rendered inline, and
|
||||
that is the whole change: the panel used to be checkboxes inside the
|
||||
resource's *save form*, so a share only happened if you also saved the
|
||||
resource, and the list of candidates was every group and every account on the
|
||||
instance, unpaginated, on every detail page.
|
||||
|
||||
Only the owner sees this at all: someone a thing was shared with cannot share
|
||||
it onward, which keeps "who can see this" answerable by asking one person.
|
||||
Sharing still grants *reading*. Two people editing one note with no history and
|
||||
no merge is worse than the inconvenience of copying it, so there is no "can
|
||||
edit" and its absence is deliberate rather than missing.
|
||||
|
||||
Only the owner sees it at all — someone a thing was shared with cannot share it
|
||||
onward — and the route enforces that as well, because a template is not a
|
||||
permission check.
|
||||
#}
|
||||
{% if is_owner and can_share %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Shared with
|
||||
{% if shared_users or shared_groups %}
|
||||
<span class="badge badge--leaf">{{ shared_users|length + shared_groups|length }}</span>
|
||||
{% else %}
|
||||
<span class="badge">nobody</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
They will be able to read this, and their models will find it. They cannot
|
||||
change it or share it on.
|
||||
</p>
|
||||
|
||||
{% if groups %}
|
||||
<div class="field">
|
||||
<label class="field__label">Groups</label>
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="share_group" value="{{ group.id }}"
|
||||
{{ 'checked' if group.id in shared_groups }}>
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if people %}
|
||||
<div class="field">
|
||||
<label class="field__label">People</label>
|
||||
<div class="checkbox-row">
|
||||
{% for person in people %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="share_user" value="{{ person.id }}"
|
||||
{{ 'checked' if person.id in shared_users }}>
|
||||
<span>{{ person.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not groups and not people %}
|
||||
<p class="muted text-sm">There is nobody else on this instance yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
<div hx-get="/api/library/share/{{ share_kind }}/{{ share_id }}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Shared with <span class="badge">…</span></h2>
|
||||
</section>
|
||||
</div>
|
||||
{% elif not is_owner %}
|
||||
<div class="alert">
|
||||
{{ icon("users", "alert__icon") }}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
Who can see one thing, and the search that changes it.
|
||||
|
||||
Swapped into itself after every change, so what is on screen is always what is
|
||||
stored -- the old panel was a set of checkboxes that only took effect if the
|
||||
resource happened to be saved afterwards, which is a control that silently
|
||||
does nothing.
|
||||
|
||||
Every fetch in here names its own `hx-target`. This fragment is included on
|
||||
pages whose forms carry an inherited target, and an element that fetches
|
||||
without one aims at whatever an ancestor said -- the bug the jobs chip had, and
|
||||
the reason `tests/test_chat.py` walks the composer for it.
|
||||
|
||||
The switches are `<label>`s carrying no `role="menuitem"`, for the reason the
|
||||
scope menu's are: `ui.js` closes a picker when a menuitem is clicked, which is
|
||||
right for an action and wrong for a list you set several of.
|
||||
#}
|
||||
<section class="card" id="share-panel">
|
||||
<h2 class="card__title">
|
||||
Shared with
|
||||
{% if share_count %}
|
||||
<span class="badge badge--leaf">{{ share_count }}</span>
|
||||
{% else %}
|
||||
<span class="badge">nobody</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card__lede">
|
||||
They will be able to read this, and their models will find it. They cannot
|
||||
change it, delete it, or share it on — so “who can see this?” stays a
|
||||
question you can answer.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="share-search">Find somebody</label>
|
||||
<input class="input" id="share-search" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Name, email or group…"
|
||||
hx-get="/api/library/share/{{ kind }}/{{ resource.id }}"
|
||||
hx-trigger="input changed delay:250ms, search"
|
||||
hx-target="#share-panel"
|
||||
hx-swap="outerHTML">
|
||||
{% if truncated %}
|
||||
<p class="field__hint">
|
||||
Showing the first few. Type to narrow it — anything already shared stays
|
||||
listed whatever you search for.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="field">
|
||||
<label class="field__label">Groups</label>
|
||||
<div class="checkbox-row">
|
||||
{% for group in groups %}
|
||||
{% set on = group.id in shared_groups %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" {{ 'checked' if on }}
|
||||
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
|
||||
hx-vals='{"principal_type": "group", "principal_id": "{{ group.id }}",
|
||||
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
|
||||
hx-target="#share-panel"
|
||||
hx-swap="outerHTML">
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if people %}
|
||||
<div class="field">
|
||||
<label class="field__label">People</label>
|
||||
<div class="checkbox-row">
|
||||
{% for person in people %}
|
||||
{% set on = person.id in shared_users %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" {{ 'checked' if on }}
|
||||
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
|
||||
hx-vals='{"principal_type": "user", "principal_id": "{{ person.id }}",
|
||||
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
|
||||
hx-target="#share-panel"
|
||||
hx-swap="outerHTML">
|
||||
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not groups and not people %}
|
||||
<p class="muted text-sm">
|
||||
{% if q %}Nobody matches “{{ q }}”.{% else %}There is nobody else here yet.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -13,6 +13,13 @@
|
||||
everything in it comes with it.
|
||||
</p>
|
||||
|
||||
{# Links rather than a form, so a filtered view is a URL you can keep. #}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/knowledge">All bases</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}"
|
||||
href="/library/knowledge?shared=1">Shared with me</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
yours, not its.
|
||||
</p>
|
||||
|
||||
{#
|
||||
Two views, as links, so a filtered list is a real URL you can keep -- the same
|
||||
shape the admin lists use. A badge on a row answers "is this mine?"; the question somebody has is
|
||||
"what have people given me?", which a mixed list of two hundred cannot answer.
|
||||
#}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/notes">All notes</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/notes?shared=1">Shared with me</a>
|
||||
</div>
|
||||
<form method="get" action="/library/notes" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search notes…">
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
change can be read and undone.
|
||||
</p>
|
||||
|
||||
{#
|
||||
Two views, as links, so a filtered list is a real URL you can keep -- the same
|
||||
shape the admin lists use. Same as the notes list.
|
||||
#}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/skills">All skills</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/skills?shared=1">Shared with me</a>
|
||||
</div>
|
||||
<form method="get" action="/library/skills" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search skills…">
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
{{ body_html | safe }}
|
||||
</article>
|
||||
|
||||
{# The same panel every library store uses. A finished piece of work is the
|
||||
thing somebody most wants to hand over. #}
|
||||
<div style="margin-top: var(--sp-6)">
|
||||
{% include "library/_share.html" %}
|
||||
</div>
|
||||
|
||||
{# Only the owner may delete. Sharing grants reading, so somebody a report was
|
||||
shared with sees the panel above saying so and no button here. #}
|
||||
{% if report.owner_id == user.id %}
|
||||
<div class="btn-row" style="margin-top: var(--sp-6)">
|
||||
<form method="post" action="/api/reports/{{ report.id }}/delete"
|
||||
data-confirm="Delete this report? It cannot be brought back.">
|
||||
@@ -44,4 +53,5 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
schedule leaves its result here. Nothing on this page can be replied to.
|
||||
</p>
|
||||
|
||||
{# Reports became shareable and this filter arrived with them: a feed that
|
||||
quietly grew somebody else's work with no way to see only theirs would be
|
||||
worse than one that never grew. #}
|
||||
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
|
||||
<a class="filter-tab {{ 'is-active' if not shared }}" href="/reports">All reports</a>
|
||||
<a class="filter-tab {{ 'is-active' if shared }}" href="/reports?shared=1">Shared with me</a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/reports" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search reports…">
|
||||
|
||||
Reference in New Issue
Block a user