Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions changelog.d/auth-users-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
### Changed
- Admin navigation groups auth collections under Users and ordinary collections under Data collections. Account links preserve the selected auth collection, including legacy record URLs, and collection settings link directly to account management.

### Features
- User create/edit forms now support schema-defined profile fields, validation, JSON and rich text editors, relations, multiple selections, and file uploads alongside credentials.

### Fixes
- Clearing a single select or relation during a multipart record update now clears the field instead of validating an empty option or target ID. A single select that declares `""` as an option keeps it as a value and is cleared with `null`.
- Admin account password validation uses UTF-8 byte length, matching the server policy for non-ASCII passwords.
- Admin account forms accept internationalized email addresses and keep Manage users links on the stored collection until a rename is saved.
- Float fields normalize consistently across JSON and multipart saves, including finite JSON numeric tokens beyond the integer range, with the same bounds and nonfinite-value validation.
- File changes preserve untouched nullable text, date, editor, URL, and email profile fields while keeping intentional empty-string clears.
- Large finite numeric min/max schema options survive API parsing and schema JSON round trips; string and nonfinite bounds remain invalid.
- Valid custom field names such as constructor, valueOf, and toString use their own values in forms, validation messages, JSON payloads, and file-removal state.
- Account credentials and profile controls have associated labels, including JSON, rich text, relations, and file actions, so assistive technology can identify each field.
- Focusing and leaving unchanged rich text preserves null values; deliberate clears still save. Account creation also honors an explicitly configured zero password minimum.
- Account edits submit only changed fields, preserving concurrent profile updates and allowing credential resets on legacy rows with missing required profile values. Blank usernames on creation remain null.
- Users reuses the sidebar collection metadata; one guarded refresh owns loading and errors, and failed lookups do not block later collection routes. Record drawers load their current collection definition when opened, including fields changed in another tab or through the API.
- Rich-text blur persists cleanup of unsafe stored HTML, and authentication settings preserve a zero password minimum when saved and reopened.
- Base-collection record drawers retain ordinary email, username, verified and token_epoch fields, including the email inputs in built-in mail collections.
- Authentication settings validate an integer password minimum from 0 through 255; invalid collection API values return a field error instead of silently falling back.
- Account drawers also refresh their collection definition on open, honoring required profile fields and password policy changed while Users remains open.
32 changes: 27 additions & 5 deletions docs/framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -2408,11 +2408,33 @@ realtime health, below), **Features** (flags & experiments, below), and
**Settings** (raw KV, above). It ships as browser-native ES modules — no build
step — and every asset is served with a CRC32 `ETag`.

**Users** (`/_/#/users`) manages both superusers and auth-collection users: list
and search (filter-based) either, create/edit/delete records, an admin password
reset (superuser `PATCH`), and a read-only OAuth-providers panel per auth
collection. It composes the existing collections/records/auth REST API — no new
endpoints ship for it. Per-device sessions are not yet exposed here.
The sidebar groups ordinary collections under **Data collections** and auth
collections under **Users**, using each collection's type rather than its name.
**Collection settings** lists every collection definition and offers collection
creation, fields, API rules, and authentication settings.

**Users** (`/_/#/users/<collection>`) manages both superusers and auth-collection
accounts. Selecting an auth collection opens its account list, and the collection
stays selected across reloads and browser history. Existing
`/_/#/collections/<collection>/records` links for auth collections redirect to
the same collection's Users page. The unscoped `/_/#/users` link chooses the
`users` auth collection when present, otherwise the first auth collection.

Create/edit forms include email, username, password, and custom schema fields,
including required profile fields, JSON, rich text, relations, multiple
selections, and file uploads. Creation requires a password meeting the
collection's minimum length, as required by the records REST API; leaving the
password blank during editing preserves it. An entered password performs an
admin reset (superuser `PATCH`). Editing submits only changed profile fields
and credentials, plus file uploads/removals, preserving unrelated concurrent
updates. A blank username on creation is omitted, matching API-created accounts.
Server-managed credential and system fields remain protected. Unchecked boolean profile fields save as false, and relation
editors retain existing selections beyond the initially loaded target list.
Users links to its collection's settings, and auth collection
settings links back with **Manage users →**. Search matches email or username,
and a read-only OAuth-providers panel shows the collection's providers.
It composes the existing collections/records/auth REST API — no new endpoints
ship for it. Per-device sessions are not yet exposed here.

