summaryrefslogtreecommitdiff
path: root/internal/adminui/assets
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-05 14:48:00 +1200
committerChia <Chia@93.nz>2026-08-05 14:48:00 +1200
commitcd0dd91ab93653631904f2ea0e574ccde6d60339 (patch)
treec65417b880a3f4a35c504c44edae821bc2122f70 /internal/adminui/assets
parent86b1f42e3c5601ff10621a9779cf0076590797a1 (diff)
add passkey, totp.
Diffstat (limited to '')
-rw-r--r--internal/adminui/assets/app.js92
-rw-r--r--internal/adminui/assets/index.html43
-rw-r--r--internal/adminui/assets/style.css7
3 files changed, 124 insertions, 18 deletions
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js
index 1a7469b..1a965fc 100644
--- a/internal/adminui/assets/app.js
+++ b/internal/adminui/assets/app.js
@@ -1,7 +1,8 @@
const state = {
token: '', csrf: '', actor: {}, permissions: new Set(), overview: {},
tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [],
- usage: [], usageSummary: [], limits: [], users: [], audit: []
+ usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], sessions: [],
+ mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {}
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
@@ -44,7 +45,49 @@ function integer(value) { return new Intl.NumberFormat().format(Number(value ||
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; }
+function authError(message='', success=false) { const node=$('#auth-error');node.textContent=message;node.classList.toggle('success',Boolean(message)&&success); }
+function showAuthPane(id) {
+ $$('.auth-pane').forEach(node => node.classList.toggle('active', node.id === id));
+ $$('.auth-tab').forEach(node => node.classList.toggle('active', node.dataset.authPane === id));
+}
+
+function base64URLBytes(value) {
+ const normalized=String(value).replaceAll('-','+').replaceAll('_','/');
+ const padded=normalized+'='.repeat((4-normalized.length%4)%4);
+ return Uint8Array.from(atob(padded),char=>char.charCodeAt(0));
+}
+function bytesBase64URL(value) {
+ if (value == null) return null;
+ const bytes=new Uint8Array(value); let text=''; bytes.forEach(byte=>{text+=String.fromCharCode(byte);});
+ return btoa(text).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
+}
+function browserPublicKey(options) {
+ const value=structuredClone(options.publicKey||options);
+ value.challenge=base64URLBytes(value.challenge);
+ if(value.user?.id)value.user.id=base64URLBytes(value.user.id);
+ if(value.excludeCredentials)value.excludeCredentials=value.excludeCredentials.map(item=>({...item,id:base64URLBytes(item.id)}));
+ if(value.allowCredentials)value.allowCredentials=value.allowCredentials.map(item=>({...item,id:base64URLBytes(item.id)}));
+ return value;
+}
+function browserCredential(value) {
+ const response={clientDataJSON:bytesBase64URL(value.response.clientDataJSON)};
+ for(const key of ['attestationObject','authenticatorData','signature','userHandle']){
+ if(value.response[key]!=null)response[key]=bytesBase64URL(value.response[key]);
+ }
+ if(typeof value.response.getTransports==='function')response.transports=value.response.getTransports();
+ return {id:value.id,rawId:bytesBase64URL(value.rawId),type:value.type,response,
+ authenticatorAttachment:value.authenticatorAttachment,clientExtensionResults:value.getClientExtensionResults()};
+}
+async function createPasskey(options) {
+ const credential=await navigator.credentials.create({publicKey:browserPublicKey(options)});
+ if(!credential)throw new Error('Passkey creation was cancelled');
+ return browserCredential(credential);
+}
+async function getPasskey(options) {
+ const credential=await navigator.credentials.get({publicKey:browserPublicKey(options)});
+ if(!credential)throw new Error('Passkey authentication was cancelled');
+ return browserCredential(credential);
+}
async function permitted(permission, path) { if (!can(permission)) return []; return api(path); }
async function loadAll(knownSession = null) {
@@ -56,9 +99,12 @@ async function loadAll(knownSession = null) {
permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('usage.read','/usage'),
permitted('usage.read','/usage/summary'), permitted('limits.read','/limits'), permitted('users.read','/users'),
permitted('audit.read','/audit'), state.overview.billing_enabled ? permitted('billing.read','/billing/accounts') : [],
- state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : []
+ state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [],
+ state.actor.id ? api('/auth/mfa') : {totp_enabled:false,passkeys:[]},
+ state.actor.id ? api('/auth/sessions') : [],
+ state.actor.tenant_id && state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : []
]);
- [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;
+ [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders] = results;
renderAll(); setConnected(true); return true;
} catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; }
}
@@ -72,7 +118,7 @@ function applyPermissions() {
}
function renderAll() {
applyPermissions(); renderOverview(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
- renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor();
+ renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor(); renderSecurity();
}
function renderOverview() {
const data = state.overview; const propagation = !data.redis_configured ? 'PG polling' : data.redis_connected ? 'Redis live' : 'PG fallback';
@@ -97,6 +143,16 @@ function renderBilling() {
$('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`<tr><td><strong>${esc(item.tenant_name)}</strong><br><code>${shortID(item.tenant_id)}</code></td><td>${money(item.balance_micros,item.currency)}</td><td>${money(item.reserved_micros,item.currency)}</td><td><strong>${money(item.available_micros,item.currency)}</strong></td><td>${date(item.updated_at)}</td></tr>`).join('')||emptyRow(5);
$('#billing-ledger-body').innerHTML=state.ledger.map(item=>`<tr><td>${date(item.created_at)}</td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="tag">${esc(item.kind)}</span></td><td class="${item.amount_micros>=0?'money-positive':'money-negative'}">${money(item.amount_micros,item.currency)}</td><td>${money(item.balance_after_micros,item.currency)}</td><td title="${esc(item.description)}"><code>${shortID(item.source_id)}</code></td></tr>`).join('')||emptyRow(6);
}
+function renderSecurity() {
+ const totp=Boolean(state.mfa?.totp_enabled);
+ $$('#account [autocomplete="username"]').forEach(node=>{node.value=state.actor.email||'';});
+ $('#totp-action').textContent=totp?'Disable TOTP':'Set up TOTP';
+ $('#totp-disable-code').classList.toggle('hidden',!totp);
+ $('#totp-disable-code input').required=totp;
+ $('#sessions-body').innerHTML=(state.sessions||[]).map(item=>`<tr><td><strong>${esc(item.current?'Current device':'Browser session')}</strong><br><span class="muted">${esc(item.user_agent||'Unknown client')}</span></td><td><code>${esc(item.remote_ip||'—')}</code></td><td><span class="tag">${esc(item.auth_method)}</span></td><td>${date(item.last_seen_at)}</td><td><button class="text-button danger" data-revoke-session="${esc(item.id)}">Sign out</button></td></tr>`).join('')||emptyRow(5);
+ $('#passkeys-body').innerHTML=(state.mfa?.passkeys||[]).map(item=>`<tr><td><strong>${esc(item.name)}</strong></td><td>${date(item.created_at)}</td><td>${date(item.last_used_at)}</td><td><button class="text-button danger" data-delete-passkey="${esc(item.id)}">Delete</button></td></tr>`).join('')||emptyRow(4);
+ $('#orders-body').innerHTML=(state.orders||[]).map(item=>`<tr><td>${date(item.created_at)}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='paid'?'active':'suspended'}">${esc(item.status)}</span></td><td><code>${shortID(item.id)}</code></td></tr>`).join('')||emptyRow(4);
+}
function renderUsage() {
$('#usage-summary-body').innerHTML=state.usageSummary.map(item=>`<tr><td>${new Date(item.period_start).toLocaleDateString(undefined,{year:'numeric',month:'short'})}</td><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.input_tokens)}</td><td>${integer(item.output_tokens)}</td><td>${money(item.cost_micros)}</td></tr>`).join('')||emptyRow(7);
$('#usage-events-body').innerHTML=state.usage.map(item=>`<tr><td>${date(item.started_at)}</td><td><code title="${esc(item.request_id)}">${shortID(item.request_id)}</code></td><td>${esc(item.public_model)}</td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span>${item.error_type?`<small class="error-label">${esc(item.error_type)}</small>`:''}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td>${integer(item.duration_ms)} ms</td></tr>`).join('')||emptyRow(7);
@@ -108,7 +164,7 @@ 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><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);
+ $('#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>${item.email_verified?'<span class="badge active">verified</span>':'<span class="badge suspended">pending</span>'} ${(item.mfa_methods||[]).map(method=>`<span class="tag">${esc(method)}</span>`).join('')}</td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status!=='revoked'&&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;}); }
@@ -123,12 +179,21 @@ document.addEventListener('click',async(event)=>{
const revokeUser=event.target.closest('[data-revoke-user]');if(revokeUser&&confirm('Revoke this console credential?')){try{await api(`/users/${revokeUser.dataset.revokeUser}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('Console credential revoked');}catch(error){toast(error.message,true);}}
const provider=event.target.closest('[data-toggle-provider]');if(provider){try{await api(`/providers/${provider.dataset.toggleProvider}/toggle`,{method:'POST',body:JSON.stringify({enabled:provider.dataset.enabled==='true'})});await loadAll();toast('Provider updated');}catch(error){toast(error.message,true);}}
const model=event.target.closest('[data-toggle-model]');if(model){try{await api(`/models/${model.dataset.toggleModel}/toggle`,{method:'POST',body:JSON.stringify({enabled:model.dataset.enabled==='true'})});await loadAll();toast('Model updated');}catch(error){toast(error.message,true);}}
+ const session=event.target.closest('[data-revoke-session]');if(session&&confirm('Sign out this device?')){try{const result=await api(`/auth/sessions/${session.dataset.revokeSession}/revoke`,{method:'POST',body:'{}'});if(result.current){state.csrf='';state.actor={};setConnected(false);}else{await loadAll();toast('Device signed out');}}catch(error){toast(error.message,true);}}
+ const passkey=event.target.closest('[data-delete-passkey]');if(passkey&&confirm('Delete this passkey?')){const password=$('#passkey-form [name=current_password]').value;if(!password){toast('Enter your current password in the Passkey panel',true);}else{try{await api(`/auth/passkeys/${passkey.dataset.deletePasskey}/delete`,{method:'POST',body:JSON.stringify({current_password:password})});await loadAll();toast('Passkey deleted');}catch(error){toast(error.message,true);}}}
const save=event.target.closest('.save-limit');if(save){const row=save.closest('[data-limit-project]');try{await api(`/limits/${row.dataset.limitProject}`,{method:'POST',body:JSON.stringify({requests_per_minute:Number(row.querySelector('.limit-rpm').value),tokens_per_minute:Number(row.querySelector('.limit-tpm').value),concurrent_requests:Number(row.querySelector('.limit-concurrency').value),monthly_spend_micros:decimalToScaled(row.querySelector('.limit-spend').value,6)})});await loadAll();toast('Project limits updated');}catch(error){toast(error.message,true);}}
});
$('#key-tenant').addEventListener('change',renderKeyProjects);
-$('#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);}});
+async function completeBrowserLogin(result,form){state.csrf=result.csrf_token||cookie('aigw_csrf');state.pendingMFA=null;if(form)form.reset();await loadAll(result);}
+$('#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))});if(result.mfa_required){state.pendingMFA=result;$('#mfa-passkey').classList.toggle('hidden',!result.methods.includes('passkey'));showAuthPane('mfa-pane');}else await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}});
+$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showAuthPane('login-pane');authError('Verification email sent.',true);}catch(error){authError(error.message);}});
+$('#mfa-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();if(!state.pendingMFA)return showAuthPane('login-pane');try{const result=await api('/auth/mfa/totp',{method:'POST',body:JSON.stringify({challenge_token:state.pendingMFA.challenge_token,code:formJSON(event.target).code})});await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}});
+$('#reset-request-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{await api('/auth/password-reset/request',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showAuthPane('login-pane');authError('Password reset email sent if the account exists.',true);}catch(error){authError(error.message);}});
+$('#reset-complete-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{await api('/auth/password-reset/complete',{method:'POST',body:JSON.stringify(formJSON(event.target))});history.replaceState({},'',location.pathname);event.target.reset();showAuthPane('login-pane');authError('Password updated. Sign in again.',true);}catch(error){authError(error.message);}});
+$('#invite-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{const result=await api('/auth/invite/accept',{method:'POST',body:JSON.stringify(formJSON(event.target))});history.replaceState({},'',location.pathname);await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}});
+$('#passkey-login').addEventListener('click',async()=>{authError();if(!state.authConfig.passkeys_enabled)return authError('Passkeys are not configured.');const email=$('#login-pane [name=email]').value;if(!email)return authError('Enter your email first.');try{const begin=await api('/auth/passkey/login/options',{method:'POST',body:JSON.stringify({email})});const credential=await getPasskey(begin.options);const result=await api('/auth/passkey/login',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,credential})});await completeBrowserLogin(result,$('#login-pane'));}catch(error){authError(error.message);}});
+$('#mfa-passkey').addEventListener('click',async()=>{authError();if(!state.pendingMFA)return showAuthPane('login-pane');try{const begin=await api('/auth/mfa/passkey/login/options',{method:'POST',body:JSON.stringify({challenge_token:state.pendingMFA.challenge_token})});const credential=await getPasskey(begin.options);const result=await api('/auth/mfa/passkey/login',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,credential})});await completeBrowserLogin(result,$('#mfa-pane'));}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);}});
@@ -138,8 +203,13 @@ $('#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{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);}});
+$('#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('Invitation sent');}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.',true);}catch(error){toast(error.message,true);}});
+$('#totp-begin-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);if(state.mfa.totp_enabled){await api('/auth/mfa/totp/disable',{method:'POST',body:JSON.stringify(data)});event.target.reset();await loadAll();toast('TOTP disabled');return;}const result=await api('/auth/mfa/totp/begin',{method:'POST',body:JSON.stringify({current_password:data.current_password})});$('#totp-qr').src=result.qr_code;$('#totp-secret').textContent=result.secret;$('#totp-confirm-form').classList.remove('hidden');toast('Authenticator enrollment started');}catch(error){toast(error.message,true);}});
+$('#totp-confirm-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const result=await api('/auth/mfa/totp/confirm',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();$('#totp-confirm-form').classList.add('hidden');showSecret('Recovery codes',result.recovery_codes.join('\n'));await loadAll();}catch(error){toast(error.message,true);}});
+$('#passkey-form').addEventListener('submit',async(event)=>{event.preventDefault();try{if(!state.authConfig.passkeys_enabled)throw new Error('Passkeys are not configured');const data=formJSON(event.target);const begin=await api('/auth/mfa/passkey/options',{method:'POST',body:JSON.stringify({current_password:data.current_password})});const credential=await createPasskey(begin.options);await api('/auth/mfa/passkey',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,name:data.name,credential})});event.target.reset();await loadAll();toast('Passkey added');}catch(error){toast(error.message,true);}});
+$('#revoke-other-sessions').addEventListener('click',async()=>{if(!confirm('Sign out every other device?'))return;try{await api('/auth/sessions/revoke-others',{method:'POST',body:'{}'});await loadAll();toast('Other devices signed out');}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');});
-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);}}
+async function pollTopUp(orderID){for(let attempt=0;attempt<20;attempt++){const order=await api(`/billing/orders/${encodeURIComponent(orderID)}`);if(order.status==='paid'){await loadAll();toast('Balance credited');return;}if(['failed','expired'].includes(order.status)){toast(`Top-up ${order.status}`,true);return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment is still processing');}
+async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}}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 ca9f282..95ab153 100644
--- a/internal/adminui/assets/index.html
+++ b/internal/adminui/assets/index.html
@@ -14,23 +14,50 @@
<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="reset-request-pane">Reset password</button>
+ <button class="auth-tab" type="button" data-auth-pane="invite-pane">Accept invite</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>
+ <div class="form-actions"><button class="button primary" type="submit">Sign in</button><button class="button subtle" type="button" id="passkey-login">Use passkey</button></div>
</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>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="mfa-pane">
+ <div><span class="eyebrow">SECOND FACTOR</span><h1>Verify sign in</h1></div>
+ <label>Code<input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>
+ <div class="form-actions"><button class="button primary" type="submit">Verify</button><button class="button subtle" type="button" id="mfa-passkey">Use passkey</button></div>
+ </form>
+ <form class="auth-pane" id="reset-request-pane">
+ <div><span class="eyebrow">ACCOUNT RECOVERY</span><h1>Reset password</h1></div>
+ <label>Email<input name="email" type="email" required autocomplete="email"></label>
+ <button class="button primary" type="submit">Send reset link</button>
+ </form>
+ <form class="auth-pane" id="reset-complete-pane">
+ <div><span class="eyebrow">ACCOUNT RECOVERY</span><h1>Choose a new password</h1></div>
+ <input name="token" id="reset-token" type="hidden">
+ <input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1">
+ <label>New password<input name="new_password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
+ <button class="button primary" type="submit">Update password</button>
+ </form>
+ <form class="auth-pane" id="invite-pane">
+ <div><span class="eyebrow">TEAM ACCESS</span><h1>Accept invitation</h1></div>
+ <input name="token" id="invite-token" type="hidden">
+ <input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1">
+ <label>Name<input name="display_name" required autocomplete="name"></label>
+ <label>Password<input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
+ <button class="button primary" type="submit">Join workspace</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">
@@ -78,7 +105,7 @@
<section id="tenants" class="section">
<div class="section-heading"><div><span class="eyebrow">IDENTITY</span><h1>Tenants</h1></div></div>
- <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form>
+ <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Slug</th><th>Status</th><th>Created</th></tr></thead><tbody id="tenants-body"></tbody></table></div>
</section>
@@ -125,13 +152,21 @@
<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><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>
+ <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">Invite 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>
+ <div class="account-grid">
+ <form class="panel form-grid compact-form" id="totp-begin-form"><h2>Authenticator app</h2><input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label class="hidden" id="totp-disable-code">Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code"></label><button class="button secondary" id="totp-action" type="submit">Set up TOTP</button></form>
+ <form class="panel form-grid compact-form hidden" id="totp-confirm-form"><h2>Confirm authenticator</h2><img id="totp-qr" alt="TOTP QR code"><code id="totp-secret"></code><label>Code<input name="code" inputmode="numeric" autocomplete="one-time-code" required></label><button class="button primary" type="submit">Enable TOTP</button></form>
+ <form class="panel form-grid compact-form" id="passkey-form"><h2>Passkey</h2><input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>Passkey name<input name="name" value="This device" required></label><button class="button secondary" type="submit">Add passkey</button></form>
+ </div>
+ <div class="panel table-wrap"><div class="section-heading"><h2>Devices</h2><button class="button subtle" id="revoke-other-sessions" type="button">Sign out other devices</button></div><table><thead><tr><th>Device</th><th>IP</th><th>Method</th><th>Last seen</th><th></th></tr></thead><tbody id="sessions-body"></tbody></table></div>
+ <div class="panel table-wrap"><div class="section-heading"><h2>Passkeys</h2></div><table><thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="passkeys-body"></tbody></table></div>
+ <div class="panel table-wrap"><div class="section-heading"><h2>Top-up orders</h2></div><table><thead><tr><th>Created</th><th>Amount</th><th>Status</th><th>Reference</th></tr></thead><tbody id="orders-body"></tbody></table></div>
</section>
<section id="audit" class="section">
diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css
index 745378f..e712da3 100644
--- a/internal/adminui/assets/style.css
+++ b/internal/adminui/assets/style.css
@@ -1,6 +1,6 @@
: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; }
-.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; }
+.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,560px); 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,5vw,64px); overflow:auto; } .auth-tabs { display:flex; overflow:auto; 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; } .auth-error.success { color:#187151; } .form-actions { display:flex; gap:8px; } .form-actions .button { flex:1; }
.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; }
@@ -10,8 +10,9 @@
.table-wrap { overflow:auto; padding:0; } table { width:100%; border-collapse:collapse; min-width:700px; } th,td { padding:14px 18px; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle; } th { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0; background:#fbfcfd; } tbody tr:last-child td { border-bottom:0; } td { font-size:13px; } code { font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; color:#486071; } .badge,.tag { display:inline-flex; align-items:center; padding:4px 7px; font-size:11px; line-height:1; } .badge { border:1px solid #d9e0e4; color:var(--muted); } .badge.active { color:#187151; background:#e9f7f0; border-color:#c7e9d9; } .badge.revoked,.badge.suspended { color:var(--danger); background:#fff0f0; border-color:#f0cccc; } .tag { color:#4c6572; background:#eef3f5; margin:2px 3px 2px 0; } .text-button { border:0; background:transparent; color:var(--accent); padding:5px 0; } .text-button.danger { color:var(--danger); } .empty { color:var(--muted); text-align:center; padding:32px; } .truncate { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.route-editor { grid-column:1/-1; display:flex; flex-direction:column; gap:8px; } .route-row { display:grid; grid-template-columns:1.4fr 1.4fr .6fr .6fr 34px; gap:8px; } .icon-button { border:1px solid var(--line); background:#fff; color:var(--muted); width:34px; height:34px; font-size:19px; } .route-list { display:flex; flex-direction:column; gap:3px; color:#486071; font-size:12px; } .route-list em { color:var(--muted); font-style:normal; margin-left:4px; }
.hidden { display:none !important; } .visually-hidden { position:absolute !important; width:1px !important; height:1px !important; padding:0 !important; margin:-1px !important; overflow:hidden !important; clip:rect(0,0,0,0) !important; white-space:nowrap !important; border:0 !important; } .billing-actions { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .compact-form { grid-template-columns:1fr 1fr; } .compact-form .button { grid-column:1/-1; } .currency-label { color:var(--muted); font-size:12px; text-transform:uppercase; } .ledger-heading { margin-top:28px; } .money-positive { color:#187151; } .money-negative { color:var(--danger); }
+.account-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .account-grid .panel { align-content:start; } #totp-qr { width:180px; height:180px; border:1px solid var(--line); } #totp-secret { overflow-wrap:anywhere; } .table-wrap > .section-heading { padding:18px; margin:0; align-items:center; border-bottom:1px solid var(--line); }
.price-line { display:block; color:var(--muted); font-size:10px; margin-top:5px; white-space:nowrap; }
.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; } }
+.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; white-space:pre-wrap; }
+@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,.account-grid { grid-template-columns:1fr; } }
@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; } }