summaryrefslogtreecommitdiff
path: root/internal/adminui/assets
diff options
context:
space:
mode:
Diffstat (limited to 'internal/adminui/assets')
-rw-r--r--internal/adminui/assets/app.js44
-rw-r--r--internal/adminui/assets/index.html48
-rw-r--r--internal/adminui/assets/style.css5
3 files changed, 76 insertions, 21 deletions
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js
index 6b8bf87..1a7469b 100644
--- a/internal/adminui/assets/app.js
+++ b/internal/adminui/assets/app.js
@@ -1,5 +1,5 @@
const state = {
- token: sessionStorage.getItem('aigw_admin_token') || '', actor: {}, permissions: new Set(), overview: {},
+ token: '', csrf: '', actor: {}, permissions: new Set(), overview: {},
tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [],
usage: [], usageSummary: [], limits: [], users: [], audit: []
};
@@ -13,15 +13,21 @@ function shortID(value) { const text = String(value || ''); return text ? `${tex
function percent(part, total) { return total ? `${Math.round((part / total) * 100)}%` : '—'; }
function toast(message, error = false) { const node = $('#toast'); node.textContent = message; node.className = `toast visible ${error ? 'error' : ''}`; setTimeout(() => { node.className = 'toast'; }, 3200); }
async function api(path, options = {}) {
- const response = await fetch(`./api${path}`, { ...options, headers: { 'Content-Type':'application/json', 'Authorization':`Bearer ${state.token}`, ...(options.headers || {}) } });
+ const method = (options.method || 'GET').toUpperCase();
+ const headers = { ...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {}) };
+ if (state.token) headers.Authorization = `Bearer ${state.token}`;
+ if (!['GET','HEAD','OPTIONS'].includes(method) && state.csrf && !path.startsWith('/auth/login') && !path.startsWith('/auth/register')) headers['X-CSRF-Token'] = state.csrf;
+ const response = await fetch(`./api${path}`, { ...options, method, credentials:'same-origin', headers });
const payload = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(payload?.error?.message || `Request failed (${response.status})`);
+ if (!response.ok) { const error = new Error(payload?.error?.message || `Request failed (${response.status})`); error.status=response.status; error.type=payload?.error?.type; throw error; }
return payload;
}
function setConnected(connected) {
- $('#connection-state').textContent = connected ? state.actor.role?.replaceAll('_', ' ') || 'Connected' : 'Offline';
- $('#connection-state').className = `state ${connected ? 'online' : ''}`;
- $('#actor-label').textContent = connected ? state.actor.display_name || state.actor.email || '' : '';
+ $('#auth-screen').classList.toggle('hidden', connected);
+ $('#console-app').classList.toggle('hidden', !connected);
+ if (!connected) return;
+ $('#connection-state').textContent = state.actor.role?.replaceAll('_', ' ') || 'connected';
+ $('#actor-label').textContent = state.actor.display_name || state.actor.email || 'Operator';
}
function formJSON(form) { return Object.fromEntries(new FormData(form).entries()); }
function selectOptions(items, valueKey, labelKey, empty = 'Select…') { return `<option value="">${empty}</option>${items.map(item => `<option value="${esc(item[valueKey])}">${esc(item[labelKey])}</option>`).join('')}`; }
@@ -37,12 +43,13 @@ function money(micros, currency = state.overview.billing_currency || 'usd') { re
function integer(value) { return new Intl.NumberFormat().format(Number(value || 0)); }
function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; }
function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); }
+function cookie(name) { const prefix=`${encodeURIComponent(name)}=`; const value=document.cookie.split('; ').find(item=>item.startsWith(prefix)); return value ? decodeURIComponent(value.slice(prefix.length)) : ''; }
+function authError(message='') { $('#auth-error').textContent=message; }
async function permitted(permission, path) { if (!can(permission)) return []; return api(path); }
-async function loadAll() {
- if (!state.token) { setConnected(false); return; }
+async function loadAll(knownSession = null) {
try {
- const session = await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []);
+ const session = knownSession || await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []);
state.overview = await api('/overview');
const results = await Promise.all([
permitted('tenants.read','/tenants'), permitted('projects.read','/projects'), permitted('keys.read','/keys'),
@@ -52,14 +59,15 @@ async function loadAll() {
state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : []
]);
[state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger] = results;
- renderAll(); setConnected(true);
- } catch (error) { setConnected(false); toast(error.message, true); }
+ renderAll(); setConnected(true); return true;
+ } catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; }
}
function applyPermissions() {
$$('[data-permission]').forEach(node => node.classList.toggle('hidden', !can(node.dataset.permission)));
$('#billing-tab').classList.toggle('hidden', !state.overview.billing_enabled || !can('billing.read'));
$('#topup-form').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup'));
+ $('#account-tab').classList.toggle('hidden', !state.actor.id);
const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="overview"]').click();
}
function renderAll() {
@@ -100,13 +108,14 @@ function renderLimits() {
function renderUsers() {
const roles = state.actor.tenant_id ? [['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']] : [['platform_admin','Platform admin'],['platform_viewer','Platform viewer'],['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']];
$('#user-role').innerHTML=roles.map(([value,label])=>`<option value="${value}">${label}</option>`).join('');
- $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><code>${esc(item.token_prefix)}</code></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7);
+ $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="badge ${item.has_password?'active':'suspended'}">${item.has_password?'password':'legacy token'}</span></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7);
}
function renderAudit() { $('#audit-body').innerHTML=state.audit.map(item=>`<tr><td>${date(item.created_at)}</td><td><span class="tag">${esc(item.actor_role.replaceAll('_',' '))}</span></td><td>${esc(item.action)}</td><td><code>${esc(item.method)}</code></td><td><span class="badge ${item.status_code<400?'active':'suspended'}">${item.status_code}</span></td><td><code>${shortID(item.request_id)}</code></td><td><code>${esc(item.remote_ip||'—')}</code></td></tr>`).join('')||emptyRow(7); }
function renderRouteEditor() { const current=$('#route-editor');if(!current.children.length&&can('platform.write'))addRoute();$$('.route-provider').forEach(select=>{const selected=select.value;select.innerHTML=selectOptions(state.providers.filter(item=>item.enabled),'id','name','Provider…');select.value=selected;}); }
function addRoute() { const wrapper=document.createElement('div');wrapper.className='route-row';wrapper.innerHTML='<select class="route-provider" required></select><input class="route-upstream" required placeholder="Upstream model"><input class="route-priority" type="number" min="0" value="0" title="Priority"><input class="route-weight" type="number" min="1" max="100" value="100" title="Weight"><button class="icon-button remove-route" type="button" aria-label="Remove route">×</button>';$('#route-editor').appendChild(wrapper);renderRouteEditor(); }
document.addEventListener('click',async(event)=>{
+ const authTab=event.target.closest('.auth-tab');if(authTab){$$('.auth-tab').forEach(node=>node.classList.toggle('active',node===authTab));$$('.auth-pane').forEach(node=>node.classList.toggle('active',node.id===authTab.dataset.authPane));authError();return;}
const tab=event.target.closest('.tab');if(tab){$$('.tab').forEach(node=>node.classList.toggle('active',node===tab));$$('.section').forEach(node=>node.classList.toggle('active',node.id===tab.dataset.section));return;}
if(event.target.id==='reload'){try{await api('/reload',{method:'POST',body:'{}'});await loadAll();toast('Snapshot reloaded');}catch(error){toast(error.message,true);}}
if(event.target.id==='add-route')addRoute();if(event.target.closest('.remove-route'))event.target.closest('.route-row').remove();
@@ -118,7 +127,10 @@ document.addEventListener('click',async(event)=>{
});
$('#key-tenant').addEventListener('change',renderKeyProjects);
-$('#session-form').addEventListener('submit',async(event)=>{event.preventDefault();state.token=$('#admin-token').value.trim();sessionStorage.setItem('aigw_admin_token',state.token);await loadAll();});
+$('#login-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/login',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}});
+$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}});
+$('#bootstrap-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();state.csrf='';state.token=formJSON(event.target).token.trim();if(!await loadAll()){state.token='';authError('Bootstrap token is invalid');}event.target.reset();});
+$('#sign-out').addEventListener('click',async()=>{try{if(!state.token)await api('/auth/logout',{method:'POST',body:'{}'});}catch(error){if(error.status!==401)toast(error.message,true);}state.token='';state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);});
$('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}});
$('#project-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/projects',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Project created');}catch(error){toast(error.message,true);}});
$('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});event.target.reset();showSecret('API key created',result.key);await loadAll();}catch(error){toast(error.message,true);}});
@@ -126,6 +138,8 @@ $('#provider-form').addEventListener('submit',async(event)=>{event.preventDefaul
$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}});
$('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
$('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}});
-$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const result=await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showSecret('Console token issued',result.token);await loadAll();}catch(error){toast(error.message,true);}});
+$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Member created');}catch(error){toast(error.message,true);}});
+$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.');}catch(error){toast(error.message,true);}});
$('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');});
-$('#admin-token').value=state.token;applyPermissions();if(state.token)loadAll();
+async function start(){try{const config=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!config.registration_enabled);if(!config.registration_enabled&&$('#register-tab').classList.contains('active'))$('.auth-tab[data-auth-pane="login-pane"]').click();state.csrf=cookie('aigw_csrf');const session=await api('/auth/session');if(session.authenticated)await loadAll(session);else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
+start();
diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html
index c299037..ca9f282 100644
--- a/internal/adminui/assets/index.html
+++ b/internal/adminui/assets/index.html
@@ -8,9 +8,43 @@
<link rel="stylesheet" href="./style.css">
</head>
<body>
+ <section class="auth-screen" id="auth-screen">
+ <div class="auth-brand"><span class="brand-mark">A</span><div><strong>AIGW</strong><small>CONTROL PLANE</small></div></div>
+ <div class="auth-panel">
+ <div class="auth-tabs" role="tablist" aria-label="Account access">
+ <button class="auth-tab active" type="button" data-auth-pane="login-pane">Sign in</button>
+ <button class="auth-tab" type="button" data-auth-pane="register-pane" id="register-tab">Create account</button>
+ <button class="auth-tab" type="button" data-auth-pane="bootstrap-pane">Operator</button>
+ </div>
+ <form class="auth-pane active" id="login-pane">
+ <div><span class="eyebrow">ACCOUNT ACCESS</span><h1>Sign in</h1></div>
+ <label>Email<input name="email" type="email" required autocomplete="username" placeholder="you@company.com"></label>
+ <label>Password<input name="password" type="password" required autocomplete="current-password"></label>
+ <button class="button primary" type="submit">Sign in</button>
+ </form>
+ <form class="auth-pane" id="register-pane">
+ <div><span class="eyebrow">NEW WORKSPACE</span><h1>Create account</h1></div>
+ <label>Organization<input name="organization" required autocomplete="organization" placeholder="Acme Inc."></label>
+ <label>Workspace slug<input name="tenant_slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label>
+ <label>Your name<input name="display_name" required autocomplete="name"></label>
+ <label>Email<input name="email" type="email" required autocomplete="email"></label>
+ <label>Password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label>
+ <button class="button primary" type="submit">Create account</button>
+ </form>
+ <form class="auth-pane" id="bootstrap-pane">
+ <div><span class="eyebrow">BREAK GLASS</span><h1>Operator access</h1></div>
+ <input class="visually-hidden" name="username" autocomplete="username" value="aigw-operator" aria-hidden="true" tabindex="-1">
+ <label>Bootstrap token<input name="token" type="password" required autocomplete="off"></label>
+ <button class="button primary" type="submit">Continue</button>
+ </form>
+ <p class="auth-error" id="auth-error" role="alert"></p>
+ </div>
+ </section>
+
+ <div id="console-app" class="hidden">
<header class="topbar">
<div class="brand"><span class="brand-mark">A</span><div><strong>AIGW</strong><small>CONTROL PLANE</small></div></div>
- <form class="session" id="session-form"><span id="actor-label" class="actor-label"></span><input class="visually-hidden" name="username" autocomplete="username" value="aigw-console" aria-hidden="true" tabindex="-1"><input id="admin-token" name="admin-token" type="password" placeholder="Console token" autocomplete="current-password" aria-label="Console token"><button type="submit">Connect</button><span id="connection-state" class="state">Offline</span></form>
+ <div class="session"><div><strong id="actor-label" class="actor-label"></strong><span id="connection-state" class="state"></span></div><button type="button" id="sign-out">Sign out</button></div>
</header>
<main class="shell">
<nav class="tabs" aria-label="Admin sections">
@@ -21,6 +55,7 @@
<button class="tab" data-section="keys" data-permission="keys.read">API keys</button>
<button class="tab" data-section="limits" data-permission="limits.read">Limits</button>
<button class="tab" data-section="team" data-permission="users.read">Team</button>
+ <button class="tab" data-section="account" id="account-tab">Account</button>
<button class="tab" data-section="audit" data-permission="audit.read">Audit</button>
<button class="tab" data-section="tenants" data-permission="tenants.read">Tenants</button>
<button class="tab" data-section="providers" data-permission="platform.read">Providers</button>
@@ -90,9 +125,13 @@
<section id="team" class="section">
<div class="section-heading"><div><span class="eyebrow">RBAC</span><h1>Console access</h1></div></div>
- <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><button class="button primary" type="submit">Issue console token</button></form>
- <div class="panel warning"><strong>Token visibility</strong><span>The console token is shown only once after creation.</span></div>
- <div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Token</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div>
+ <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><label>Temporary password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Create member</button></form>
+ <div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Login</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div>
+ </section>
+
+ <section id="account" class="section">
+ <div class="section-heading"><div><span class="eyebrow">SECURITY</span><h1>Account</h1></div></div>
+ <form class="panel form-grid compact-form" id="password-form"><input class="visually-hidden" name="username" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>New password<input name="new_password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Change password</button></form>
</section>
<section id="audit" class="section">
@@ -100,6 +139,7 @@
<div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Method</th><th>Status</th><th>Request</th><th>IP</th></tr></thead><tbody id="audit-body"></tbody></table></div>
</section>
</main>
+ </div>
<div id="toast" class="toast" role="status"></div>
<dialog id="secret-dialog"><div class="dialog-content"><div class="section-heading"><div><span class="eyebrow">ONE-TIME SECRET</span><h2 id="secret-title">Credential created</h2></div><button class="icon-button" id="close-dialog" aria-label="Close">×</button></div><p>Copy this credential now. It will not be shown again.</p><code id="created-secret"></code><button class="button primary" id="copy-secret">Copy credential</button></div></dialog>
<script src="./app.js" defer></script>
diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css
index b158d5b..745378f 100644
--- a/internal/adminui/assets/style.css
+++ b/internal/adminui/assets/style.css
@@ -1,6 +1,7 @@
:root { --bg:#f3f5f7; --panel:#fff; --ink:#18212b; --muted:#71808e; --line:#dce3e8; --accent:#146c94; --accent-soft:#e5f2f7; --danger:#b4494d; --shadow:0 8px 24px rgba(29,47,61,.06); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
* { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select { font:inherit; } button { cursor:pointer; }
-.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:8px; } .session input { width:220px; border:1px solid #3b5b6c; background:#18384b; color:#fff; padding:9px 11px; outline:none; } .session input::placeholder { color:#91acb9; } .session button { min-height:40px; border:1px solid #8fd0df; background:#b8eef7; color:#102a3a; padding:0 14px; font-weight:750; } .session button:hover { background:#d4f6fb; } .state { color:#9db0bb; font-size:12px; text-transform:capitalize; } .state.online { color:#86d5ad; } .actor-label { max-width:190px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#c6d7df; font-size:12px; }
+.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,520px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,6vw,72px); overflow:auto; } .auth-tabs { display:flex; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; }
+.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:12px; } .session div { display:flex; flex-direction:column; align-items:flex-end; gap:2px; } .session button { min-height:36px; border:1px solid #8fd0df; background:transparent; color:#b8eef7; padding:0 13px; font-weight:700; } .session button:hover { background:#18384b; } .state { color:#86d5ad; font-size:11px; text-transform:capitalize; } .actor-label { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#fff; font-size:12px; }
.shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; }
.section { display:none; } .section.active { display:block; } .section-heading { display:flex; justify-content:space-between; align-items:flex-end; gap:20px; margin-bottom:19px; } .eyebrow { color:var(--accent); font-size:10px; letter-spacing:0; font-weight:800; } h1 { font-size:28px; line-height:1.1; margin:7px 0 0; letter-spacing:0; } h2 { margin:4px 0 0; font-size:20px; }
.metric-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:12px; } .metric { background:var(--panel); border:1px solid var(--line); padding:18px; box-shadow:var(--shadow); } .metric span,.metric small { display:block; color:var(--muted); } .metric strong { display:block; font-size:28px; margin:12px 0 3px; font-weight:750; } .metric small { font-size:11px; }
@@ -13,4 +14,4 @@
.muted,.error-label { display:block; color:var(--muted); font-size:11px; margin-top:4px; } .error-label { color:var(--danger); } .limits-table input { min-width:118px; padding:8px 9px; } .limits-table .button { min-height:36px; } input:disabled,select:disabled { background:#f5f7f8; color:#697985; cursor:not-allowed; }
.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; }
@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions { grid-template-columns:1fr; } }
-@media (max-width:620px) { .topbar { height:auto; padding:16px; align-items:flex-start; flex-direction:column; } .session { width:100%; display:grid; grid-template-columns:minmax(0,1fr) auto; } .session input { width:100%; min-width:0; } .session .actor-label,.session .state { grid-column:1/-1; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } }
+@media (max-width:620px) { .auth-screen { grid-template-columns:1fr; background:#fff; } .auth-brand { background:#102a3a; padding:22px; } .auth-panel { padding:28px 22px 50px; } .auth-tabs { overflow:auto; } .topbar { height:auto; padding:16px; align-items:flex-start; } .session { margin-left:auto; } .session div { align-items:flex-end; max-width:150px; } .session .actor-label { max-width:150px; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } }