initial commit
This commit is contained in:
commit
54576d9d33
61 changed files with 5569 additions and 0 deletions
240
web/app.js
Normal file
240
web/app.js
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''};
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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)';
|
||||
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 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;
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tenantID = state.selectedTenant;
|
||||
const selectedTenant = state.tenants.find(item => item.id === tenantID);
|
||||
const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role);
|
||||
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));
|
||||
const tenantContracts = state.contracts.filter(item => tenantAppIDs.has(item.application_id));
|
||||
const tenantUsers = state.users.filter(item => item.tenant_id === tenantID);
|
||||
const apps = new Map(state.applications.map(item => [item.id, item]));
|
||||
$('#tenant-count').textContent = state.tenants.length;
|
||||
$('#app-count').textContent = tenantApps.length;
|
||||
$('#key-count').textContent = tenantKeys.length;
|
||||
$('#contract-count').textContent = tenantContracts.length;
|
||||
$('#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>${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');
|
||||
$('#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);
|
||||
$$('.tenant-select').forEach(select => {
|
||||
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
||||
select.value = tenantID;
|
||||
});
|
||||
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''));
|
||||
}
|
||||
|
||||
const empty = text => `<article class="card"><p>${text}</p></article>`;
|
||||
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
|
||||
|
||||
function formJSON(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (data.allowed_hosts !== undefined) data.allowed_hosts = data.allowed_hosts.split('\n').map(v => v.trim()).filter(Boolean);
|
||||
if (!data.expires_at) delete data.expires_at; else data.expires_at = new Date(data.expires_at).toISOString();
|
||||
return data;
|
||||
}
|
||||
|
||||
$$('[data-dialog]').forEach(button => button.addEventListener('click', () => $('#' + button.dataset.dialog).showModal()));
|
||||
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
|
||||
$$('.tab').forEach(button => button.addEventListener('click', () => {
|
||||
$$('.tab,.panel').forEach(el => el.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
$('#' + button.dataset.panel).classList.add('active')
|
||||
}));
|
||||
|
||||
$('#tenant-form').addEventListener('submit', async event => submit(event, '/api/v1/tenants', 'Tenant created'));
|
||||
$('#app-form').addEventListener('submit', async event => submit(event, '/api/v1/applications', 'Application registered'));
|
||||
$('#key-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api('/api/v1/keys', {method: 'POST', body: JSON.stringify(formJSON(event.target))});
|
||||
event.target.closest('dialog').close();
|
||||
event.target.reset();
|
||||
showSecret('API key issued', 'This API key is shown once. Copy it now.', result.secret);
|
||||
await load()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
});
|
||||
$('#contract-form').addEventListener('submit', async event => submit(event, '/api/v1/contracts', 'Contract imported'));
|
||||
$('#user-form').addEventListener('submit', async event => submit(event, '/api/v1/users', 'User invited'));
|
||||
$('#client-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const data = formJSON(event.target), id = data.application_id;
|
||||
data.redirect_uris = data.redirect_uris.split('\n').map(v => v.trim()).filter(Boolean);
|
||||
delete data.application_id;
|
||||
delete data.client_id;
|
||||
try {
|
||||
const result = await api(`/api/v1/applications/${id}/client-config`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
event.target.closest('dialog').close();
|
||||
showSecret('Client secret rotated', 'Update the client application now. This secret is shown once.', result.client_secret);
|
||||
await load()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
});
|
||||
$('#access-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.target, data = formJSON(form);
|
||||
data.application_ids = [...form.querySelectorAll('input[name=application_ids]:checked')].map(input => input.value);
|
||||
const userID = data.user_id, approval = data.approval_mode === 'true';
|
||||
delete data.user_id;
|
||||
delete data.approval_mode;
|
||||
if (approval) data.tenant_id = data.approval_tenant_id;
|
||||
delete data.approval_tenant_id;
|
||||
try {
|
||||
await api(`/api/v1/users/${userID}/${approval ? 'approve' : 'access'}`, {
|
||||
method: approval ? 'POST' : 'PUT', body: JSON.stringify(data)
|
||||
});
|
||||
form.closest('dialog').close();
|
||||
notice(approval ? 'User approved' : 'User access updated');
|
||||
await load()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(event, path, message) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api(path, {method: 'POST', body: JSON.stringify(formJSON(event.target))});
|
||||
event.target.closest('dialog').close();
|
||||
event.target.reset();
|
||||
notice(message);
|
||||
await load()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async event => {
|
||||
const revoke = event.target.dataset.revoke;
|
||||
if (revoke) {
|
||||
if (!confirm('Revoke this key? Existing integrations will stop working.')) return;
|
||||
try {
|
||||
await api(`/api/v1/keys/${revoke}/revoke`, {method: 'POST'});
|
||||
notice('Key revoked');
|
||||
await load()
|
||||
} catch (error) {
|
||||
notice(error.message, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
const clientID = event.target.dataset.client;
|
||||
if (clientID) {
|
||||
const app = state.applications.find(item => item.id === clientID), form = $('#client-form');
|
||||
form.application_id.value = app.id;
|
||||
form.client_id.value = app.client_id;
|
||||
form.redirect_uris.value = app.redirect_uris.join('\n');
|
||||
$('#client-dialog').showModal();
|
||||
return
|
||||
}
|
||||
const userID = event.target.dataset.access;
|
||||
if (userID) {
|
||||
const user = state.users.find(item => item.id === userID && item.tenant_id === event.target.dataset.tenant),
|
||||
form = $('#access-form');
|
||||
form.user_id.value = user.id;
|
||||
form.tenant_id.value = user.tenant_id;
|
||||
form.approval_mode.value = 'false';
|
||||
$('#approval-tenant-label').hidden = true;
|
||||
form.role.value = user.role;
|
||||
renderAccessApps(user.tenant_id, user.application_ids);
|
||||
$('#access-dialog').showModal();
|
||||
return
|
||||
}
|
||||
const pendingID = event.target.dataset.pending;
|
||||
if (pendingID) {
|
||||
const form = $('#access-form');
|
||||
form.user_id.value = pendingID;
|
||||
form.approval_mode.value = 'true';
|
||||
$('#approval-tenant-label').hidden = false;
|
||||
form.approval_tenant_id.value = state.selectedTenant;
|
||||
form.role.value = 'viewer';
|
||||
renderAccessApps(state.selectedTenant, []);
|
||||
$('#access-dialog').showModal()
|
||||
}
|
||||
});
|
||||
$('#copy-secret').addEventListener('click', () => navigator.clipboard.writeText($('#new-secret').textContent).then(() => notice('Secret copied')));
|
||||
|
||||
function showSecret(title, description, secret) {
|
||||
$('#secret-title').textContent = title;
|
||||
$('#secret-description').textContent = description;
|
||||
$('#new-secret').textContent = 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)
|
||||
});
|
||||
$('#tenant-context').addEventListener('change', event => {
|
||||
state.selectedTenant = event.target.value;
|
||||
render()
|
||||
});
|
||||
$('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, []));
|
||||
|
||||
function renderAccessApps(tenantID, selected) {
|
||||
$('#access-apps').innerHTML = state.applications.filter(app => app.tenant_id === tenantID).map(app => `<label><input type="checkbox" name="application_ids" value="${escapeHTML(app.id)}" ${selected.includes(app.id) ? 'checked' : ''}><span>${escapeHTML(app.name)}</span></label>`).join('') || '<p>No applications</p>'
|
||||
}
|
||||
load().catch(error => notice(error.message, true));
|
||||
27
web/client-login.html
Normal file
27
web/client-login.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!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"><title>{{TENANT_NAME}} login · Go Loose</title><link rel="stylesheet" href="/assets/style.css"></head>
|
||||
<body class="login-page">
|
||||
<div class="login-glow"></div>
|
||||
<main class="terminal client-terminal">
|
||||
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@identity-broker:~</strong></div>
|
||||
<div class="terminal-body">
|
||||
<p class="eyebrow">{{APPLICATION_NAME}} · SECURE ACCESS</p>
|
||||
<h1>{{TENANT_NAME}} login.<br><span>Continue securely.</span></h1>
|
||||
<p><span class="prompt">identity@{{TENANT_NAME}}:~$</span> verify access with your organization</p>
|
||||
<p class="muted">Go Loose will return you to {{APPLICATION_NAME}} after your identity and application access are verified.</p>
|
||||
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
|
||||
<input type="hidden" name="return" value="{{RETURN_URL}}">
|
||||
<label>Email<input type="email" name="email" autocomplete="username" required></label>
|
||||
<label>Password<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">LOGIN TO {{APPLICATION_NAME}}</button>
|
||||
</form>
|
||||
<div style="{{SSO_STYLE}}"><p class="login-divider">or use your organization</p>
|
||||
<a class="terminal-login" href="{{LOGIN_URL}}"><span>›</span> CONTINUE WITH SSO</a></div>
|
||||
<p class="cursor-line"><span class="prompt">identity@go-loose:~$</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>
|
||||
</body>
|
||||
</html>
|
||||
23
web/docs.html
Normal file
23
web/docs.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!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">
|
||||
<title>Go Loose · API documentation</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body class="docs-page">
|
||||
<header><a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
|
||||
<nav><a href="/">← Dashboard</a></nav>
|
||||
</header>
|
||||
<div id="swagger-ui"></div>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>SwaggerUIBundle({
|
||||
url: '/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
persistAuthorization: false
|
||||
})</script>
|
||||
</body>
|
||||
</html>
|
||||
8
web/embed.go
Normal file
8
web/embed.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains the dependency-free management console and API documentation shell.
|
||||
//
|
||||
//go:embed *.html *.css *.js *.json
|
||||
var Files embed.FS
|
||||
95
web/index.html
Normal file
95
web/index.html
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<!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 · API access control</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="/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>
|
||||
<span id="identity" class="identity"></span>
|
||||
<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">MULTI-TENANT API ACCESS CONTROL</p>
|
||||
<h1>One key plane.<br><span>Every Go API.</span></h1>
|
||||
<p class="lede">Register applications, issue scoped keys, import OpenAPI contracts, and authorize requests without coupling identity logic to every service.</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="pulse"></div>
|
||||
<div><strong>Authorization plane</strong><span>Operational</span></div>
|
||||
<code>POST /api/v1/authorize</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="notice" class="notice" hidden></div>
|
||||
|
||||
<section class="stats" id="stats">
|
||||
<article><span>Tenants</span><strong id="tenant-count">—</strong></article>
|
||||
<article><span>Applications</span><strong id="app-count">—</strong></article>
|
||||
<article><span>API keys</span><strong id="key-count">—</strong></article>
|
||||
<article><span>Contracts</span><strong id="contract-count">—</strong></article>
|
||||
<article><span>Users</span><strong id="user-count">—</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<aside>
|
||||
<button class="tab active" data-panel="applications">01 <span>Applications</span></button>
|
||||
<button class="tab" data-panel="keys">02 <span>API keys</span></button>
|
||||
<button class="tab" data-panel="contracts">03 <span>Contracts</span></button>
|
||||
<button class="tab" data-panel="tenants">04 <span>Tenants</span></button>
|
||||
<button class="tab" data-panel="users">05 <span>Users</span></button>
|
||||
</aside>
|
||||
|
||||
<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="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="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="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">
|
||||
<div class="panel-head"><div><p class="eyebrow">ORGANIZATIONS</p><h2>Tenants</h2></div><button id="new-tenant" data-dialog="tenant-dialog">New tenant</button></div>
|
||||
<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="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>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="tenant-dialog"><form id="tenant-form"><div class="dialog-head"><h3>Create tenant</h3><button type="button" class="close">×</button></div><label>Name<input name="name" required></label><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="nmbs"></label><button type="submit">Create tenant</button></form></dialog>
|
||||
<dialog id="app-dialog"><form id="app-form"><div class="dialog-head"><h3>Register application</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Name<input name="name" required></label><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="guess"></label><label>Description<textarea name="description"></textarea></label><label>Allowed hosts <small>(one per line, empty allows all)</small><textarea name="allowed_hosts" placeholder="nmbs.guess.local"></textarea></label><button type="submit">Register application</button></form></dialog>
|
||||
<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"><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="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>
|
||||
</body>
|
||||
</html>
|
||||
25
web/install.html
Normal file
25
web/install.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!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"><title>Install Go Loose</title><link rel="stylesheet" href="/assets/style.css"></head>
|
||||
<body class="login-page">
|
||||
<div class="login-glow"></div>
|
||||
<main class="terminal installer">
|
||||
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@installer:~</strong></div>
|
||||
<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>
|
||||
<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 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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
30
web/login.html
Normal file
30
web/login.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!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"><title>Go Loose · Login</title><link rel="stylesheet" href="/assets/style.css"></head>
|
||||
<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-body">
|
||||
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
|
||||
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
|
||||
██║ ███╗██║ ██║ ██║ ██║ ██║██║ ██║███████╗█████╗
|
||||
██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝
|
||||
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
|
||||
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
|
||||
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p>
|
||||
<p class="muted">Sign in with a database account or continue through your organization's identity provider.</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>
|
||||
<label>Password<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">LOGIN</button>
|
||||
</form>
|
||||
<div style="{{SSO_STYLE}}"><p class="login-divider">or use your organization</p>
|
||||
<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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
240
web/openapi.json
Normal file
240
web/openapi.json
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Go Loose API",
|
||||
"version": "0.1.0",
|
||||
"description": "Multi-tenant API-key management and runtime authorization."
|
||||
},
|
||||
"servers": [{"url": "/"}],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"session": {"type": "apiKey", "in": "cookie", "name": "go_loose_session"},
|
||||
"apiKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"},
|
||||
"clientBasic": {"type": "http", "scheme": "basic"},
|
||||
"userBearer": {"type": "http", "scheme": "bearer"}
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {"type": "object", "properties": {"error": {"type": "string"}}},
|
||||
"AuthorizationRequest": {
|
||||
"type": "object",
|
||||
"required": ["application", "method", "path"],
|
||||
"properties": {
|
||||
"tenant": {"type": "string", "example": "nmbs"},
|
||||
"application": {"type": "string", "example": "guess"},
|
||||
"method": {"type": "string", "example": "GET"},
|
||||
"path": {"type": "string", "example": "/api/guesses"},
|
||||
"host": {"type": "string", "example": "nmbs.guess.local"}
|
||||
}
|
||||
},
|
||||
"AuthorizationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allowed": {"type": "boolean"},
|
||||
"reason": {"type": "string"},
|
||||
"tenant_id": {"type": "string", "format": "uuid"},
|
||||
"tenant_slug": {"type": "string"},
|
||||
"application_id": {"type": "string", "format": "uuid"},
|
||||
"application_slug": {"type": "string"},
|
||||
"api_key_id": {"type": "string", "format": "uuid"},
|
||||
"api_key_name": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Monitoring"
|
||||
],
|
||||
"summary": "Liveness check",
|
||||
"responses": {"200": {"description": "Healthy"}}
|
||||
}
|
||||
},
|
||||
"/install": {
|
||||
"get": {
|
||||
"tags": ["Installation"],
|
||||
"summary": "Show the first-run installer",
|
||||
"responses": {"200": {"description": "Installer form"}, "303": {"description": "Already installed"}}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Installation"],
|
||||
"summary": "Persist Google OIDC configuration and optional demo data",
|
||||
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object", "required": ["csrf_token", "oidc_client_id", "oidc_client_secret"], "properties": {"csrf_token": {"type": "string"}, "oidc_client_id": {"type": "string"}, "oidc_client_secret": {"type": "string", "format": "password"}, "seed_demo": {"type": "boolean"}}}}}},
|
||||
"responses": {"303": {"description": "Installation completed; continue to Google OIDC login"}, "409": {"description": "Already installed"}, "502": {"description": "Google OIDC discovery failed"}}
|
||||
}
|
||||
},
|
||||
"/auth/password": {
|
||||
"post": {
|
||||
"tags": ["User Action"],
|
||||
"summary": "Authenticate a database-backed user",
|
||||
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object", "required": ["email", "password"], "properties": {"email": {"type": "string", "format": "email"}, "password": {"type": "string", "format": "password"}, "return": {"type": "string", "format": "uri"}}}}}},
|
||||
"responses": {"303": {"description": "Login succeeded"}, "401": {"description": "Invalid email or password"}, "429": {"description": "Temporarily rate limited"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/authorize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Authorization Management"
|
||||
],
|
||||
"summary": "Authorize an incoming API request",
|
||||
"security": [{"apiKey": []}],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthorizationRequest"}}}},
|
||||
"responses": {
|
||||
"200": {"description": "Authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthorizationResponse"}}}},
|
||||
"401": {"description": "Missing or invalid key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
||||
"403": {"description": "Key not allowed for this request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthorizationResponse"}}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Dashboard Management"
|
||||
],
|
||||
"summary": "Get management console data",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"200": {"description": "Dashboard data"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/tenants": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Tenant Management"
|
||||
],
|
||||
"summary": "Create a tenant",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"201": {"description": "Tenant created"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/applications": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Application Management"
|
||||
],
|
||||
"summary": "Register an application",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"201": {"description": "Application registered"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/keys": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Key Management"
|
||||
],
|
||||
"summary": "Issue an API key",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"201": {"description": "Key created; plaintext secret is returned once"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/keys/{id}/revoke": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Key Management"
|
||||
],
|
||||
"summary": "Revoke an API key",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"responses": {"204": {"description": "Key revoked"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/contracts": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Contract Management"
|
||||
],
|
||||
"summary": "Import a pasted contract or scan an OpenAPI URL",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"201": {"description": "Contract imported"}}
|
||||
}
|
||||
},
|
||||
"/connect/authorize": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"User Action"
|
||||
],
|
||||
"summary": "Start hosted client application login",
|
||||
"parameters": [
|
||||
{"in": "query", "name": "response_type", "required": true, "schema": {"type": "string", "const": "code"}},
|
||||
{"in": "query", "name": "client_id", "required": true, "schema": {"type": "string"}},
|
||||
{"in": "query", "name": "redirect_uri", "required": true, "schema": {"type": "string", "format": "uri"}},
|
||||
{"in": "query", "name": "state", "required": true, "schema": {"type": "string"}}
|
||||
],
|
||||
"responses": {"302": {"description": "Redirect to login or exact client callback"}}
|
||||
}
|
||||
},
|
||||
"/connect/token": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"User Action"
|
||||
],
|
||||
"summary": "Exchange a one-time authorization code",
|
||||
"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"}}
|
||||
}
|
||||
},
|
||||
"/connect/userinfo": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"User Action"
|
||||
],
|
||||
"summary": "Validate a client user session",
|
||||
"security": [{"userBearer": []}],
|
||||
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
|
||||
}
|
||||
},
|
||||
"/connect/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"User Action"
|
||||
],
|
||||
"summary": "Revoke a client user session",
|
||||
"security": [{"userBearer": []}],
|
||||
"responses": {"204": {"description": "Session revoked"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/applications/{id}/client-config": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Application Management"
|
||||
],
|
||||
"summary": "Configure exact redirect URIs and rotate the client secret",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"responses": {"200": {"description": "Configuration saved; client secret returned once"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"User Management"
|
||||
],
|
||||
"summary": "Invite a tenant user",
|
||||
"security": [{"session": []}],
|
||||
"responses": {"201": {"description": "User invited"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}/access": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"User Management"
|
||||
],
|
||||
"summary": "Set tenant role and application access",
|
||||
"security": [{"session": []}],
|
||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||
"responses": {"204": {"description": "Access updated"}}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}/approve": {
|
||||
"post": {
|
||||
"tags": ["User Management"],
|
||||
"summary": "Approve a waiting-room user into a tenant",
|
||||
"description": "Requires a system administrator session.",
|
||||
"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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
web/style.css
Normal file
18
web/style.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
:root{--bg:#16161e;--surface:#1f2335;--surface2:#24283b;--text:#c0caf5;--muted:#7aa2f7;--accent:#7dcfff;--accent2:#bb9af7;--good:#9ece6a;--danger:#f7768e;--border:#3b4261;--shadow:rgba(0,0,0,.35)}
|
||||
[data-theme=catppuccin]{--bg:#1e1e2e;--surface:#181825;--surface2:#313244;--text:#cdd6f4;--muted:#89b4fa;--accent:#94e2d5;--accent2:#cba6f7;--good:#a6e3a1;--danger:#f38ba8;--border:#45475a}
|
||||
[data-theme=tomorrow]{--bg:#1d1f21;--surface:#282a2e;--surface2:#373b41;--text:#c5c8c6;--muted:#81a2be;--accent:#8abeb7;--accent2:#b294bb;--good:#b5bd68;--danger:#cc6666;--border:#4d5057}
|
||||
[data-theme=kanagawa]{--bg:#1f1f28;--surface:#2a2a37;--surface2:#363646;--text:#dcd7ba;--muted:#7e9cd8;--accent:#7fb4ca;--accent2:#957fb8;--good:#98bb6c;--danger:#e46876;--border:#54546d}
|
||||
[data-mode=light][data-theme=tokyo]{--bg:#e6e7ed;--surface:#fff;--surface2:#dfe2ed;--text:#343b58;--muted:#34548a;--accent:#0f4b6e;--accent2:#5a4a78;--good:#485e30;--danger:#8c4351;--border:#b4b5c3;--shadow:rgba(52,59,88,.15)}
|
||||
[data-mode=light][data-theme=catppuccin]{--bg:#eff1f5;--surface:#fff;--surface2:#e6e9ef;--text:#4c4f69;--muted:#1e66f5;--accent:#179299;--accent2:#8839ef;--good:#40a02b;--danger:#d20f39;--border:#bcc0cc;--shadow:rgba(76,79,105,.15)}
|
||||
[data-mode=light][data-theme=tomorrow]{--bg:#f5f5f5;--surface:#fff;--surface2:#e8e8e8;--text:#4d4d4c;--muted:#4271ae;--accent:#3e999f;--accent2:#8959a8;--good:#718c00;--danger:#c82829;--border:#c9c9c9;--shadow:rgba(77,77,76,.15)}
|
||||
[data-mode=light][data-theme=kanagawa]{--bg:#f2ecde;--surface:#fffaf0;--surface2:#e7dba9;--text:#545464;--muted:#4d699b;--accent:#4d7f8c;--accent2:#624c83;--good:#6f894e;--danger:#c34043;--border:#c8c093;--shadow:rgba(84,84,100,.15)}
|
||||
*{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}
|
||||
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}
|
||||
.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%}}
|
||||
16
web/waiting-room.html
Normal file
16
web/waiting-room.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!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"><title>Waiting for access · Go Loose</title><link rel="stylesheet" href="/assets/style.css"></head>
|
||||
<body class="login-page">
|
||||
<div class="login-glow"></div>
|
||||
<main class="terminal client-terminal">
|
||||
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@waiting-room:~</strong></div>
|
||||
<div class="terminal-body">
|
||||
<p class="eyebrow">IDENTITY VERIFIED</p>
|
||||
<h1>Access pending.<br><span>You are in the waiting room.</span></h1>
|
||||
<p class="muted">A Go Loose administrator must assign your account to at least one tenant. No tenant or application data is visible while you wait.</p>
|
||||
<form action="/auth/logout" method="post"><button class="terminal-login" type="submit">LOG OUT</button></form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue