Fix bulk actions, create chats lazily, rework the UI
Seven reported problems.
**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.
**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.
**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.
**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.
**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.
**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.
**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.
Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.
197 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
#}
|
||||
<section class="card connection" id="connection-{{ connection.id }}">
|
||||
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
|
||||
<div class="connection__head">
|
||||
<div class="card__header">
|
||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
|
||||
else 'is-bad' if connection.last_error else 'is-off' }}"
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row" style="gap: var(--sp-2)">
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
hx-post="/admin/connections/{{ connection.id }}/test"
|
||||
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
|
||||
@@ -80,7 +80,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="connection__footer">
|
||||
<div class="card__footer">
|
||||
<span class="text-xs faint">
|
||||
{% if connection.last_checked_at %}
|
||||
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Add a connection</h2>
|
||||
<form method="post" action="/admin/connections" class="form-grid">
|
||||
<form method="post" action="/admin/connections" >
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
||||
@@ -43,7 +43,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--actions">
|
||||
<div class="btn-row" style="margin-top: var(--sp-5)">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Add and load models
|
||||
</button>
|
||||
|
||||
@@ -26,6 +26,21 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Default system prompt</h2>
|
||||
<p class="card__lede">
|
||||
Applied to every chat that does not have a prompt of its own. A model's
|
||||
prompt overrides this, and a chat's prompt overrides both — most specific
|
||||
wins outright rather than the three being stacked together.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
|
||||
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
|
||||
placeholder="You are a helpful assistant.">{{ values.system_prompt }}</textarea>
|
||||
<p class="field__hint">Leave empty to send no system prompt at all.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Registration
|
||||
@@ -69,6 +84,6 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<button class="btn btn--primary" type="submit">Save settings</button>
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="btn btn--primary" type="submit">Save baseline</button>
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save baseline</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</h2>
|
||||
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/groups" class="row" style="gap: var(--sp-2)">
|
||||
<form method="post" action="/admin/groups" class="btn-row">
|
||||
<input class="input" name="name" placeholder="New group name" required
|
||||
aria-label="New group name">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
@@ -71,7 +71,7 @@
|
||||
{% for group in groups %}
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/groups/{{ group.id }}">
|
||||
<div class="row row--between" style="margin-bottom: var(--sp-4)">
|
||||
<div class="card__header">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span class="text-xs faint">
|
||||
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
|
||||
@@ -150,10 +150,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="connection__footer">
|
||||
<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"
|
||||
onsubmit="return confirm('Delete the group “{{ group.name }}”?')">
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Every model discovered across your connections. The order here is the order
|
||||
users see. Pinned models are offered first, and the default is what a new chat
|
||||
starts with.
|
||||
users see in the picker. <strong>Pinning</strong> does not reorder anything —
|
||||
it puts a shortcut in the chat sidebar. The <strong>default</strong> is what a
|
||||
new chat starts with.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
@@ -26,12 +27,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<form method="post" action="/admin/models/bulk" class="bulk-bar">
|
||||
<span class="text-sm muted">With selected:</span>
|
||||
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
||||
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
||||
<button class="btn btn--sm" name="action" value="public" type="submit">Make public</button>
|
||||
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
||||
<form method="post" action="/admin/models/bulk">
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-bar__label">With selected:</span>
|
||||
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
||||
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
||||
<button class="btn btn--sm" name="action" value="public" type="submit">Make public</button>
|
||||
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
||||
</div>
|
||||
|
||||
<div class="model-rows">
|
||||
{% for model in models %}
|
||||
@@ -82,7 +85,7 @@
|
||||
|
||||
{% for model in models %}
|
||||
<section class="card" id="model-{{ model.id }}">
|
||||
<div class="row row--between" style="margin-bottom: var(--sp-4)">
|
||||
<div class="card__header">
|
||||
<div class="row" style="gap: var(--sp-3); min-width: 0">
|
||||
{{ model_avatar(model, cls="model-row__avatar") }}
|
||||
<div style="min-width: 0">
|
||||
@@ -111,6 +114,18 @@
|
||||
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sp-{{ model.id }}">System prompt</label>
|
||||
<textarea class="textarea" id="sp-{{ model.id }}" name="system_prompt" rows="3"
|
||||
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
|
||||
>{{ model.system_prompt }}</textarea>
|
||||
<p class="field__hint">
|
||||
Used by chats on this model that have no prompt of their own.
|
||||
{% if instance_prompt %}Leave empty to fall back to the instance prompt.
|
||||
{% else %}Leave empty to send no system prompt.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field__label">Capabilities</span>
|
||||
<div class="checkbox-row">
|
||||
@@ -138,7 +153,7 @@
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
||||
<span>Pinned — offered first in the picker</span>
|
||||
<span>Pinned — shortcut in the chat sidebar</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,12 +184,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="connection__footer">
|
||||
<div class="card__footer">
|
||||
<form method="post" action="/admin/models/{{ model.id }}/image"
|
||||
enctype="multipart/form-data" class="row" style="gap: var(--sp-2)">
|
||||
enctype="multipart/form-data" class="btn-row">
|
||||
<input class="input input--file" type="file" name="image"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif" required
|
||||
aria-label="Model image">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="/admin/users" class="row" style="margin-bottom: var(--sp-5)">
|
||||
<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>
|
||||
@@ -50,7 +50,9 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
@@ -61,7 +63,7 @@
|
||||
{% for account in users %}
|
||||
<section class="card">
|
||||
<form method="post" action="/admin/users/{{ account.id }}">
|
||||
<div class="row row--between" style="margin-bottom: var(--sp-4); flex-wrap: wrap">
|
||||
<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>
|
||||
@@ -135,12 +137,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
|
||||
</form>
|
||||
|
||||
<div class="connection__footer">
|
||||
<form method="post" action="/admin/users/{{ account.id }}/password" class="row"
|
||||
style="gap: var(--sp-2)">
|
||||
<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 }}">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The composer, used both inside an existing chat and on /chat where no chat
|
||||
row exists yet.
|
||||
|
||||
The only difference is where it posts. With a chat, the turn is appended to
|
||||
the thread in place; without one, /api/chats/start creates the chat and
|
||||
redirects, and the reply streams on arrival because the page renders the
|
||||
unfinished assistant message with its sse-connect. That is what stops an
|
||||
opened-and-abandoned chat ever being written to the database.
|
||||
#}
|
||||
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
|
||||
{% if can.get("files.upload") %}
|
||||
<form id="upload-form" hx-post="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
|
||||
hx-target="#attachments" hx-swap="beforeend"
|
||||
hx-encoding="multipart/form-data" hx-on::after-request="this.reset()">
|
||||
<input class="visually-hidden" type="file" name="file" id="file-input" multiple
|
||||
accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
||||
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__inner">
|
||||
<div class="composer__attachments" id="attachments"></div>
|
||||
|
||||
<form class="composer__form"
|
||||
{% if chat %}
|
||||
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||
hx-target="#thread" hx-swap="beforeend"
|
||||
hx-on::after-request="if (event.detail.successful) {
|
||||
this.reset();
|
||||
document.getElementById('attachments').replaceChildren();
|
||||
window.lembas.autosize(this.querySelector('textarea'));
|
||||
window.lembas.scrollThread(true);
|
||||
}"
|
||||
{% else %}
|
||||
hx-post="/api/chats/start" hx-swap="none"
|
||||
{% endif %}>
|
||||
|
||||
{# The chips live outside this form, so their hidden inputs are pulled in
|
||||
explicitly at submit time. #}
|
||||
<div hx-include="#attachments" hidden></div>
|
||||
{% if not chat and current_model %}
|
||||
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
||||
{% endif %}
|
||||
|
||||
{% if can.get("files.upload") %}
|
||||
<button class="btn btn--icon composer__btn" type="button"
|
||||
aria-label="Attach a file" title="Attach a file"
|
||||
onclick="document.getElementById('file-input').click()">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
|
||||
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||
aria-label="Send">
|
||||
{{ icon("send") }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
{% if can.get("files.upload") %}
|
||||
Drag files in, or paste an image.
|
||||
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
||||
<strong>{{ current_model.label }} has no vision</strong>, so images
|
||||
will not be sent — documents still will.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if can.get("files.upload") %}
|
||||
<div class="dropzone-overlay" aria-hidden="true">
|
||||
{{ icon("attach", "icon--lg") }}
|
||||
<span>Drop to attach</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
{% from "_macros.html" import icon, mark, model_avatar %}
|
||||
{% set speaking_model = (models_by_id | default({})).get(message.model_id) %}
|
||||
{#
|
||||
One message bubble, in either of two states.
|
||||
|
||||
@@ -22,7 +23,14 @@
|
||||
|
||||
<div class="msg__gutter" aria-hidden="true">
|
||||
{% if message.role == "assistant" %}
|
||||
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
||||
{# The model that actually wrote this turn, which is not necessarily the
|
||||
one the chat is set to now. Falls back to the LLeMbas mark when the
|
||||
model has been removed or has no image of its own. #}
|
||||
{% if speaking_model %}
|
||||
{{ model_avatar(speaking_model, cls="msg__avatar") }}
|
||||
{% else %}
|
||||
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||
{% endif %}
|
||||
@@ -31,9 +39,14 @@
|
||||
<div class="msg__main">
|
||||
<header class="msg__meta">
|
||||
<span class="msg__author">
|
||||
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
|
||||
{% if message.role == "assistant" %}
|
||||
{{ speaking_model.label if speaking_model else "LLeMbas" }}
|
||||
{% else %}
|
||||
{{ user.name or "You" }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if message.model_id %}
|
||||
{% if message.model_id and (not speaking_model or speaking_model.display_name) %}
|
||||
{# Only worth showing when it adds something the author line does not. #}
|
||||
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
{% from "_macros.html" import icon, mark, model_avatar %}
|
||||
|
||||
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
|
||||
{% block title %}{{ chat.title if chat else "New chat" }} - LLeMbas{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
@@ -20,84 +20,94 @@
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
|
||||
{% if chat %}
|
||||
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
|
||||
<h1 class="topbar__title">
|
||||
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||
</h1>
|
||||
|
||||
{% if models and can.get("chat.model_select") %}
|
||||
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change from:find select">
|
||||
<select class="select select--compact" name="model_id" aria-label="Model">
|
||||
{% if pinned_models %}
|
||||
<optgroup label="Pinned">
|
||||
{% for model in pinned_models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
<optgroup label="Other models">
|
||||
<div class="topbar__actions">
|
||||
{% if models %}
|
||||
{% if can.get("chat.model_select") or not chat %}
|
||||
{# Models are listed in the administrator's order. Pinning is a
|
||||
sidebar shortcut and deliberately does not reorder this. #}
|
||||
<label class="model-select">
|
||||
{% if current_model %}{{ model_avatar(current_model, cls="model-select__avatar") }}{% endif %}
|
||||
<select class="select select--bare" name="model_id" id="model-select"
|
||||
aria-label="Model"
|
||||
{% if chat %}hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change"{% else %}onchange="
|
||||
const u = new URL(window.location);
|
||||
u.searchParams.set('model', this.value);
|
||||
window.location = u;
|
||||
"{% endif %}>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if current_model and model.model_id == current_model.model_id }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% elif current_model %}
|
||||
<span class="badge">{{ current_model.label }}</span>
|
||||
{% endif %}
|
||||
{% for model in other_models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
|
||||
{% endfor %}
|
||||
{% if pinned_models %}</optgroup>{% endif %}
|
||||
</select>
|
||||
</form>
|
||||
{% elif current_model %}
|
||||
<span class="badge">{{ current_model.label }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn--icon" type="button" aria-label="Chat settings"
|
||||
title="Chat settings"
|
||||
onclick="document.getElementById('chat-settings').toggleAttribute('hidden')">
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
{% else %}
|
||||
<h1 class="topbar__title">Chats</h1>
|
||||
{% endif %}
|
||||
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Chat settings"
|
||||
title="Chat settings" data-toggle="#chat-settings">
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
||||
{# Collapsed by default: these are per-chat overrides, not everyday controls.
|
||||
Each field saves on change rather than needing a Save button, so there is
|
||||
no half-applied state to reason about. #}
|
||||
<section class="chat-settings" id="chat-settings" hidden>
|
||||
<div class="chat-settings__inner">
|
||||
{# Collapsed by default: per-chat overrides, not everyday controls. Each
|
||||
field saves on change, so there is no half-applied state. #}
|
||||
<section class="panel" id="chat-settings" hidden>
|
||||
<div class="panel__inner">
|
||||
{% if current_model and current_model.description %}
|
||||
<p class="chat-settings__note">{{ current_model.description }}</p>
|
||||
<p class="panel__note">{{ current_model.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can.get("chat.system_prompt") %}
|
||||
<div class="field">
|
||||
<label class="field__label" for="system-prompt">System prompt</label>
|
||||
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="3"
|
||||
placeholder="Instructions that apply to every message in this chat."
|
||||
placeholder="{{ inherited_prompt or 'Instructions that apply to every message in this chat.' }}"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change">{{ chat.system_prompt }}</textarea>
|
||||
<p class="field__hint">
|
||||
{% if inherited_prompt %}
|
||||
Leave empty to use the {{ inherited_from }} prompt shown above.
|
||||
{% else %}
|
||||
Overrides the model and instance prompts for this chat only.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can.get("chat.params") %}
|
||||
<div class="chat-settings__params">
|
||||
<div class="grid grid--3">
|
||||
<div class="field">
|
||||
<label class="field__label" for="temperature">Temperature</label>
|
||||
<input class="input" id="temperature" name="temperature" type="number"
|
||||
min="0" max="2" step="0.05" placeholder="default"
|
||||
value="{{ chat.params_json.get('temperature', '') }}"
|
||||
value="{{ chat.params_json.get('temperature') if chat.params_json.get('temperature') is not none else '' }}"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="top-p">Top-p</label>
|
||||
<input class="input" id="top-p" name="top_p" type="number"
|
||||
min="0" max="1" step="0.05" placeholder="default"
|
||||
value="{{ chat.params_json.get('top_p', '') }}"
|
||||
value="{{ chat.params_json.get('top_p') if chat.params_json.get('top_p') is not none else '' }}"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max-tokens">Max tokens</label>
|
||||
<input class="input" id="max-tokens" name="max_tokens" type="number"
|
||||
min="1" step="1" placeholder="default"
|
||||
value="{{ chat.params_json.get('max_tokens', '') }}"
|
||||
value="{{ chat.params_json.get('max_tokens') if chat.params_json.get('max_tokens') is not none else '' }}"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,22 +120,8 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if not chat %}
|
||||
{# No chat selected. #}
|
||||
<div class="empty">
|
||||
{{ mark(cls="empty__mark", uid="empty") }}
|
||||
<h2 class="empty__title">The road goes ever on</h2>
|
||||
<p class="empty__text">
|
||||
Pick a chat from the side, or start a new one.
|
||||
</p>
|
||||
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% elif not models %}
|
||||
{# Nothing to talk to yet. This is the state every fresh install lands in,
|
||||
so it points straight at the fix rather than just reporting a problem. #}
|
||||
{% if not models %}
|
||||
{# Every fresh install lands here, so it points at the fix. #}
|
||||
<div class="empty">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<h2 class="empty__title">No models available</h2>
|
||||
@@ -164,71 +160,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
|
||||
{% if can.get("files.upload") %}
|
||||
{# Uploads go up as soon as a file is chosen, so the chip (and any
|
||||
rejection) appears immediately rather than at send time. The chips
|
||||
carry hidden inputs, which is how the ids reach the message POST. #}
|
||||
<form id="upload-form" hx-post="/api/files?chat_id={{ chat.id }}"
|
||||
hx-target="#attachments" hx-swap="beforeend"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-on::after-request="this.reset()">
|
||||
<input class="visually-hidden" type="file" name="file" id="file-input"
|
||||
multiple accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
||||
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__attachments" id="attachments"></div>
|
||||
|
||||
<form class="composer__form"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||
hx-target="#thread" hx-swap="beforeend"
|
||||
hx-on::after-request="if (event.detail.successful) {
|
||||
this.reset();
|
||||
document.getElementById('attachments').replaceChildren();
|
||||
const t = this.querySelector('textarea');
|
||||
window.lembas.autosize(t);
|
||||
window.lembas.scrollThread(true);
|
||||
}">
|
||||
{# The chips live outside this form, so their hidden inputs are pulled
|
||||
in explicitly at submit time. #}
|
||||
<div hx-include="#attachments" hidden></div>
|
||||
|
||||
{% if can.get("files.upload") %}
|
||||
<button class="btn btn--icon composer__attach" type="button"
|
||||
aria-label="Attach a file" title="Attach a file"
|
||||
onclick="document.getElementById('file-input').click()">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="Send a message…" aria-label="Message"></textarea>
|
||||
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
|
||||
{{ icon("send", "icon--sm") }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
{% if can.get("files.upload") %}
|
||||
Drag files in, or paste an image.
|
||||
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
||||
<strong>{{ current_model.label }} has no vision</strong>, so images
|
||||
will not be sent — documents still will.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if can.get("files.upload") %}
|
||||
<div class="dropzone-overlay" aria-hidden="true">
|
||||
{{ icon("attach", "icon--lg") }}
|
||||
<span>Drop to attach</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "chat/_composer.html" %}
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
{% from "_macros.html" import icon, brand %}
|
||||
{% from "_macros.html" import icon, brand, model_avatar %}
|
||||
{#
|
||||
Sidebar: brand, new chat, the folder tree, then unfiled chats.
|
||||
Sidebar: brand, new chat, pinned models, the folder tree, then unfiled chats.
|
||||
|
||||
Folders render recursively through _folder.html. Chats appear under their
|
||||
folder, and any chat without one falls to the flat list at the bottom.
|
||||
"New chat" is a link, not a button that creates a row. The chat is written
|
||||
when the first message is sent, so opening one and walking away leaves
|
||||
nothing behind.
|
||||
#}
|
||||
<aside class="sidebar" id="sidebar" x-data="{ }">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar__header">
|
||||
{{ brand(uid="side") }}
|
||||
</div>
|
||||
|
||||
{# Buttons for actions the user cannot perform are omitted rather than
|
||||
disabled: a greyed-out control invites a support question, an absent one
|
||||
does not. The routes enforce the same permissions regardless. #}
|
||||
{% if can.get("chat.create") or can.get("folder.manage") %}
|
||||
<div class="sidebar__actions">
|
||||
{% if can.get("chat.create") %}
|
||||
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
|
||||
<a class="btn btn--primary btn--grow" href="/chat">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can.get("folder.manage") %}
|
||||
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
|
||||
@@ -30,6 +28,20 @@
|
||||
{% endif %}
|
||||
|
||||
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
|
||||
{% if pinned_models and can.get("chat.create") %}
|
||||
{# Shortcuts to start a chat with a particular model. These link rather than
|
||||
post, so no chat exists until something is actually said. #}
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Pinned models</div>
|
||||
{% for model in pinned_models %}
|
||||
<a class="nav-item nav-item--model" href="/chat?model={{ model.model_id }}">
|
||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||
<span class="nav-item__label">{{ model.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if folders %}
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Folders</div>
|
||||
@@ -52,14 +64,13 @@
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<div class="row row--between">
|
||||
<a class="nav-item" href="/settings" style="flex: 1">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ user.name }}</span>
|
||||
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
|
||||
<a class="nav-item" href="/settings">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ user.name }}</span>
|
||||
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
||||
</a>
|
||||
|
||||
<div class="sidebar__tools">
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
||||
{{ icon("shield") }}
|
||||
@@ -70,7 +81,7 @@
|
||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||
</button>
|
||||
<span class="topbar__spacer"></span>
|
||||
<span class="spacer"></span>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
|
||||
{{ icon("logout") }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
|
||||
{% block title %}Your settings - LLeMbas{% endblock %}
|
||||
|
||||
@@ -19,135 +19,198 @@
|
||||
<h1 class="topbar__title">Your settings</h1>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if saved %}
|
||||
<div class="alert alert--success" role="status">
|
||||
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{#
|
||||
Tabs are radio inputs plus sibling selectors: no JavaScript, and the
|
||||
chosen tab survives a re-render because the browser keeps the checked
|
||||
state. Each panel is a real fragment of the page, not a fetch.
|
||||
#}
|
||||
<div class="tabs">
|
||||
<div class="tabs__bar" role="tablist">
|
||||
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
|
||||
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<div class="field">
|
||||
<span class="field__label">Name</span>
|
||||
<p>{{ user.name }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Email</span>
|
||||
<p class="mono text-sm">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Role</span>
|
||||
<p>
|
||||
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-models">
|
||||
<label class="tabs__tab" for="tab-models">{{ icon("sliders", "icon--sm") }} Models</label>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Default model</h2>
|
||||
{% if models %}
|
||||
<form method="post" action="/api/preferences/default-model">
|
||||
<div class="field">
|
||||
<select class="select" name="model_id" aria-label="Default model">
|
||||
<option value="">Use the instance default</option>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
||||
{{ model.label }}{% if model.pinned %} — pinned{% endif %}
|
||||
</option>
|
||||
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance">
|
||||
<label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label>
|
||||
|
||||
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
|
||||
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
|
||||
</div>
|
||||
|
||||
<div class="tabs__body">
|
||||
<div class="page">
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if saved %}
|
||||
<div class="alert alert--success" role="status">
|
||||
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Account --- #}
|
||||
<section class="tabs__panel" data-tab="tab-account">
|
||||
<div class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<dl class="detail-list">
|
||||
<dt>Name</dt><dd>{{ user.name }}</dd>
|
||||
<dt>Email</dt><dd class="mono">{{ user.email }}</dd>
|
||||
<dt>Role</dt>
|
||||
<dd><span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span></dd>
|
||||
<dt>Groups</dt>
|
||||
<dd>
|
||||
{% if user.groups %}
|
||||
{% for group in user.groups %}<span class="badge">{{ group.name }}</span> {% endfor %}
|
||||
{% else %}
|
||||
<span class="faint">None</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card__title">What you can do</h2>
|
||||
<p class="card__lede">
|
||||
{% if user.is_admin %}
|
||||
You are an administrator, so every permission applies.
|
||||
{% else %}
|
||||
From the instance baseline plus whatever your groups add.
|
||||
{% endif %}
|
||||
</p>
|
||||
<ul class="perm-list">
|
||||
{% for key, granted in can.items() %}
|
||||
<li class="perm-list__item">
|
||||
<span class="perm-list__state {{ 'is-on' if granted }}">
|
||||
{{ icon("check" if granted else "x", "icon--sm") }}
|
||||
</span>
|
||||
<code>{{ key }}</code>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">What a new chat starts with. Existing chats keep their model.</p>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted text-sm">No models are available to you yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Permissions</h2>
|
||||
<p class="text-sm muted" style="margin-bottom: var(--sp-3)">
|
||||
{% if user.is_admin %}
|
||||
You are an administrator, so every permission applies.
|
||||
{% else %}
|
||||
What this account may do, from the instance baseline plus your groups.
|
||||
{# --- Models --- #}
|
||||
<section class="tabs__panel" data-tab="tab-models">
|
||||
<div class="card">
|
||||
<h2 class="card__title">Default model</h2>
|
||||
<p class="card__lede">
|
||||
What a new chat starts with. Existing chats keep their model.
|
||||
</p>
|
||||
{% if models %}
|
||||
<form method="post" action="/api/preferences/default-model">
|
||||
<div class="field">
|
||||
<select class="select" name="model_id" aria-label="Default model">
|
||||
<option value="">Use the instance default</option>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted text-sm">No models are available to you yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if models %}
|
||||
<div class="card">
|
||||
<h2 class="card__title">Available to you</h2>
|
||||
<p class="card__lede">In the order an administrator arranged them.</p>
|
||||
<ul class="model-list">
|
||||
{% for model in models %}
|
||||
<li class="model-list__item">
|
||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||
<div style="min-width: 0">
|
||||
<strong>{{ model.label }}</strong>
|
||||
{% if model.description %}
|
||||
<div class="text-xs faint">{{ model.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
{% for key, granted in can.items() %}
|
||||
<span class="badge {{ 'badge--success' if granted }}">
|
||||
{{ key }}{{ '' if granted else ' — no' }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if user.groups %}
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Groups: {% for group in user.groups %}{{ group.name }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Appearance</h2>
|
||||
<div class="row" style="gap: var(--sp-3)">
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||
{{ icon("moon", "icon--sm") }} Moria
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||
{{ icon("sun", "icon--sm") }} Shire
|
||||
</button>
|
||||
</div>
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Moria is the dark theme, Shire the light one. Your choice is saved
|
||||
to this browser and to your account.
|
||||
</p>
|
||||
</section>
|
||||
{# --- Appearance --- #}
|
||||
<section class="tabs__panel" data-tab="tab-appearance">
|
||||
<div class="card">
|
||||
<h2 class="card__title">Theme</h2>
|
||||
<p class="card__lede">
|
||||
Saved to this browser and to your account, so it follows you.
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||
{{ icon("moon", "icon--sm") }} Moria — dark
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||
{{ icon("sun", "icon--sm") }} Shire — light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Change password</h2>
|
||||
<form method="post" action="/api/preferences/password">
|
||||
<div class="field">
|
||||
<label class="field__label" for="current-password">Current password</label>
|
||||
<input class="input" type="password" id="current-password"
|
||||
name="current_password" required autocomplete="current-password">
|
||||
{# --- Security --- #}
|
||||
<section class="tabs__panel" data-tab="tab-security">
|
||||
<div class="card">
|
||||
<h2 class="card__title">Change password</h2>
|
||||
<p class="card__lede">
|
||||
Every other session is signed out when the password changes.
|
||||
You stay signed in here.
|
||||
</p>
|
||||
<form method="post" action="/api/preferences/password">
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="current-password">Current password</label>
|
||||
<input class="input" type="password" id="current-password"
|
||||
name="current_password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field"></div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">New password</label>
|
||||
<input class="input" type="password" id="new-password" name="new_password"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm-password">Confirm new password</label>
|
||||
<input class="input" type="password" id="confirm-password"
|
||||
name="confirm_password" required minlength="8"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">New password</label>
|
||||
<input class="input" type="password" id="new-password" name="new_password"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm-password">Confirm new password</label>
|
||||
<input class="input" type="password" id="confirm-password"
|
||||
name="confirm_password" required minlength="8"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit">Change password</button>
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Every other session is signed out when the password changes. You
|
||||
stay signed in here.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Session</h2>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--danger" type="submit">
|
||||
{{ icon("logout", "icon--sm") }} Sign out
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<div class="card">
|
||||
<h2 class="card__title">Session</h2>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--danger" type="submit">
|
||||
{{ icon("logout", "icon--sm") }} Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user