**Files** (`/_/#/files`) covers per-collection file-field browsing: a read-only
storage-backend strip (local disk vs S3, from the new superuser `GET
Expand Down
96 changes: 82 additions & 14 deletions src/admin/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { html, render, useState, useEffect } from '/_/assets/preact.js';
import { html, render, useState, useEffect, useRef, useCallback } from '/_/assets/preact.js';
import { API } from '/_/assets/lib/api.js';
import { go } from '/_/assets/lib/ui.js';
import { RecordsTable, SchemaEditor } from '/_/assets/views/collections.js';
Expand All @@ -25,7 +25,7 @@ function parseRoute(hash) {
if (seg[0] === 'login') return { name: 'login' };
if (seg[0] === 'settings') return { name: 'settings' };
if (seg[0] === 'features') return { name: 'features' };
if (seg[0] === 'users') return { name: 'users' };
if (seg[0] === 'users') return { name: 'users', col: seg[1] ? decodeURIComponent(seg[1]) : '' };
if (seg[0] === 'email') return { name: 'email' };
if (seg[0] === 'files') return { name: 'files' };
if (seg[0] === 'logs') return { name: 'logs' };
Expand Down Expand Up @@ -120,14 +120,48 @@ function App() {
function Shell({ route }) {
const [cols, setCols] = useState(null);
const [err, setErr] = useState('');
const collectionRequest = useRef({ id: 0, pending: false });
const missingCollections = useRef(new Set());
const failedLookup = useRef(null);
const currentCol = useRef(route.col);
currentCol.current = route.col;
const [collectionsLoading, setCollectionsLoading] = useState(true);
const refreshCollections = useCallback(async missingName => {
const request = ++collectionRequest.current.id;
collectionRequest.current.pending = true;
setErr('');
setCollectionsLoading(true);
try {
const cs = await API.collections();
if (request !== collectionRequest.current.id) return;
if (missingName) missingCollections.current.add(missingName);
setCols(cs);
} catch (x) {
if (request !== collectionRequest.current.id) return;
if (missingName) {
// The admin has moved on; a late failure belongs to the route it was for.
if (currentCol.current !== missingName) return;
failedLookup.current = missingName;
}
setErr(x.data?.message || 'Failed to load collections');
} finally {
if (request === collectionRequest.current.id) {
collectionRequest.current.pending = false;
setCollectionsLoading(false);
}
}
}, []);
// Mount supplies the initial list; later Users entries refresh it once.
useEffect(() => {
if (cols == null || route.name === 'users') refreshCollections();
}, [route.name]);
const [backend, setBackend] = useState('');
const [collapsed, setCollapsed] = useState(localStorage.getItem('zb_sidebar') === '1');
// `/api/analytics/events` is comptime-gated (R2-3 lean build) — unmounted (404) unless the
// app configures `.analytics`. Probe it once so the Logs tab (and its deep-link) only ever
// appear when the feature actually exists, rather than showing a tab that 404s.
const [analyticsEnabled, setAnalyticsEnabled] = useState(false);
useEffect(() => {
API.collections().then(setCols).catch(x => setErr((x.data && x.data.message) || 'Failed to load collections'));
// Read-only backend badge; degrade silently if /health is unreachable.
API.health().then(h => setBackend(h && h.backend ? h.backend : '')).catch(() => {});
let active = true;
Expand All @@ -137,36 +171,70 @@ function Shell({ route }) {
function toggle() { const v = !collapsed; setCollapsed(v); localStorage.setItem('zb_sidebar', v ? '1' : '0'); }
async function logout() { try { await API.logout(); } catch (_) {} go('#/login'); }

// A failed lookup waits for the next navigation rather than retrying in a loop.
// Its error belongs to that route; a failed workspace refresh keeps showing.
useEffect(() => {
if (failedLookup.current == null) return;
failedLookup.current = null;
setErr('');
}, [route.name, route.col]);
// Cache completed lookups per missing name. Requests stay useful across route
// changes; only a newer refresh can supersede their result.
useEffect(() => {
if (collectionsLoading || collectionRequest.current.pending || !route.col || (route.name === 'schema' && route.col === '__new__') ||
cols?.some(c => c.name === route.col) || missingCollections.current.has(route.col) || failedLookup.current === route.col) return;
refreshCollections(route.col);
}, [route.name, route.col, cols, collectionsLoading, refreshCollections]);

const activeCol = route.col;
const metadataError = route.name === 'records' && cols?.some(c => c.name === route.col) ? '' : err;
const legacyAuth = route.name === 'records' && cols?.find(c => c.name === route.col && c.type === 'auth');
useEffect(() => {
if (legacyAuth) location.replace('#/users/' + encodeURIComponent(legacyAuth.name));
}, [legacyAuth?.name]);
function collectionLinks(type) {
const collections = (cols || []).filter(c => type === 'auth' ? c.type === 'auth' : c.type !== 'auth');
return collections.map(c => html`<a key=${c.id} class=${'navitem' + (c.name === activeCol ? ' active' : '')} data-test=${'nav-' + c.name}
title=${c.name} aria-label=${c.name} href=${type === 'auth' ? '#/users/' + encodeURIComponent(c.name) : '#/collections/' + encodeURIComponent(c.name) + '/records'}>
<span class="hide-collapsed">${c.name}</span>${collapsed ? c.name[0] : ''}</a>`);
}
return html`
<div class="shell">
<div class=${'sidebar' + (collapsed ? ' collapsed' : '')}>
<div class="brand"><span class="hide-collapsed">zigbase</span>${backend ? html`<span class="badge hide-collapsed" data-test="backend-badge" title="Active database backend">${backend === 'postgres' ? 'Postgres' : 'SQLite'}</span>` : ''}<button class="ghost" data-test="sidebar-toggle" onClick=${toggle}>${collapsed ? '»' : '«'}</button></div>
<div class="hide-collapsed muted" style="font-size:11px;text-transform:uppercase">Collections</div>
${cols == null ? html`<div class="muted">…</div>` :
cols.map(c => html`<a key=${c.id} class=${'navitem' + (c.name === activeCol ? ' active' : '')} data-test=${'nav-' + c.name} href=${'#/collections/' + encodeURIComponent(c.name) + '/records'}>
<span class="hide-collapsed">${c.name} ${c.type !== 'base' ? html`<span class="badge">(${c.type})</span>` : ''}</span>${collapsed ? c.name[0] : ''}</a>`)}
${err && html`<div class="error">${err}</div>`}
<div data-test="nav-data-group">
<div class="hide-collapsed muted" style="font-size:11px;text-transform:uppercase">Data collections</div>
${cols == null ? html`<div class="muted">…</div>` : collectionLinks('base')}
${cols && !cols.some(c => c.type !== 'auth') && html`<div class="hide-collapsed muted">No data collections</div>`}
</div>
<div data-test="nav-users-group">
<a class="navitem" href="#/users" data-test="nav-users-home" title="Users" aria-label="Users"><span class="hide-collapsed">Users</span>${collapsed ? '👤' : ''}</a>
${collectionLinks('auth')}
</div>
${metadataError && html`<div class="error">${metadataError}</div>`}
<div class="spacer"></div>
<a class=${'navitem hide-collapsed' + (route.name === 'users' ? ' active' : '')} href="#/users" data-test="nav-users">👤 Users</a>
<a class=${'navitem hide-collapsed' + (route.name === 'email' ? ' active' : '')} href="#/email" data-test="nav-email">📧 Email</a>
<a class=${'navitem hide-collapsed' + (route.name === 'files' ? ' active' : '')} href="#/files" data-test="nav-files">📁 Files</a>
${analyticsEnabled && html`<a class=${'navitem hide-collapsed' + (route.name === 'logs' ? ' active' : '')} href="#/logs" data-test="nav-logs">📊 Logs</a>`}
<a class=${'navitem hide-collapsed' + (route.name === 'features' ? ' active' : '')} href="#/features" data-test="nav-features">🚩 Features</a>
<a class=${'navitem hide-collapsed' + (route.name === 'settings' ? ' active' : '')} href="#/settings" data-test="nav-settings">⚙ Settings</a>
<a class="navitem hide-collapsed" href="#/collections" data-test="nav-collections">⚙ Collections</a>
<a class="navitem" href="#/collections" data-test="nav-collections" title="Collection settings" aria-label="Collection settings">⚙ <span class="hide-collapsed">Collection settings</span></a>
<a class="navitem" data-test="logout" onClick=${logout} style="cursor:pointer">⎋ <span class="hide-collapsed">Logout</span></a>
</div>
<div class="main">
${route.name === 'users' ? html`<${UsersView}/>`
${route.name === 'users' ? html`<${UsersView} key=${route.col} collection=${route.col} collections=${collectionsLoading ? null : cols} collectionsError=${err}/>`
: route.name === 'email' ? html`<${EmailView}/>`
: route.name === 'files' ? html`<${FilesView}/>`
: route.name === 'logs' && analyticsEnabled ? html`<${LogsView}/>`
: route.name === 'features' ? html`<${FeaturesView}/>`
: route.name === 'settings' ? html`<${SettingsView}/>`
: route.name === 'records' ? html`<${RecordsTable} col=${route.col}/>`
: route.name === 'schema' ? html`<${SchemaEditor} name=${route.col}/>`
: html`<div data-test="collections-home"><h2>Collections</h2><button data-test="new-collection" onClick=${() => go('#/collections/__new__')}>+ New collection</button></div>`}
: route.name === 'records' ? ((cols == null && !err) || legacyAuth ? html`<div class="muted">…</div>` : html`<${RecordsTable} key=${route.col} col=${route.col} collection=${cols?.find(c => c.name === route.col)} metadataError=${metadataError}/>` )
: route.name === 'schema' ? html`<${SchemaEditor} key=${route.col} name=${route.col}/>`
: html`<div data-test="collections-home"><h2>Collection settings</h2><button data-test="new-collection" onClick=${() => go('#/collections/__new__')}>+ New collection</button>
${(cols || []).map(c => html`<div class="row" key=${c.id} style="gap:12px;margin-top:12px">
<a data-test=${'settings-' + c.name} href=${'#/collections/' + encodeURIComponent(c.name)}>${c.name} <span class="badge">${c.type}</span></a>
${c.type === 'auth' && html`<a href=${'#/users/' + encodeURIComponent(c.name)}>Manage users →</a>`}
</div>`)}</div>`}
</div>
</div>`;
}
Expand Down
4 changes: 2 additions & 2 deletions src/admin/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export const API = {
logout: () => api('POST', '/collections/_superusers/auth-logout'),
collections: () => api('GET', '/collections').then(r => r.items),
records: (col, q) => api('GET', `/collections/${encodeURIComponent(col)}/records?${q}`),
createUser: (col, body) => api('POST', `/collections/${encodeURIComponent(col)}/records`, body),
updateUser: (col, id, body) => api('PATCH', `/collections/${encodeURIComponent(col)}/records/${encodeURIComponent(id)}`, body),
createUser: (col, body, isForm = false) => api('POST', `/collections/${encodeURIComponent(col)}/records`, body, isForm),
updateUser: (col, id, body, isForm = false) => api('PATCH', `/collections/${encodeURIComponent(col)}/records/${encodeURIComponent(id)}`, body, isForm),
deleteUser: (col, id) => api('DELETE', `/collections/${encodeURIComponent(col)}/records/${encodeURIComponent(id)}`),
oauthProviders: (col) => api('GET', `/collections/${encodeURIComponent(col)}/auth/oauth2/providers`),
createCollection: (payload) => api('POST', '/collections', payload),
Expand Down
Loading
Loading