fix installer

This commit is contained in:
Daniel Noulet 2026-10-04 12:24:13 +02:00
parent 318fb51085
commit 6319da650c
31 changed files with 2391 additions and 87 deletions

View file

@ -1,5 +1,15 @@
const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''};
const state = {
tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [],
roles: [], profile: null, selectedTenant: ''
};
// Capability names mirror internal/role. The server sends the grants for the
// selected tenant so the console never re-implements the matrix.
const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission));
const $ = selector => document.querySelector(selector);
const roleOptions = roles => roles
.map(item => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
.join('');
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
@ -38,14 +48,38 @@ async function load() {
const data = await api('/api/v1/dashboard');
Object.assign(state, data);
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
$('#identity').textContent = data.user.display_name || data.user.email;
renderIdentity();
renderRoleOptions();
render();
}
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')
}
}
function renderRoleOptions() {
// Inviting an owner is rejected by the server, so leave it out of the list.
const invitable = state.roles.filter(item => item.value !== 'owner');
$('#invite-role').innerHTML = roleOptions(invitable);
$('#access-role').innerHTML = roleOptions(state.roles);
}
function render() {
const tenantID = state.selectedTenant;
const selectedTenant = state.tenants.find(item => item.id === tenantID);
const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role);
const canManageApplications = grants(selectedTenant, 'applications.manage');
const canManageKeys = grants(selectedTenant, 'keys.manage');
const canManageContracts = grants(selectedTenant, 'contracts.manage');
const canManageUsers = grants(selectedTenant, 'users.manage');
const tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
const tenantAppIDs = new Set(tenantApps.map(item => item.id));
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id));
@ -60,12 +94,15 @@ function render() {
$('#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>${canManageTenant ? `<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>${canManageTenant && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
$('#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');
$('#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><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(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageTenant ? `<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');
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant);
$('#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(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');
$$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
$$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers);
$$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
select.value = tenantID;
@ -74,6 +111,10 @@ function render() {
}
const empty = text => `<article class="card"><p>${text}</p></article>`;
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">`
: `<span class="avatar avatar-empty">${escapeHTML(initials(item.display_name || item.email))}</span>`;
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
function formJSON(form) {

View file

@ -18,7 +18,7 @@
<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>
<span id="identity" class="identity"></span>
<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>
</nav>
</header>
@ -58,15 +58,15 @@
<div class="panels">
<section class="panel active" id="applications">
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-action>New application</button></div>
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-application-action>New application</button></div>
<div class="card-grid" id="app-list"></div>
</section>
<section class="panel" id="keys">
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-action>Issue key</button></div>
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-key-action>Issue key</button></div>
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div>
</section>
<section class="panel" id="contracts">
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-action>Import contract</button></div>
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-contract-action>Import contract</button></div>
<div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div>
</section>
<section class="panel" id="tenants">
@ -74,8 +74,9 @@
<div class="card-grid" id="tenant-list"></div>
</section>
<section class="panel" id="users">
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-action>Invite user</button></div>
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-user-action>Invite user</button></div>
<div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div>
<p class="muted">Roles come from the shared permission matrix. <a href="/profile">Open your own profile</a> to change your name and picture.</p>
</section>
</div>
</section>
@ -86,8 +87,8 @@
<dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog>
<dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0&#10;paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog>
<dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label><button type="submit">Invite user</button></form></dialog>
<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"><option value="viewer">Viewer</option><option value="admin">Administrator</option><option value="owner">Owner</option></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="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role" id="invite-role" required></select></label><button type="submit">Invite user</button></form></dialog>
<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="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/app.js" defer></script>

View file

@ -4,7 +4,7 @@
<body class="login-page">
<div class="login-glow"></div>
<main class="terminal">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@access-plane:~</strong></div>
<div class="terminal-bar"><span></span><span></span><span></span><strong>{{LOGIN_PROMPT}}</strong></div>
<div class="terminal-body">
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
@ -12,9 +12,9 @@
██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<p class="eyebrow">{{LOGIN_CONTEXT}}</p>
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p>
<p class="muted">{{LOGIN_MESSAGE}}</p>
<p class="eyebrow">{{LOGIN_CONTEXT}}</p>
<p><span class="prompt">{{LOGIN_PROMPT}}$</span> initialize secure session</p>
<p class="muted">{{LOGIN_MESSAGE}}</p>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Email<input type="email" name="email" autocomplete="username" required></label>
@ -23,7 +23,7 @@
</form>
<div style="{{SSO_STYLE}}">
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p>
<p class="cursor-line"><span class="prompt">{{LOGIN_PROMPT}}$</span> <span class="cursor">█</span></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>

View file

@ -38,6 +38,50 @@
"api_key_id": {"type": "string", "format": "uuid"},
"api_key_name": {"type": "string"}
}
},
"Membership": {
"type": "object",
"description": "A tenant role held by a user, with the capabilities it grants.",
"properties": {
"tenant_id": {"type": "string", "format": "uuid"},
"tenant_slug": {"type": "string"},
"tenant_name": {"type": "string"},
"role": {"type": "string", "enum": ["owner", "admin", "developer", "operator", "viewer", "user"]},
"permissions": {"type": "array", "items": {"type": "string"}}
}
},
"Profile": {
"type": "object",
"properties": {
"id": {"type": "string", "format": "uuid"},
"email": {"type": "string", "format": "email"},
"display_name": {"type": "string"},
"avatar_url": {"type": "string", "description": "Absolute picture URL, or an empty string when no picture is set."},
"memberships": {"type": "array", "items": {"$ref": "#/components/schemas/Membership"}},
"is_system_administrator": {"type": "boolean"},
"name_customized": {"type": "boolean", "description": "True once the user set the display name themselves, which stops the identity provider from overwriting it."},
"created_at": {"type": "string", "format": "date-time"},
"last_login_at": {"type": "string", "format": "date-time"},
"profile_updated_at": {"type": "string", "format": "date-time"},
"avatar_updated_at": {"type": "string", "format": "date-time"}
}
},
"ProfileUpdate": {
"type": "object",
"description": "Send display_name, avatar, or remove_avatar. avatar accepts raw base64 or a data URL.",
"properties": {
"display_name": {"type": "string", "minLength": 1, "maxLength": 200},
"avatar": {"type": "string", "description": "Base64 or data URL of a JPEG, PNG, or GIF up to 2 MiB and 4096 by 4096 pixels."},
"remove_avatar": {"type": "boolean"}
}
},
"RoleCatalogEntry": {
"type": "object",
"properties": {
"value": {"type": "string"},
"label": {"type": "string"},
"permissions": {"type": "array", "items": {"type": "string"}}
}
}
}
},
@ -93,10 +137,54 @@
"Dashboard Management"
],
"summary": "Get management console data",
"description": "Requires a role that grants console.read. Each tenant and user entry carries the capability names its role grants.",
"security": [{"session": []}],
"responses": {"200": {"description": "Dashboard data"}}
}
},
"/profile": {
"get": {
"tags": ["Profile"],
"summary": "Open the self-service profile screen",
"description": "Available to every authenticated management session, including self-service User members.",
"security": [{"session": []}],
"responses": {"200": {"description": "Profile screen"}, "303": {"description": "Redirect to login"}}
}
},
"/api/v1/profile": {
"get": {
"tags": ["Profile"],
"summary": "Read the caller's own profile and tenant roles",
"security": [{"session": []}],
"responses": {
"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
"401": {"description": "Authentication required"}
}
},
"put": {
"tags": ["Profile"],
"summary": "Update the caller's display name or profile picture",
"security": [{"session": []}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdate"}}}},
"responses": {
"200": {"description": "Updated profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
"400": {"description": "Invalid display name or unsupported picture"}
}
}
},
"/api/v1/avatars/{key}": {
"get": {
"tags": ["Profile"],
"summary": "Fetch a profile picture",
"description": "Public on purpose so other applications can embed the picture with a plain image tag. The key is 256 bits of randomness and exposes nothing about the user.",
"parameters": [{"in": "path", "name": "key", "required": true, "schema": {"type": "string"}}],
"responses": {
"200": {"description": "Image bytes", "content": {"image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/png": {"schema": {"type": "string", "format": "binary"}}, "image/gif": {"schema": {"type": "string", "format": "binary"}}}},
"304": {"description": "Not modified"},
"404": {"description": "No picture for that key"}
}
}
},
"/api/v1/tenants": {
"post": {
"tags": [
@ -169,6 +257,7 @@
"User Action"
],
"summary": "Exchange a one-time authorization code",
"description": "The nested user object carries role, permissions, and avatar_url for the tenant owning the application.",
"security": [{"clientBasic": []}],
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}},
"responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}}
@ -179,7 +268,8 @@
"tags": [
"User Action"
],
"summary": "Validate a client user session",
"summary": "Validate a client user session and read the caller's role and picture",
"description": "Returns id, email, display_name, avatar_url, tenant and application identity, role, and permissions.",
"security": [{"userBearer": []}],
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
}
@ -210,7 +300,7 @@
"tags": [
"User Management"
],
"summary": "Invite a tenant user",
"summary": "Invite a tenant user with an admin, developer, operator, viewer, or user role",
"security": [{"session": []}],
"responses": {"201": {"description": "User invited"}}
}
@ -221,11 +311,20 @@
"User Management"
],
"summary": "Set tenant role and application access",
"description": "Any known role is accepted, including owner.",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "Access updated"}}
}
},
"/api/v1/roles": {
"get": {
"tags": ["User Management"],
"summary": "List tenant roles with their capabilities",
"security": [{"session": []}],
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/RoleCatalogEntry"}}}}}}
}
},
"/api/v1/users/{id}/approve": {
"post": {
"tags": ["User Management"],

79
web/profile.html Normal file
View file

@ -0,0 +1,79 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Go Loose · Profile</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="profile-page">
<div class="noise"></div>
<header>
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav>
<a href="/">Console</a>
<a href="/docs">API docs</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>
</nav>
</header>
<main>
<section class="hero profile-hero">
<div>
<p class="eyebrow">YOUR ACCOUNT</p>
<h1>Profile<span>.</span></h1>
<p class="lede">Your name, picture, and the tenant roles you hold. Applications such as go-tell read the picture link and your role through the login endpoints.</p>
</div>
</section>
<div id="notice" class="notice" hidden></div>
<section class="profile-grid">
<article class="card profile-card">
<h2>Profile picture</h2>
<div class="avatar-preview">
<img id="avatar-image" alt="" width="112" height="112" hidden>
<span id="avatar-fallback" class="avatar-initials">–</span>
</div>
<form id="avatar-form">
<label>Choose a picture <small>(JPEG, PNG, or GIF, up to 2 MiB)</small><input type="file" id="avatar-file" accept="image/jpeg,image/png,image/gif"></label>
<div class="profile-actions">
<button type="submit" id="avatar-save" disabled>Upload picture</button>
<button type="button" class="ghost" id="avatar-remove" hidden>Remove picture</button>
</div>
</form>
<label class="muted">Picture link <small>(embed it with a plain image tag)</small>
<div class="secret"><code id="avatar-url">No picture uploaded</code><button id="copy-avatar" type="button" hidden>Copy</button></div>
</label>
</article>
<article class="card profile-card">
<h2>Account</h2>
<form id="profile-form">
<label>Display name<input name="display_name" id="display-name" maxlength="200" required></label>
<label>Email<input id="email" readonly></label>
<dl class="facts">
<div><dt>System administrator</dt><dd id="system-admin">—</dd></div>
<div><dt>Member since</dt><dd id="created-at">—</dd></div>
<div><dt>Last login</dt><dd id="last-login">—</dd></div>
<div><dt>Name managed by</dt><dd id="name-source">—</dd></div>
</dl>
<button type="submit">Save profile</button>
</form>
</article>
<article class="card profile-card profile-wide">
<h2>Tenant roles</h2>
<div class="table-wrap"><table><thead><tr><th>Tenant</th><th>Slug</th><th>Role</th><th>Capabilities</th></tr></thead><tbody id="membership-list"></tbody></table></div>
</article>
</section>
</main>
<script src="/assets/profile.js" defer></script>
</body>
</html>

166
web/profile.js Normal file
View file

@ -0,0 +1,166 @@
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
const date = value => value ? new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(value)) : 'Never';
const notice = (message, error = false) => {
const el = $('#notice');
el.textContent = message;
el.hidden = false;
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
clearTimeout(notice.timer);
notice.timer = setTimeout(() => el.hidden = true, 5000)
};
const avatar = {dataURL: '', fileName: '', pending: false};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin', ...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})}
});
if (response.status === 401) {
location.href = '/login';
throw new Error('Session expired')
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function load() {
render(await api('/api/v1/profile'))
}
function render(profile) {
$('#display-name').value = profile.display_name;
$('#email').value = profile.email;
$('#system-admin').textContent = profile.is_system_administrator ? 'Yes' : 'No';
$('#created-at').textContent = date(profile.created_at);
$('#last-login').textContent = date(profile.last_login_at);
$('#name-source').textContent = profile.name_customized ? 'You' : 'Your identity provider';
renderAvatar(profile);
$('#membership-list').innerHTML = profile.memberships.map(item => `<tr>
<td>${escapeHTML(item.tenant_name)}</td>
<td><code>${escapeHTML(item.tenant_slug)}</code></td>
<td><span class="badge">${escapeHTML(item.role)}</span></td>
<td class="capabilities">${item.permissions.map(permission => `<span class="chip">${escapeHTML(permission)}</span>`).join('') || '<span class="muted">None</span>'}</td>
</tr>`).join('') || '<tr><td colspan="4">No tenant role assigned yet</td></tr>'
}
function renderAvatar(profile) {
const image = $('#avatar-image'), fallback = $('#avatar-fallback'), link = $('#avatar-url');
const source = avatar.pending ? avatar.dataURL : profile.avatar_url;
$('#avatar-remove').hidden = !profile.avatar_url;
$('#copy-avatar').hidden = !profile.avatar_url;
if (source) {
image.src = source;
image.hidden = false;
fallback.hidden = true
} else {
image.hidden = true;
image.removeAttribute('src');
fallback.hidden = false;
fallback.textContent = initials(profile.display_name || profile.email)
}
link.textContent = profile.avatar_url || (avatar.pending ? 'Saved on upload' : 'No picture uploaded')
}
function initials(name) {
return String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?'
}
$('#profile-form').addEventListener('submit', async event => {
event.preventDefault();
try {
render(await api('/api/v1/profile', {
method: 'PUT',
body: JSON.stringify({display_name: $('#display-name').value})
}));
notice('Profile saved')
} catch (error) {
notice(error.message, true)
}
});
$('#avatar-file').addEventListener('change', async event => {
const file = event.target.files?.[0];
avatar.pending = false;
if (!file) {
$('#avatar-save').disabled = true;
return
}
if (file.size > 2 * 1024 * 1024) {
notice('The picture must be 2 MiB or smaller', true);
event.target.value = '';
$('#avatar-save').disabled = true;
return
}
avatar.dataURL = await readAsDataURL(file);
avatar.fileName = file.name;
avatar.pending = true;
$('#avatar-save').disabled = false;
renderAvatar({avatar_url: '', display_name: $('#display-name').value, email: $('#email').value})
});
$('#avatar-form').addEventListener('submit', async event => {
event.preventDefault();
if (!avatar.pending) return;
try {
const profile = await api('/api/v1/profile', {
method: 'PUT', body: JSON.stringify({avatar: avatar.dataURL})
});
avatar.pending = false;
$('#avatar-file').value = '';
$('#avatar-save').disabled = true;
render(profile);
notice('Profile picture updated')
} catch (error) {
notice(error.message, true)
}
});
$('#avatar-remove').addEventListener('click', async () => {
if (!confirm('Remove your profile picture?')) return;
try {
const profile = await api('/api/v1/profile', {method: 'PUT', body: JSON.stringify({remove_avatar: true})});
avatar.pending = false;
$('#avatar-file').value = '';
$('#avatar-save').disabled = true;
render(profile);
notice('Profile picture removed')
} catch (error) {
notice(error.message, true)
}
});
$('#copy-avatar').addEventListener('click', () => navigator.clipboard.writeText($('#avatar-url').textContent).then(() => notice('Picture link copied')));
function readAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('could not read the selected file'));
reader.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)
});
load().catch(error => notice(error.message, true));

View file

@ -16,3 +16,4 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border
.password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}
.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}