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 => ``).join('');
$('#new-tenant').hidden = !state.is_system_administrator;
$('#tenant-list').innerHTML = state.tenants.map(item => `${escapeHTML(item.role)}${escapeHTML(item.name)}
${escapeHTML(item.slug)}
Tenant${escapeHTML(item.id.slice(0, 8))}
`).join('') || empty('No tenants yet');
$('#app-list').innerHTML = tenantApps.map(item => `${escapeHTML(item.tenant_slug)}${escapeHTML(item.name)}
${escapeHTML(item.description || 'No description')}
${item.client_ready ? 'Client login ready' : 'Client login not configured'}${escapeHTML(item.client_id)}
${canManageTenant ? `` : ''}`).join('') || empty('No applications yet');
$('#key-list').innerHTML = tenantKeys.map(item => `
| ${escapeHTML(item.name)} | ${escapeHTML(apps.get(item.application_id)?.name || '—')} | ${escapeHTML(item.prefix)}… | ${escapeHTML(item.status)} | ${date(item.last_used_at)} | ${canManageTenant && item.status === 'active' ? `` : ''} |
`).join('') || rowEmpty(6, 'No keys issued');
$('#contract-list').innerHTML = tenantContracts.map(item => `| ${escapeHTML(apps.get(item.application_id)?.name || '—')} | ${escapeHTML(item.version)} | ${item.endpoint_count} | ${item.source_url ? `source` : 'Pasted'} | ${date(item.created_at)} |
`).join('') || rowEmpty(5, 'No contracts imported');
const pending = state.is_system_administrator ? state.pending_users.map(item => `${escapeHTML(item.display_name)} ${escapeHTML(item.email)} | Waiting room | — | pending | 0 | |
`).join('') : '';
$('#user-list').innerHTML = tenantUsers.map(item => `${escapeHTML(item.display_name)} ${escapeHTML(item.email)} | ${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')} | ${escapeHTML(item.role)} | ${escapeHTML(item.status)} | ${item.application_ids.length} | ${canManageTenant ? `` : ''} |
`).join('') + pending || rowEmpty(6, 'No users');
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant);
$$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => ``).join('');
select.value = tenantID;
});
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => ``).join(''));
}
const empty = text => `${text}
`;
const rowEmpty = (span, text) => `| ${text} |
`;
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 => ``).join('') || 'No applications
'
}
load().catch(error => notice(error.message, true));