feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account control, database-backed roles and permissions, and signing out across the applications that share this sign-in. The account control replaces a separate identity chip and a separate sign-out button, which meant the header carried two controls describing one fact. web/account.js is mounted into every page by a placeholder and owns the keyboard handling, the outside-click behaviour and the sign-out form once. It also fills the trigger's name, which the previous markup left empty. Signing out revokes the hosted-login bearer sessions this installation issued, then redirects to the provider's end_session_endpoint when the discovery document advertises one. Applications that authenticate against this installation therefore lose their session too; one pointed straight at an identity provider is out of its reach and is unchanged. Roles and permissions become rows rather than a closed enum, so a tenant can extend the catalog without a migration, and a role can no longer be granted a capability that is not in it. requireSameOrigin now compares against the origin the request arrived on instead of the configured base URL, which defaulted to http://localhost:8080 and so rejected every write in a deployment that never set it. Two bugs found while verifying in a browser: - A profile picture never rendered. avatarURL built an absolute URL from the configured base URL, and the page's own img-src 'self' blocked it. The console's own pages now take a root-relative path; only /connect/userinfo, read from another origin, needs an absolute one, and it follows the request. - absoluteURL preferred the configuration over the request while its comment claimed the opposite, which also misdirected the logout redirect. Card layout: the footer was one two-column row shared between a label and a forty-character identifier, so the identifier took the width and crushed its label into three stacked words. Each pair is a line now, the value truncates with the full text in the title, and cards align their footers across a row.
This commit is contained in:
parent
3fdc8cb489
commit
c7c81fb65c
43 changed files with 2248 additions and 298 deletions
132
web/account.js
Normal file
132
web/account.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// The header account control.
|
||||
//
|
||||
// One component, mounted into every page by the placeholder below it, so the
|
||||
// identity and the two things a person can do about their own account live
|
||||
// together. It replaced a separate sign-out button, which meant the header showed
|
||||
// two controls describing one fact.
|
||||
//
|
||||
// The menu is rendered here rather than duplicated into each page's markup so that
|
||||
// the keyboard handling, the outside-click behaviour and the sign-out form exist
|
||||
// once. The trade is one small request for the profile, which the pages that show a
|
||||
// tenant context were making anyway.
|
||||
//
|
||||
// The sign-out item is a real form posting to /auth/logout rather than a fetch, so
|
||||
// signing out works without JavaScript and cannot be triggered by a stray
|
||||
// cross-site request: the endpoint only accepts POST.
|
||||
//
|
||||
// Everything is wrapped in an IIFE and exposed only as window.mountAccountMenu.
|
||||
// These are classic scripts sharing one global scope, so a top-level const here
|
||||
// would collide with the escapeHTML and initials that each page already declares,
|
||||
// and the collision is a SyntaxError that stops the whole script before it runs.
|
||||
|
||||
(function () {
|
||||
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[character]));
|
||||
|
||||
const initials = name => String(name || '?')
|
||||
.split(/[\s@._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(part => part[0].toUpperCase())
|
||||
.join('') || '?';
|
||||
|
||||
async function loadProfile() {
|
||||
const response = await fetch('/api/v1/profile', {
|
||||
credentials: 'same-origin',
|
||||
headers: {accept: 'application/json'}
|
||||
});
|
||||
if (response.status === 401) {
|
||||
// Signed out: the page itself will redirect, so there is nothing to show.
|
||||
return null
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Mounts the control into a placeholder element. Exported on window because the
|
||||
// pages load their own script and there is no module loader here; the alternative
|
||||
// would be repeating this markup and this wiring in every page.
|
||||
function mount(host) {
|
||||
if (!host || host.dataset.mounted === 'true') return;
|
||||
host.dataset.mounted = 'true';
|
||||
host.classList.add('account');
|
||||
host.innerHTML = `
|
||||
<button type="button" class="account-trigger" aria-haspopup="true" aria-expanded="false" hidden>
|
||||
<span class="account-avatar-slot"></span>
|
||||
<span class="account-name"></span>
|
||||
<span class="account-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div class="account-menu" role="menu" hidden>
|
||||
<div class="account-identity">
|
||||
<strong class="account-display-name"></strong>
|
||||
<span class="account-email"></span>
|
||||
</div>
|
||||
<a class="account-profile" role="menuitem" href="/profile">Edit profile</a>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button type="submit" role="menuitem">Log out</button>
|
||||
</form>
|
||||
<p class="account-note" hidden>Signing out here also ends your sessions in the applications that use this sign-in.</p>
|
||||
</div>`;
|
||||
|
||||
const trigger = host.querySelector('.account-trigger');
|
||||
const menu = host.querySelector('.account-menu');
|
||||
const note = host.querySelector('.account-note');
|
||||
|
||||
const setOpen = open => {
|
||||
menu.hidden = !open;
|
||||
trigger.setAttribute('aria-expanded', String(open));
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
setOpen(menu.hidden)
|
||||
});
|
||||
|
||||
// Escape closes and returns focus, which is the one keyboard path a menu has to
|
||||
// get right: without it the menu is a trap for anyone not using a mouse.
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || menu.hidden) return;
|
||||
setOpen(false);
|
||||
trigger.focus()
|
||||
});
|
||||
|
||||
// A click anywhere else dismisses it. Capture phase, so a click that also
|
||||
// dismisses a dialog underneath does not race this.
|
||||
document.addEventListener('click', () => setOpen(false), true);
|
||||
|
||||
loadProfile().then(profile => {
|
||||
if (!profile) {
|
||||
// No session: leave the control hidden rather than showing an empty name.
|
||||
return
|
||||
}
|
||||
const name = profile.display_name || profile.email || '';
|
||||
// The trigger and the menu both carry the name. Filling only the menu
|
||||
// leaves the header showing a picture and a caret with no name beside
|
||||
// them, which is the one thing the control exists to say.
|
||||
host.querySelector('.account-name').textContent = name;
|
||||
host.querySelector('.account-display-name').textContent = name;
|
||||
host.querySelector('.account-email').textContent = profile.email || '';
|
||||
const slot = host.querySelector('.account-avatar-slot');
|
||||
slot.innerHTML = profile.avatar_url
|
||||
? `<img class="avatar" src="${escapeHTML(profile.avatar_url)}" alt="" width="24" height="24">`
|
||||
: `<span class="avatar avatar-empty">${escapeHTML(initials(name))}</span>`;
|
||||
trigger.hidden = false;
|
||||
|
||||
// Only explain the cross-application effect when it is true. A person with
|
||||
// no hosted-login sessions has nothing to sign out of elsewhere.
|
||||
const usesHostedLogin = (profile.memberships || []).length > 0;
|
||||
note.hidden = !usesHostedLogin
|
||||
})
|
||||
};
|
||||
|
||||
window.mountAccountMenu = mount
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-account-menu]').forEach(mount)
|
||||
})
|
||||
})()
|
||||
52
web/app.js
52
web/app.js
|
|
@ -46,7 +46,6 @@ async function load() {
|
|||
Object.assign(state, data);
|
||||
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
|
||||
await loadRoles();
|
||||
renderIdentity();
|
||||
render();
|
||||
}
|
||||
|
||||
|
|
@ -67,18 +66,6 @@ async function loadRoles() {
|
|||
}
|
||||
}
|
||||
|
||||
function renderIdentity() {
|
||||
const profile = state.profile || {};
|
||||
$('#identity-name').textContent = profile.display_name || profile.email || '';
|
||||
const image = $('#identity-avatar');
|
||||
if (profile.avatar_url) {
|
||||
image.src = profile.avatar_url;
|
||||
image.hidden = false
|
||||
} else {
|
||||
image.hidden = true;
|
||||
image.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
|
||||
// assignableRoles is what the invite and access dialogs may offer: the server
|
||||
// refuses to hand out a role more powerful than the caller's own, so offering
|
||||
|
|
@ -111,12 +98,12 @@ function render() {
|
|||
$('#user-count').textContent = tenantUsers.length + state.pending_users.length;
|
||||
$('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
|
||||
$('#new-tenant').hidden = !state.is_system_administrator;
|
||||
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet');
|
||||
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageApplications ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
|
||||
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageKeys && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
|
||||
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><div class="meta">${metaRow('Slug', item.slug)}${metaRow('Identifier', item.id.slice(0, 8), item.id)}</div></article>`).join('') || empty('No tenants yet');
|
||||
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta">${metaRow('Client login', item.client_ready ? 'Ready' : 'Not configured')}${item.client_id ? metaRow('Client ID', item.client_id, item.client_id) : ''}</div>${canManageApplications ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
|
||||
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td><div class="table-actions">${canManageKeys && item.status === 'active' ? `<button class="table-action danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</div></td></tr>`).join('') || rowEmpty(6, 'No keys issued');
|
||||
$('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported');
|
||||
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : '';
|
||||
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td>${avatarCell(item)}<strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(roleLabel(item.role_id) || item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageUsers ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users');
|
||||
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><div class="table-actions"><button class="table-action" data-pending="${escapeHTML(item.id)}">Approve</button></div></td></tr>`).join('') : '';
|
||||
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td>${avatarCell(item)}<strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(roleLabel(item.role_id) || item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td><div class="table-actions">${canManageUsers ? `<button class="table-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</div></td></tr>`).join('') + pending || rowEmpty(6, 'No users');
|
||||
renderRoleOptions();
|
||||
$$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
|
||||
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
|
||||
|
|
@ -144,6 +131,12 @@ function renderRoleOptions() {
|
|||
}
|
||||
|
||||
const empty = text => `<article class="card"><p>${text}</p></article>`;
|
||||
// One labelled pair in a card's footer. The value is the half that can be long --
|
||||
// a client identifier runs to forty-odd characters -- so it is the half allowed to
|
||||
// shrink and be truncated, with the whole value kept in the title for anyone who
|
||||
// needs to copy it.
|
||||
const metaRow = (label, value, title = value) =>
|
||||
`<div><span>${escapeHTML(label)}</span><b title="${escapeHTML(title)}">${escapeHTML(value)}</b></div>`;
|
||||
const initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?';
|
||||
const avatarCell = item => item.avatar_url
|
||||
? `<img class="avatar" src="${escapeHTML(item.avatar_url)}" alt="" width="28" height="28" loading="lazy">`
|
||||
|
|
@ -292,19 +285,16 @@ function showSecret(title, description, secret) {
|
|||
$('#secret-dialog').showModal()
|
||||
}
|
||||
|
||||
const theme = $('#theme'), mode = $('#mode');
|
||||
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
|
||||
theme.addEventListener('change', () => {
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
localStorage.setItem('gl-theme', theme.value)
|
||||
});
|
||||
mode.addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.mode = next;
|
||||
localStorage.setItem('gl-mode', next)
|
||||
});
|
||||
// The palette and appearance controls are the components shipped by the theme
|
||||
// service, which own data-theme and data-mode on <html>. This file used to drive a
|
||||
// <select> and a button itself; that duplicated the same two attributes in a second
|
||||
// place, and whichever loaded last won.
|
||||
//
|
||||
// The storage key changes: the components persist under go-bananas:appearance rather
|
||||
// than gl-theme / gl-mode. A user's previous choice is therefore not carried over on
|
||||
// first load, and the page starts from the attributes already on <html> — the theme
|
||||
// these pages ship with. Migrating the old keys would be a nicety, not a
|
||||
// correctness fix, so it is not done here.
|
||||
$('#tenant-context').addEventListener('change', async event => {
|
||||
state.selectedTenant = event.target.value;
|
||||
await loadRoles();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
<meta name="color-scheme" content="dark light">
|
||||
<title>Go Loose · API access control</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<!-- The shared styling contract. Every palette, both appearances, in one
|
||||
stylesheet, so switching is a change to an attribute rather than a request.
|
||||
The origin is substituted by the server from GO_LOOSE_THEME_BASE_URL; when it
|
||||
is unset the link is simply absent and this application's own theme blocks
|
||||
take over unchanged. -->
|
||||
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
|
||||
</head>
|
||||
<body>
|
||||
<div class="noise"></div>
|
||||
|
|
@ -15,12 +21,13 @@
|
|||
<a href="/docs">API docs</a>
|
||||
<a href="/roles">Roles</a>
|
||||
<label class="compact">Tenant<select id="tenant-context"></select></label>
|
||||
<label class="compact">Theme
|
||||
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
|
||||
</label>
|
||||
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
|
||||
<a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
|
||||
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
|
||||
<!-- The controls, injected rather than hand-written. They live in a shadow root
|
||||
so this page's own CSS cannot reach them, and they read and write the same
|
||||
data-theme and data-mode attributes the stylesheet blocks above key on, so
|
||||
the values are already the ones this application was storing. -->
|
||||
<bananas-theme-selector id="theme"></bananas-theme-selector>
|
||||
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
|
||||
<div data-account-menu></div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
@ -92,6 +99,12 @@
|
|||
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role_id" id="access-role" required></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
|
||||
<dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog>
|
||||
|
||||
<script src="/assets/account.js" defer></script>
|
||||
<script src="/assets/app.js" defer></script>
|
||||
<!-- Loaded from the theme service rather than vendored, so a change to the controls
|
||||
needs no rebuild here. A module import is a CORS request, so this deployment's
|
||||
origin must be in the service's CORS_ALLOWED_ORIGINS; a same-origin
|
||||
deployment needs nothing configured. -->
|
||||
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,16 +8,17 @@
|
|||
<div class="terminal-body">
|
||||
<p class="eyebrow">FIRST-RUN INSTALLER</p>
|
||||
<h1>Initialize<br><span>Go Loose.</span></h1>
|
||||
<p class="muted">Configure Google identity and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.</p>
|
||||
<p class="muted">Configure OIDC identity provider and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.</p>
|
||||
<form action="/install" method="post" class="installer-form">
|
||||
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
|
||||
<label>Google OAuth client ID<input name="oidc_client_id" value="{{GOOGLE_CLIENT_ID}}" required></label>
|
||||
<label>Google OAuth client secret<input type="password" name="oidc_client_secret" autocomplete="new-password" required></label>
|
||||
<label>Authorized callback URL<input value="{{CALLBACK_URL}}" readonly></label>
|
||||
<label>OIDC Issuer<input name="field1" value="https://accounts.google.com" required placeholder="https://accounts.google.com"></label>
|
||||
<label>Client ID<input name="field2" autocomplete="new-password" required></label>
|
||||
<label>Client Secret<input type="password" name="field3" autocomplete="new-password" required></label>
|
||||
<label>Redirect URL<input name="field4" value="{{CALLBACK_URL}}" required></label>
|
||||
<label class="seed-option"><input type="checkbox" name="seed_demo" checked><span>Seed NMBS and YPTO Guess demo data</span></label>
|
||||
<button type="submit">INSTALL GO LOOSE</button>
|
||||
</form>
|
||||
<p class="muted">In Google Cloud Console, add the callback URL above under Authorized redirect URIs. After installation you will continue to Google, then return to Go Loose with a secure signed session.</p>
|
||||
<p class="muted">In your identity provider console, add the redirect URL above under Authorized redirect URIs. After installation you will continue to the provider, then return to Go Loose with a secure signed session.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>
|
||||
|
|
|
|||
|
|
@ -94,16 +94,30 @@
|
|||
},
|
||||
"PermissionValue": {
|
||||
"type": "string",
|
||||
"description": "The closed vocabulary of capabilities a role may grant. Mirrors internal/role and the role_permissions check constraint.",
|
||||
"enum": ["tenants.manage", "roles.manage", "users.manage", "applications.manage", "keys.manage", "contracts.manage", "users.read", "console.read", "profile.edit"]
|
||||
"description": "The name of a catalog entry, such as users.manage or reports.export. Names are rows in the permission catalog, so an installation may add its own; the nine seeded ones are the capabilities the console enforces.",
|
||||
"pattern": "^[a-z0-9][a-z0-9]*(\\.[a-z0-9]+)*$",
|
||||
"maxLength": 100
|
||||
},
|
||||
"PermissionEntry": {
|
||||
"Permission": {
|
||||
"type": "object",
|
||||
"description": "A permission with the label and description the console renders.",
|
||||
"description": "An entry in the installation-wide permission catalog.",
|
||||
"required": ["name", "label", "enforced"],
|
||||
"properties": {
|
||||
"value": {"$ref": "#/components/schemas/PermissionValue"},
|
||||
"label": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
"name": {"$ref": "#/components/schemas/PermissionValue"},
|
||||
"label": {"type": "string", "maxLength": 100},
|
||||
"description": {"type": "string", "maxLength": 500},
|
||||
"system": {"type": "boolean", "description": "True for the seeded capabilities, which cannot be deleted or renamed."},
|
||||
"enforced": {"type": "boolean", "description": "True when the console itself checks for this permission, so granting it changes what the console allows. False for a permission added through the API: it is reported to client applications but nothing here tests for it."},
|
||||
"role_count": {"type": "integer", "description": "Roles currently granting it. A permission that a role still grants cannot be deleted."}
|
||||
}
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"type": "object",
|
||||
"required": ["name", "label"],
|
||||
"properties": {
|
||||
"name": {"$ref": "#/components/schemas/PermissionValue"},
|
||||
"label": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"description": {"type": "string", "maxLength": 500}
|
||||
}
|
||||
},
|
||||
"RoleRequest": {
|
||||
|
|
@ -114,7 +128,8 @@
|
|||
"slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$", "description": "Required when creating, and fixed for the life of the role."},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"description": {"type": "string", "maxLength": 500},
|
||||
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}}
|
||||
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"},
|
||||
"description": "Names from the permission catalog. An unknown name is rejected with 400."}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -364,7 +379,7 @@
|
|||
"post": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "Define a role for a tenant",
|
||||
"description": "Requires roles.manage in the tenant. The role may not grant a permission the caller does not already hold, so an administrator cannot mint their way above their own authority.",
|
||||
"description": "Requires roles.manage in the tenant. The role may not grant an enforced permission the caller does not already hold, so an administrator cannot mint their way above their own authority. Whoever administers the catalog may also grant the custom permissions they added, since nobody holds those yet.",
|
||||
"security": [{"session": []}],
|
||||
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleRequest"}}}},
|
||||
"responses": {"201": {"description": "Role created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "400": {"description": "Malformed slug or name, or an unknown permission"}, "404": {"description": "The caller does not hold roles.manage, or the role would exceed their authority"}, "409": {"description": "The slug is already used in this tenant"}}
|
||||
|
|
@ -388,6 +403,32 @@
|
|||
"responses": {"204": {"description": "Role deleted"}, "404": {"description": "Unknown role, a built-in role, or a role belonging to another tenant"}, "409": {"description": "The role still has members"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/permissions": {
|
||||
"get": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "List the permission catalog",
|
||||
"description": "Every management session may read it, because the role editor renders its checkboxes from this list.",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"200": {"description": "Catalog", "content": {"application/json": {"schema": {"type": "object", "properties": {"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/Permission"}}}}}}}}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "Add a permission to the catalog",
|
||||
"description": "Requires a system administrator or tenants.manage, because the catalog is shared by every tenant. The new name becomes grantable in any role and is reported through /connect/userinfo, but nothing in the console checks for it, so it cannot unlock console behaviour.",
|
||||
"security": [{"session": []}],
|
||||
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PermissionRequest"}}}},
|
||||
"responses": {"201": {"description": "Permission added", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Permission"}}}}, "400": {"description": "Malformed name or label"}, "404": {"description": "The caller may not administer the catalog"}, "409": {"description": "The name is already in the catalog, including a seeded one"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/permissions/{name}": {
|
||||
"delete": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "Delete a permission that no role grants",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "name", "required": true, "schema": {"$ref": "#/components/schemas/PermissionValue"}}],
|
||||
"responses": {"204": {"description": "Permission deleted"}, "400": {"description": "Malformed name"}, "404": {"description": "Unknown permission or the caller may not administer the catalog"}, "409": {"description": "A role still grants it, or it is one of the seeded capabilities"}}
|
||||
}
|
||||
},
|
||||
"/roles": {
|
||||
"get": {
|
||||
"tags": ["Role Management"],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="color-scheme" content="dark light">
|
||||
<title>Go Loose · Profile</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
|
||||
</head>
|
||||
<body class="profile-page">
|
||||
<div class="noise"></div>
|
||||
|
|
@ -15,11 +16,9 @@
|
|||
<a href="/">Console</a>
|
||||
<a href="/docs">API docs</a>
|
||||
<a href="/roles">Roles</a>
|
||||
<label class="compact">Theme
|
||||
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
|
||||
</label>
|
||||
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
|
||||
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
|
||||
<bananas-theme-selector id="theme"></bananas-theme-selector>
|
||||
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
|
||||
<div data-account-menu></div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
@ -75,6 +74,8 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/assets/account.js" defer></script>
|
||||
<script src="/assets/profile.js" defer></script>
|
||||
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -149,18 +149,7 @@ function readAsDataURL(file) {
|
|||
})
|
||||
}
|
||||
|
||||
const theme = $('#theme');
|
||||
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
|
||||
theme.addEventListener('change', () => {
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
localStorage.setItem('gl-theme', theme.value)
|
||||
});
|
||||
$('#mode').addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.mode = next;
|
||||
localStorage.setItem('gl-mode', next)
|
||||
});
|
||||
// The palette and appearance controls are the components shipped by the theme
|
||||
// service. See the note in app.js for why this file no longer drives them.
|
||||
|
||||
load().catch(error => notice(error.message, true));
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="color-scheme" content="dark light">
|
||||
<title>Go Loose · Roles</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
|
||||
</head>
|
||||
<body>
|
||||
<div class="noise"></div>
|
||||
|
|
@ -15,12 +16,9 @@
|
|||
<a href="/">Console</a>
|
||||
<a href="/docs">API docs</a>
|
||||
<label class="compact">Tenant<select id="tenant-context"></select></label>
|
||||
<label class="compact">Theme
|
||||
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
|
||||
</label>
|
||||
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
|
||||
<a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
|
||||
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
|
||||
<bananas-theme-selector id="theme"></bananas-theme-selector>
|
||||
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
|
||||
<div data-account-menu></div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
@ -47,6 +45,20 @@
|
|||
<article><span>Assignable to you</span><strong id="stat-assignable">—</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="panel active" id="permissions">
|
||||
<div class="panel-head">
|
||||
<div><p class="eyebrow">CATALOG</p><h2>Permissions</h2></div>
|
||||
<button id="new-permission">Add permission</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Permission</th><th>Name</th><th>What it allows</th><th>Enforced</th><th>Roles</th><th></th></tr></thead>
|
||||
<tbody id="permission-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted">Every role can grant any name on this list. The nine marked <strong>enforced</strong> are the ones this application checks before it acts, so granting one changes what the console lets you do. A permission you add here is reported to client applications through <code>/connect/userinfo</code>, which can then enforce it themselves, but no part of this console tests for it.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel active" id="roles">
|
||||
<div class="panel-head">
|
||||
<div><p class="eyebrow">PERMISSION CATALOG</p><h2>Roles</h2></div>
|
||||
|
|
@ -62,12 +74,9 @@
|
|||
</section>
|
||||
|
||||
<section class="panel active" id="permission-reference">
|
||||
<div class="panel-head"><div><p class="eyebrow">VOCABULARY</p><h2>Permissions</h2></div></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Permission</th><th>What it allows</th></tr></thead>
|
||||
<tbody id="permission-list"></tbody>
|
||||
</table>
|
||||
<div class="panel-head"><div><p class="eyebrow">HOW THEY ARE USED</p><h2>Reading a permission</h2></div></div>
|
||||
<div class="check-list">
|
||||
<p class="muted">A membership resolves to exactly one role, and that role resolves to a set of permissions. Every tenant-scoped statement asks the database whether the caller holds the permission it needs, so a tenant-defined role is enforced the same way a built-in one is. Assigning a role only succeeds when every permission it grants is one you already hold, which is what stops handing out a role from becoming a way to gain authority.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
|
@ -85,6 +94,19 @@
|
|||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="permission-dialog">
|
||||
<form id="permission-form">
|
||||
<div class="dialog-head"><h3>Add permission</h3><button type="button" class="close">×</button></div>
|
||||
<label>Name<input name="name" required maxlength="100" pattern="[a-z0-9][a-z0-9]*(\.[a-z0-9]+)*" placeholder="reports.export"><small>lowercase words separated by dots</small></label>
|
||||
<label>Label<input name="label" required maxlength="100" placeholder="Export reports"></label>
|
||||
<label>Description <small>(optional)</small><textarea name="description" maxlength="500" placeholder="Allows a client application to export the tenant's reports."></textarea></label>
|
||||
<p class="muted">This becomes selectable in every role. Nothing in this console checks for it; it is reported to client applications, which can enforce it themselves.</p>
|
||||
<button type="submit">Add permission</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/assets/account.js" defer></script>
|
||||
<script src="/assets/roles.js" defer></script>
|
||||
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
97
web/roles.js
97
web/roles.js
|
|
@ -31,28 +31,24 @@ async function api(path, options = {}) {
|
|||
return body
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
try {
|
||||
state.permissions = (await api('/api/v1/permissions')).permissions
|
||||
} catch (error) {
|
||||
notice('You do not have permission to read the permission catalog.', true)
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const dashboard = await api('/api/v1/dashboard');
|
||||
state.tenants = dashboard.tenants;
|
||||
state.permissions = dashboard.permissions || [];
|
||||
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
|
||||
renderIdentity(dashboard.profile);
|
||||
await loadRoles();
|
||||
// Roles and the permission catalog are fetched from their own endpoints so
|
||||
// that changing either does not need the whole dashboard reloaded.
|
||||
await Promise.all([loadRoles(), loadPermissions()]);
|
||||
render()
|
||||
}
|
||||
|
||||
function renderIdentity(profile) {
|
||||
profile = profile || {};
|
||||
$('#identity-name').textContent = profile.display_name || profile.email || '';
|
||||
const image = $('#identity-avatar');
|
||||
if (profile.avatar_url) {
|
||||
image.src = profile.avatar_url;
|
||||
image.hidden = false
|
||||
} else {
|
||||
image.hidden = true;
|
||||
image.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
if (!state.selectedTenant) {
|
||||
|
|
@ -72,6 +68,9 @@ function selectedTenant() {
|
|||
}
|
||||
|
||||
const canManageRoles = () => Boolean(selectedTenant()?.permissions?.includes('roles.manage'));
|
||||
// The catalog is shared by every tenant, so changing it needs tenants.manage
|
||||
// rather than roles.manage.
|
||||
const canAdministerCatalog = () => Boolean(state.tenants.some(tenant => tenant.permissions?.includes('tenants.manage')));
|
||||
|
||||
function render() {
|
||||
const tenant = selectedTenant();
|
||||
|
|
@ -86,6 +85,7 @@ function render() {
|
|||
$('#stat-custom').textContent = custom.length;
|
||||
$('#stat-assignable').textContent = state.roles.filter(item => item.assignable).length;
|
||||
$('#new-role').hidden = !canManageRoles();
|
||||
$('#new-permission').hidden = !canAdministerCatalog();
|
||||
|
||||
$('#role-list').innerHTML = state.roles.map(item => `<tr>
|
||||
<td><strong>${escapeHTML(item.name)}</strong>${item.system ? ' <span class="badge">built-in</span>' : ''}<br><small>${escapeHTML(item.description || 'No description')}</small></td>
|
||||
|
|
@ -93,20 +93,24 @@ function render() {
|
|||
<td class="capabilities">${item.permissions.map(permission => `<span class="chip">${escapeHTML(permission)}</span>`).join('') || '<span class="muted">None</span>'}</td>
|
||||
<td>${item.member_count}</td>
|
||||
<td>${item.assignable ? 'Yes' : '<span class="muted">No</span>'}</td>
|
||||
<td>${item.system ? '' : canManageRoles() ? `<button class="card-action" data-edit="${escapeHTML(item.id)}">Edit</button><button class="danger" data-delete="${escapeHTML(item.id)}">Delete</button>` : ''}</td>
|
||||
<td><div class="table-actions">${item.system ? '' : canManageRoles() ? `<button class="table-action" data-edit="${escapeHTML(item.id)}">Edit</button><button class="table-action danger" data-delete="${escapeHTML(item.id)}">Delete</button>` : ''}</div></td>
|
||||
</tr>`).join('') || '<tr><td colspan="6">No roles available for this tenant.</td></tr>';
|
||||
|
||||
$('#permission-list').innerHTML = state.permissions.map(item => `<tr>
|
||||
<td><code>${escapeHTML(item.value)}</code></td>
|
||||
<td>${escapeHTML(item.description)}</td>
|
||||
</tr>`).join('')
|
||||
<td><strong>${escapeHTML(item.label)}</strong>${item.system ? ' <span class="badge">built-in</span>' : ''}</td>
|
||||
<td><code>${escapeHTML(item.name)}</code></td>
|
||||
<td>${escapeHTML(item.description || 'No description')}</td>
|
||||
<td>${item.enforced ? 'Yes' : '<span class="muted">No</span>'}</td>
|
||||
<td>${item.role_count}</td>
|
||||
<td><div class="table-actions">${item.system || !canAdministerCatalog() ? '' : `<button class="table-action danger" data-delete-permission="${escapeHTML(item.name)}">Delete</button>`}</div></td>
|
||||
</tr>`).join('') || '<tr><td colspan="6">No permissions in the catalog.</td></tr>'
|
||||
}
|
||||
|
||||
function permissionCheckboxes(selected) {
|
||||
const held = new Set(selected || []);
|
||||
$('#role-permissions').innerHTML = state.permissions.map(item => `<label>
|
||||
<input type="checkbox" name="permissions" value="${escapeHTML(item.value)}" ${held.has(item.value) ? 'checked' : ''}>
|
||||
<span><strong>${escapeHTML(item.label)}</strong><br><small>${escapeHTML(item.description)}</small></span>
|
||||
<input type="checkbox" name="permissions" value="${escapeHTML(item.name)}" ${held.has(item.name) ? 'checked' : ''}>
|
||||
<span><strong>${escapeHTML(item.label)}</strong>${item.enforced ? '' : ' <small>(not enforced here)</small>'}<br><small>${escapeHTML(item.description || item.name)}</small></span>
|
||||
</label>`).join('')
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +134,29 @@ function openDialog(item) {
|
|||
}
|
||||
|
||||
$('#new-role').addEventListener('click', () => openDialog(null));
|
||||
$('#new-permission').addEventListener('click', () => $('#permission-dialog').showModal());
|
||||
|
||||
$('#permission-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
try {
|
||||
await api('/api/v1/permissions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: form.name.value.trim(),
|
||||
label: form.label.value.trim(),
|
||||
description: form.description.value.trim()
|
||||
})
|
||||
});
|
||||
form.closest('dialog').close();
|
||||
form.reset();
|
||||
notice('Permission added to the catalog');
|
||||
await loadPermissions();
|
||||
render()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
});
|
||||
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
|
||||
|
||||
$('#role-form').addEventListener('submit', async event => {
|
||||
|
|
@ -160,6 +187,19 @@ document.addEventListener('click', async event => {
|
|||
openDialog(state.roles.find(item => item.id === edit));
|
||||
return
|
||||
}
|
||||
const removePermission = event.target.dataset.deletePermission;
|
||||
if (removePermission) {
|
||||
if (!confirm(`Delete the permission "${removePermission}"?`)) return;
|
||||
try {
|
||||
await api(`/api/v1/permissions/${encodeURIComponent(removePermission)}`, {method: 'DELETE'});
|
||||
notice('Permission deleted');
|
||||
await loadPermissions();
|
||||
render()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
const remove = event.target.dataset.delete;
|
||||
if (remove) {
|
||||
const item = state.roles.find(role => role.id === remove);
|
||||
|
|
@ -175,19 +215,8 @@ document.addEventListener('click', async event => {
|
|||
}
|
||||
});
|
||||
|
||||
const theme = $('#theme');
|
||||
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
|
||||
theme.addEventListener('change', () => {
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
localStorage.setItem('gl-theme', theme.value)
|
||||
});
|
||||
$('#mode').addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.mode = next;
|
||||
localStorage.setItem('gl-mode', next)
|
||||
});
|
||||
// The palette and appearance controls are the components shipped by the theme
|
||||
// service. See the note in app.js for why this file no longer drives them.
|
||||
$('#tenant-context').addEventListener('change', async event => {
|
||||
state.selectedTenant = event.target.value;
|
||||
await loadRoles();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
|
||||
.docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)}
|
||||
main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)}
|
||||
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px}.card h3{margin:0 0 5px}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);min-height:24px}.card-action{margin-top:16px;width:100%;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:flex;justify-content:space-between;margin-top:18px;color:var(--muted);font-size:12px}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px}
|
||||
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:flex-start}.card h3{margin:6px 0 5px;width:100%}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);margin:0;width:100%;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-action{margin-top:14px;align-self:flex-start;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:grid;gap:7px;width:100%;margin-top:auto;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}.meta>div{display:flex;align-items:baseline;gap:14px;min-width:0}.meta span{flex:none;text-transform:uppercase;letter-spacing:.09em;font-size:10px}.meta b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px ui-monospace,monospace;color:var(--text)}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px}
|
||||
dialog{color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 30px 100px rgba(0,0,0,.6);width:min(520px,92vw);padding:24px}dialog::backdrop{background:rgba(8,8,15,.74);backdrop-filter:blur(4px)}dialog form{display:grid;gap:16px}.dialog-head{display:flex;align-items:center;justify-content:space-between}.dialog-head h3{margin:0;font-size:22px}.close{border:0;background:transparent;color:var(--muted);font-size:28px;cursor:pointer}dialog label{display:grid;gap:7px;font-size:13px;color:var(--muted)}textarea{resize:vertical}.segmented{display:flex;gap:20px}.segmented label{display:flex;align-items:center;grid-template-columns:auto auto}.secret{display:flex;gap:8px;align-items:center;background:var(--bg);padding:12px;border-radius:8px}.secret code{overflow-wrap:anywhere;color:var(--good);flex:1}
|
||||
.check-list{display:grid;gap:8px}.check-list label{display:flex;grid-template-columns:auto 1fr;align-items:center}.client-terminal h1{font-size:clamp(38px,6vw,64px);line-height:1;margin:10px 0 32px;letter-spacing:-.05em}.client-terminal h1 span{color:var(--accent2)}
|
||||
.login-page{display:grid;place-items:center;min-height:100vh;overflow:hidden}.login-glow{position:fixed;width:600px;height:600px;border-radius:50%;background:var(--accent2);filter:blur(180px);opacity:.13}.terminal{width:min(920px,92vw);margin:40px;position:relative;background:color-mix(in srgb,var(--surface) 94%,black);border:1px solid var(--border);border-radius:10px;box-shadow:0 40px 120px rgba(0,0,0,.55);overflow:hidden;font-family:ui-monospace,SFMono-Regular,monospace}.terminal-bar{height:44px;background:var(--surface2);display:flex;align-items:center;gap:8px;padding:0 15px}.terminal-bar span{width:11px;height:11px;border-radius:50%;background:var(--danger)}.terminal-bar span:nth-child(2){background:#e0af68}.terminal-bar span:nth-child(3){background:var(--good)}.terminal-bar strong{position:absolute;left:50%;transform:translateX(-50%);color:var(--muted);font-size:12px}.terminal-body{padding:35px}.ascii{color:var(--accent);font-size:clamp(6px,1vw,12px);line-height:1.2;overflow:hidden}.prompt{color:var(--good)}.muted{color:var(--muted);max-width:650px;line-height:1.6}.terminal-login{display:inline-flex;gap:10px;text-decoration:none;margin:20px 0 10px}.dev-login{display:block;color:var(--muted);font-size:11px}.cursor{color:var(--accent);animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.docs-page #swagger-ui{max-width:1200px;margin:30px auto}.docs-page .swagger-ui{filter:none}
|
||||
|
|
@ -17,3 +17,40 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border
|
|||
.installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)}
|
||||
@media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}}
|
||||
.identity{display:inline-flex;align-items:center;gap:8px;text-decoration:none}.identity img,.avatar{border-radius:50%;object-fit:cover;display:block;flex:none}.profile-hero{min-height:0;padding:56px 0 34px}.profile-hero h1{font-size:clamp(40px,5vw,64px)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin-top:34px}.profile-card{display:grid;gap:16px;align-content:start}.profile-card h2{margin:0;font-size:20px;letter-spacing:-.02em}.profile-wide{grid-column:1/-1}.profile-card form{display:grid;gap:14px}.profile-card label{display:grid;gap:7px;font-size:13px;color:var(--muted)}.profile-actions{display:flex;gap:10px;flex-wrap:wrap}.profile-actions button{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions .ghost{background:transparent;border:1px solid var(--border);color:var(--text)}.profile-card button[type=submit]{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer;justify-self:start}.avatar-preview{display:grid;place-items:center;width:112px;height:112px;border-radius:50%;overflow:hidden;background:var(--surface2);border:1px solid var(--border)}.avatar-preview img{width:100%;height:100%;object-fit:cover}.avatar-initials{font:750 34px ui-monospace,monospace;color:var(--accent)}.avatar-empty{display:inline-grid;place-items:center;width:28px;height:28px;vertical-align:middle;background:var(--surface2);border:1px solid var(--border);color:var(--accent);font:700 11px ui-monospace,monospace}.facts{display:grid;gap:10px;margin:4px 0 0}.facts div{display:flex;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);padding-bottom:8px;font-size:13px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right}.capabilities{display:flex;flex-wrap:wrap;gap:6px}.chip{font:600 11px ui-monospace,monospace;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 6px}
|
||||
|
||||
/* ---------------------------------------------------------- account menu --
|
||||
The header account control: a trigger showing who is signed in, opening a
|
||||
small menu with the two things a person can do about their own account. It
|
||||
replaces the separate sign-out button, so the identity and the action live in
|
||||
one place instead of two controls that mean the same thing. */
|
||||
.account{position:relative;margin-left:2px}
|
||||
.account-trigger{display:inline-flex;align-items:center;gap:8px;padding:5px 9px 5px 6px;background:transparent;border:1px solid var(--border);border-radius:8px;color:var(--text);cursor:pointer;font:inherit;font-size:13px}
|
||||
.account-trigger:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}
|
||||
.account-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.account-trigger .avatar{width:24px;height:24px}
|
||||
.account-name{font-weight:650;max-width:11rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.account-caret{color:var(--muted);font-size:10px}
|
||||
.account-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:40;min-width:15rem;padding:6px;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 14px 34px rgba(0,0,0,.28)}
|
||||
.account-menu[hidden]{display:none}
|
||||
.account-identity{padding:9px 10px 11px;border-bottom:1px solid var(--border);margin-bottom:6px}
|
||||
.account-identity strong{display:block;font-size:13px}
|
||||
.account-identity span{display:block;color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.account-menu form{margin:0}
|
||||
.account-menu a,.account-menu button{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;background:transparent;border:0;border-radius:7px;color:var(--text);font:inherit;font-size:13px;text-align:left;text-decoration:none;cursor:pointer}
|
||||
.account-menu a:hover,.account-menu button:hover{background:var(--surface2)}
|
||||
.account-menu a:focus-visible,.account-menu button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.account-note{padding:7px 10px 4px;color:var(--muted);font-size:11px;line-height:1.45}
|
||||
|
||||
/* Row actions inside a table. The existing .card-action is a full-width block
|
||||
button meant for the foot of a card, which stacked two of them in a narrow
|
||||
cell and made the row taller than its content. These are inline and sit on the
|
||||
text baseline. */
|
||||
.table-actions{display:flex;gap:6px;justify-content:flex-end;white-space:nowrap}
|
||||
.table-action{padding:5px 10px;background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--text);font:inherit;font-size:12px;cursor:pointer}
|
||||
.table-action:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));color:var(--accent)}
|
||||
.table-action:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.table-action.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,var(--border))}
|
||||
.table-action.danger:hover{background:color-mix(in srgb,var(--danger) 12%,transparent)}
|
||||
/* A slug, prefix or permission name is an identifier: wrapping it across two lines
|
||||
makes one row taller than its neighbours for no benefit. */
|
||||
.table-wrap td code{white-space:nowrap}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue