roles
This commit is contained in:
parent
6319da650c
commit
3fdc8cb489
26 changed files with 2245 additions and 488 deletions
65
web/app.js
65
web/app.js
|
|
@ -1,15 +1,12 @@
|
|||
const state = {
|
||||
tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [],
|
||||
roles: [], profile: null, selectedTenant: ''
|
||||
roles: [], permissions: [], 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 => ({
|
||||
'&': '&',
|
||||
|
|
@ -48,11 +45,28 @@ 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 || '';
|
||||
await loadRoles();
|
||||
renderIdentity();
|
||||
renderRoleOptions();
|
||||
render();
|
||||
}
|
||||
|
||||
// loadRoles fetches the role catalog for the selected tenant. Roles are rows now
|
||||
// rather than a fixed list, so they have to be asked for per tenant, and the
|
||||
// server marks which ones the signed-in user is allowed to hand out.
|
||||
async function loadRoles() {
|
||||
if (!state.selectedTenant) {
|
||||
state.roles = [];
|
||||
return
|
||||
}
|
||||
try {
|
||||
state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles
|
||||
} catch (error) {
|
||||
// A user who cannot read the role catalog still gets the rest of the
|
||||
// console; the invite and access dialogs simply offer nothing.
|
||||
state.roles = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderIdentity() {
|
||||
const profile = state.profile || {};
|
||||
$('#identity-name').textContent = profile.display_name || profile.email || '';
|
||||
|
|
@ -66,11 +80,15 @@ function renderIdentity() {
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// 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
|
||||
// anything else would only produce an error.
|
||||
function assignableRoles() {
|
||||
return state.roles.filter(item => item.assignable)
|
||||
}
|
||||
|
||||
function roleLabel(roleID) {
|
||||
return state.roles.find(item => item.id === roleID)?.name || roleID
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
|
@ -98,7 +116,8 @@ function render() {
|
|||
$('#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>${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');
|
||||
$('#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');
|
||||
renderRoleOptions();
|
||||
$$('[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);
|
||||
|
|
@ -110,6 +129,20 @@ function render() {
|
|||
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''));
|
||||
}
|
||||
|
||||
// renderRoleOptions fills both role pickers. The current value is preserved so
|
||||
// that re-rendering the table does not silently reset a pending edit.
|
||||
function renderRoleOptions() {
|
||||
const options = assignableRoles();
|
||||
const html = options.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
||||
for (const id of ['#invite-role', '#access-role']) {
|
||||
const select = $(id);
|
||||
if (!select) continue;
|
||||
const current = select.value;
|
||||
select.innerHTML = html;
|
||||
if (current && options.some(item => item.id === current)) select.value = current
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -230,7 +263,10 @@ document.addEventListener('click', async event => {
|
|||
form.tenant_id.value = user.tenant_id;
|
||||
form.approval_mode.value = 'false';
|
||||
$('#approval-tenant-label').hidden = true;
|
||||
form.role.value = user.role;
|
||||
// A role the caller may not hand out is not in the picker, so fall back
|
||||
// to the first one they can assign rather than submitting the old value.
|
||||
const current = assignableRoles().some(item => item.id === user.role_id);
|
||||
form.role_id.value = current ? user.role_id : assignableRoles()[0]?.id || '';
|
||||
renderAccessApps(user.tenant_id, user.application_ids);
|
||||
$('#access-dialog').showModal();
|
||||
return
|
||||
|
|
@ -242,7 +278,7 @@ document.addEventListener('click', async event => {
|
|||
form.approval_mode.value = 'true';
|
||||
$('#approval-tenant-label').hidden = false;
|
||||
form.approval_tenant_id.value = state.selectedTenant;
|
||||
form.role.value = 'viewer';
|
||||
form.role_id.value = assignableRoles()[0]?.id || '';
|
||||
renderAccessApps(state.selectedTenant, []);
|
||||
$('#access-dialog').showModal()
|
||||
}
|
||||
|
|
@ -269,8 +305,9 @@ mode.addEventListener('click', () => {
|
|||
document.documentElement.dataset.mode = next;
|
||||
localStorage.setItem('gl-mode', next)
|
||||
});
|
||||
$('#tenant-context').addEventListener('change', event => {
|
||||
$('#tenant-context').addEventListener('change', async event => {
|
||||
state.selectedTenant = event.target.value;
|
||||
await loadRoles();
|
||||
render()
|
||||
});
|
||||
$('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, []));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
|
||||
<nav>
|
||||
<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>
|
||||
|
|
@ -76,7 +77,7 @@
|
|||
<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-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>
|
||||
<p class="muted">Roles are per tenant and grant capabilities. <a href="/roles">Manage roles</a> to review or define your own. <a href="/profile">Open your own profile</a> to change your name and picture.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -87,8 +88,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 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" 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="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" id="invite-role" required></select></label><p class="muted">You can only assign roles that are no more powerful than your own.</p><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" 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>
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@
|
|||
"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"}}
|
||||
"role": {"type": "string", "description": "Slug of the role, which may be a role this tenant defined."},
|
||||
"role_id": {"type": "string", "format": "uuid", "description": "Identifier of the role row. Send this back when assigning the role."},
|
||||
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}}
|
||||
}
|
||||
},
|
||||
"Profile": {
|
||||
|
|
@ -75,12 +76,45 @@
|
|||
"remove_avatar": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"RoleCatalogEntry": {
|
||||
"Role": {
|
||||
"type": "object",
|
||||
"description": "A named bundle of permissions. A built-in role has no tenant_id, is visible to every tenant, and cannot be edited or deleted. A tenant may define its own.",
|
||||
"required": ["id", "slug", "name", "system", "permissions"],
|
||||
"properties": {
|
||||
"value": {"type": "string"},
|
||||
"id": {"type": "string", "format": "uuid"},
|
||||
"tenant_id": {"type": "string", "format": "uuid", "description": "Absent for built-in roles."},
|
||||
"slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"description": {"type": "string", "maxLength": 500},
|
||||
"system": {"type": "boolean", "description": "True for built-in roles, which are read only."},
|
||||
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}},
|
||||
"member_count": {"type": "integer", "description": "Members currently holding the role. A role that still has members cannot be deleted."},
|
||||
"assignable": {"type": "boolean", "description": "Whether the caller may assign this role. False when the role grants a permission the caller does not hold, which is what stops role assignment becoming a way to gain authority."}
|
||||
}
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"PermissionEntry": {
|
||||
"type": "object",
|
||||
"description": "A permission with the label and description the console renders.",
|
||||
"properties": {
|
||||
"value": {"$ref": "#/components/schemas/PermissionValue"},
|
||||
"label": {"type": "string"},
|
||||
"permissions": {"type": "array", "items": {"type": "string"}}
|
||||
"description": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"RoleRequest": {
|
||||
"type": "object",
|
||||
"required": ["name", "permissions"],
|
||||
"properties": {
|
||||
"tenant_id": {"type": "string", "format": "uuid", "description": "Required when creating. Ignored when updating, which takes the role's own tenant."},
|
||||
"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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -300,7 +334,8 @@
|
|||
"tags": [
|
||||
"User Management"
|
||||
],
|
||||
"summary": "Invite a tenant user with an admin, developer, operator, viewer, or user role",
|
||||
"summary": "Invite a tenant user with a role",
|
||||
"description": "Requires users.manage in the tenant, which owner, admin, developer, and operator hold. role_id must name a role of that tenant whose permissions are a subset of the caller's own.",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"201": {"description": "User invited"}}
|
||||
}
|
||||
|
|
@ -311,7 +346,7 @@
|
|||
"User Management"
|
||||
],
|
||||
"summary": "Set tenant role and application access",
|
||||
"description": "Any known role is accepted, including owner.",
|
||||
"description": "Requires users.manage. The caller may not grant a role more powerful than their own, and may not demote the last owner of a tenant.",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"responses": {"204": {"description": "Access updated"}}
|
||||
|
|
@ -319,17 +354,53 @@
|
|||
},
|
||||
"/api/v1/roles": {
|
||||
"get": {
|
||||
"tags": ["User Management"],
|
||||
"summary": "List tenant roles with their capabilities",
|
||||
"tags": ["Role Management"],
|
||||
"summary": "List the roles available in a tenant",
|
||||
"description": "Returns every built-in role plus the roles this tenant defined, each annotated with whether the caller may assign it.",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/RoleCatalogEntry"}}}}}}
|
||||
"parameters": [{"in": "query", "name": "tenant_id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "object", "properties": {"roles": {"type": "array", "items": {"$ref": "#/components/schemas/Role"}}}}}}}, "404": {"description": "Not a member of that tenant"}}
|
||||
},
|
||||
"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.",
|
||||
"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"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/roles/{id}": {
|
||||
"put": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "Rename a role and replace its permissions",
|
||||
"description": "Built-in roles are immutable. Saving replaces the permissions of every member holding the role, so treat it as a live change.",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleRequest"}}}},
|
||||
"responses": {"200": {"description": "Role updated", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "400": {"description": "Malformed name or an unknown permission"}, "404": {"description": "Unknown role, a built-in role, or a role belonging to another tenant"}}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "Delete one of the tenant's own roles",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"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"}}
|
||||
}
|
||||
},
|
||||
"/roles": {
|
||||
"get": {
|
||||
"tags": ["Role Management"],
|
||||
"summary": "Role management screen",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"200": {"description": "HTML page"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}/approve": {
|
||||
"post": {
|
||||
"tags": ["User Management"],
|
||||
"summary": "Approve a waiting-room user into a tenant",
|
||||
"description": "Requires a system administrator session.",
|
||||
"description": "Requires a system administrator session. Unlike the tenant-scoped endpoints this is not limited by the caller's own role, because it grants a first membership across tenants.",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"responses": {"204": {"description": "User approved"}, "404": {"description": "User or permission not found"}}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<nav>
|
||||
<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>
|
||||
|
|
|
|||
90
web/roles.html
Normal file
90
web/roles.html
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<!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 · Roles</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<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">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>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">ROLE MANAGEMENT</p>
|
||||
<h1>Who may do<br><span>what, per tenant.</span></h1>
|
||||
<p class="lede">A role is a named bundle of permissions. Built-in roles are shared by every tenant and cannot be edited; your own tenant can define as many more as it needs.</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="pulse"></div>
|
||||
<div><strong id="role-summary">Roles</strong><span id="role-tenant">—</span></div>
|
||||
<code id="role-count">—</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="notice" class="notice" hidden></div>
|
||||
|
||||
<section class="stats" id="stats">
|
||||
<article><span>Roles</span><strong id="stat-roles">—</strong></article>
|
||||
<article><span>Built-in</span><strong id="stat-system">—</strong></article>
|
||||
<article><span>Custom</span><strong id="stat-custom">—</strong></article>
|
||||
<article><span>Assignable to you</span><strong id="stat-assignable">—</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="panel active" id="roles">
|
||||
<div class="panel-head">
|
||||
<div><p class="eyebrow">PERMISSION CATALOG</p><h2>Roles</h2></div>
|
||||
<button id="new-role">New role</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Role</th><th>Slug</th><th>Permissions</th><th>Members</th><th>Assignable to you</th><th></th></tr></thead>
|
||||
<tbody id="role-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted">You can only create and assign roles that grant no more than you already hold, so managing members can never be used to gain authority.</p>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="role-dialog">
|
||||
<form id="role-form">
|
||||
<div class="dialog-head"><h3 id="role-dialog-title">New role</h3><button type="button" class="close">×</button></div>
|
||||
<input type="hidden" name="role_id">
|
||||
<label id="role-slug-label">Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="release-manager"></label>
|
||||
<label>Name<input name="name" required maxlength="100" placeholder="Release manager"></label>
|
||||
<label>Description <small>(optional)</small><textarea name="description" maxlength="500" placeholder="Prepares releases."></textarea></label>
|
||||
<fieldset><legend>Permissions</legend><div id="role-permissions" class="check-list"></div></fieldset>
|
||||
<p class="muted" id="role-hint"></p>
|
||||
<button type="submit">Save role</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/assets/roles.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
197
web/roles.js
Normal file
197
web/roles.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
const state = {tenants: [], roles: [], permissions: [], selectedTenant: ''};
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[c]));
|
||||
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)
|
||||
};
|
||||
|
||||
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() {
|
||||
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();
|
||||
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) {
|
||||
state.roles = [];
|
||||
return
|
||||
}
|
||||
try {
|
||||
state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles
|
||||
} catch (error) {
|
||||
state.roles = [];
|
||||
notice('You do not have permission to read the roles of this tenant.', true)
|
||||
}
|
||||
}
|
||||
|
||||
function selectedTenant() {
|
||||
return state.tenants.find(tenant => tenant.id === state.selectedTenant)
|
||||
}
|
||||
|
||||
const canManageRoles = () => Boolean(selectedTenant()?.permissions?.includes('roles.manage'));
|
||||
|
||||
function render() {
|
||||
const tenant = selectedTenant();
|
||||
const custom = state.roles.filter(item => !item.system);
|
||||
$('#tenant-context').innerHTML = state.tenants.map(item =>
|
||||
`<option value="${escapeHTML(item.id)}" ${item.id === state.selectedTenant ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
|
||||
$('#role-tenant').textContent = tenant ? tenant.name : 'No tenant';
|
||||
$('#role-count').textContent = String(state.roles.length);
|
||||
$('#role-summary').textContent = canManageRoles() ? 'Roles · editable' : 'Roles · read only';
|
||||
$('#stat-roles').textContent = state.roles.length;
|
||||
$('#stat-system').textContent = state.roles.length - custom.length;
|
||||
$('#stat-custom').textContent = custom.length;
|
||||
$('#stat-assignable').textContent = state.roles.filter(item => item.assignable).length;
|
||||
$('#new-role').hidden = !canManageRoles();
|
||||
|
||||
$('#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>
|
||||
<td><code>${escapeHTML(item.slug)}</code></td>
|
||||
<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>
|
||||
</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('')
|
||||
}
|
||||
|
||||
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>
|
||||
</label>`).join('')
|
||||
}
|
||||
|
||||
function openDialog(item) {
|
||||
const form = $('#role-form');
|
||||
form.reset();
|
||||
form.role_id.value = item?.id || '';
|
||||
form.slug.value = item?.slug || '';
|
||||
form.name.value = item?.name || '';
|
||||
form.description.value = item?.description || '';
|
||||
// The slug identifies a role for the lifetime of the role, so it is fixed
|
||||
// once the role exists.
|
||||
$('#role-slug-label').hidden = Boolean(item);
|
||||
form.slug.required = !item;
|
||||
$('#role-dialog-title').textContent = item ? `Edit ${item.name}` : 'New role';
|
||||
$('#role-hint').textContent = item
|
||||
? 'Saving replaces the permissions of every member holding this role.'
|
||||
: 'The slug cannot be changed later.';
|
||||
permissionCheckboxes(item?.permissions);
|
||||
$('#role-dialog').showModal()
|
||||
}
|
||||
|
||||
$('#new-role').addEventListener('click', () => openDialog(null));
|
||||
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
|
||||
|
||||
$('#role-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const permissions = [...form.querySelectorAll('input[name=permissions]:checked')].map(input => input.value);
|
||||
const id = form.role_id.value;
|
||||
const body = id
|
||||
? {name: form.name.value, description: form.description.value, permissions}
|
||||
: {tenant_id: state.selectedTenant, slug: form.slug.value, name: form.name.value, description: form.description.value, permissions};
|
||||
try {
|
||||
await api(id ? `/api/v1/roles/${id}` : '/api/v1/roles', {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
form.closest('dialog').close();
|
||||
notice(id ? 'Role updated' : 'Role created');
|
||||
await loadRoles();
|
||||
render()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', async event => {
|
||||
const edit = event.target.dataset.edit;
|
||||
if (edit) {
|
||||
openDialog(state.roles.find(item => item.id === edit));
|
||||
return
|
||||
}
|
||||
const remove = event.target.dataset.delete;
|
||||
if (remove) {
|
||||
const item = state.roles.find(role => role.id === remove);
|
||||
if (!confirm(`Delete the role "${item.name}"?`)) return;
|
||||
try {
|
||||
await api(`/api/v1/roles/${remove}`, {method: 'DELETE'});
|
||||
notice('Role deleted');
|
||||
await loadRoles();
|
||||
render()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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)
|
||||
});
|
||||
$('#tenant-context').addEventListener('change', async event => {
|
||||
state.selectedTenant = event.target.value;
|
||||
await loadRoles();
|
||||
render()
|
||||
});
|
||||
|
||||
load().catch(error => notice(error.message, true));
|
||||
Loading…
Add table
Add a link
Reference in a new issue