feat: add OIDC admin page for managing clients and email allowlists
Admin UI at /admin/oidc with passkey login, gated by ADMIN_DIDS. Supports viewing/creating/deleting clients, adding/removing allowed emails per client, revealing/rotating secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4665d14633
commit
9c74bff465
|
|
@ -1324,16 +1324,7 @@ export interface StoredOidcClient {
|
|||
|
||||
export async function getOidcClient(clientId: string): Promise<StoredOidcClient | null> {
|
||||
const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`;
|
||||
if (!rows.length) return null;
|
||||
const r = rows[0];
|
||||
return {
|
||||
clientId: r.client_id,
|
||||
clientSecret: r.client_secret,
|
||||
name: r.name,
|
||||
redirectUris: r.redirect_uris,
|
||||
allowedEmails: r.allowed_emails || [],
|
||||
createdAt: new Date(r.created_at).getTime(),
|
||||
};
|
||||
return rows.length ? mapOidcClientRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function createOidcAuthCode(
|
||||
|
|
@ -1397,6 +1388,63 @@ export async function seedOidcClients(clients: Array<{
|
|||
}
|
||||
}
|
||||
|
||||
function mapOidcClientRow(r: any): StoredOidcClient {
|
||||
return {
|
||||
clientId: r.client_id,
|
||||
clientSecret: r.client_secret,
|
||||
name: r.name,
|
||||
redirectUris: r.redirect_uris,
|
||||
allowedEmails: r.allowed_emails || [],
|
||||
createdAt: new Date(r.created_at).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listOidcClients(): Promise<StoredOidcClient[]> {
|
||||
const rows = await sql`SELECT * FROM oidc_clients ORDER BY created_at`;
|
||||
return rows.map(mapOidcClientRow);
|
||||
}
|
||||
|
||||
export async function updateOidcClient(clientId: string, updates: {
|
||||
name?: string;
|
||||
clientSecret?: string;
|
||||
redirectUris?: string[];
|
||||
allowedEmails?: string[];
|
||||
}): Promise<StoredOidcClient | null> {
|
||||
const client = await getOidcClient(clientId);
|
||||
if (!client) return null;
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE oidc_clients SET
|
||||
name = ${updates.name ?? client.name},
|
||||
client_secret = ${updates.clientSecret ?? client.clientSecret},
|
||||
redirect_uris = ${updates.redirectUris ?? client.redirectUris},
|
||||
allowed_emails = ${updates.allowedEmails ?? client.allowedEmails}
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING *
|
||||
`;
|
||||
return rows.length ? mapOidcClientRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function createOidcClient(client: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
name: string;
|
||||
redirectUris: string[];
|
||||
allowedEmails?: string[];
|
||||
}): Promise<StoredOidcClient> {
|
||||
const rows = await sql`
|
||||
INSERT INTO oidc_clients (client_id, client_secret, name, redirect_uris, allowed_emails)
|
||||
VALUES (${client.clientId}, ${client.clientSecret}, ${client.name}, ${client.redirectUris}, ${client.allowedEmails || []})
|
||||
RETURNING *
|
||||
`;
|
||||
return mapOidcClientRow(rows[0]);
|
||||
}
|
||||
|
||||
export async function deleteOidcClient(clientId: string): Promise<boolean> {
|
||||
const result = await sql`DELETE FROM oidc_clients WHERE client_id = ${clientId}`;
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IDENTITY INVITES
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ import {
|
|||
claimIdentityInvite,
|
||||
revokeIdentityInvite,
|
||||
cleanExpiredIdentityInvites,
|
||||
listOidcClients,
|
||||
updateOidcClient,
|
||||
createOidcClient,
|
||||
deleteOidcClient,
|
||||
sql,
|
||||
} from './db.js';
|
||||
import {
|
||||
|
|
@ -2955,6 +2959,469 @@ app.delete('/api/admin/spaces/:slug/members', async (c) => {
|
|||
return c.json({ ok: true, removed: count });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN: OIDC CLIENT MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
// List all OIDC clients
|
||||
app.get('/api/admin/oidc/clients', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||
|
||||
const clients = await listOidcClients();
|
||||
// Mask secrets in list view
|
||||
return c.json({ clients: clients.map(cl => ({ ...cl, clientSecret: cl.clientSecret.slice(0, 4) + '***' })) });
|
||||
});
|
||||
|
||||
// Get single OIDC client (full details)
|
||||
app.get('/api/admin/oidc/clients/:clientId', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||
|
||||
const client = await getOidcClient(c.req.param('clientId'));
|
||||
if (!client) return c.json({ error: 'Client not found' }, 404);
|
||||
return c.json({ client });
|
||||
});
|
||||
|
||||
// Create new OIDC client
|
||||
app.post('/api/admin/oidc/clients', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { clientId, name, redirectUris, allowedEmails } = body;
|
||||
|
||||
if (!clientId || !name || !redirectUris?.length) {
|
||||
return c.json({ error: 'clientId, name, and redirectUris are required' }, 400);
|
||||
}
|
||||
|
||||
const clientSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');
|
||||
|
||||
const client = await createOidcClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectUris,
|
||||
allowedEmails: allowedEmails || [],
|
||||
});
|
||||
|
||||
return c.json({ client });
|
||||
});
|
||||
|
||||
// Update OIDC client
|
||||
app.put('/api/admin/oidc/clients/:clientId', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||
|
||||
const body = await c.req.json();
|
||||
const updated = await updateOidcClient(c.req.param('clientId'), {
|
||||
name: body.name,
|
||||
redirectUris: body.redirectUris,
|
||||
allowedEmails: body.allowedEmails,
|
||||
});
|
||||
|
||||
if (!updated) return c.json({ error: 'Client not found' }, 404);
|
||||
return c.json({ client: updated });
|
||||
});
|
||||
|
||||
// Regenerate client secret
|
||||
app.post('/api/admin/oidc/clients/:clientId/rotate-secret', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||
|
||||
const newSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');
|
||||
const updated = await updateOidcClient(c.req.param('clientId'), { clientSecret: newSecret });
|
||||
if (!updated) return c.json({ error: 'Client not found' }, 404);
|
||||
return c.json({ client: updated });
|
||||
});
|
||||
|
||||
// Delete OIDC client
|
||||
app.delete('/api/admin/oidc/clients/:clientId', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||
|
||||
const deleted = await deleteOidcClient(c.req.param('clientId'));
|
||||
if (!deleted) return c.json({ error: 'Client not found' }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Admin OIDC management page
|
||||
app.get('/admin/oidc', (c) => {
|
||||
return c.html(oidcAdminPage());
|
||||
});
|
||||
|
||||
function oidcAdminPage(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC Clients — EncryptID Admin</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f1a;
|
||||
min-height: 100vh;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.page { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
||||
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 2rem; }
|
||||
.login-prompt { text-align: center; padding: 4rem 2rem; }
|
||||
|
||||
/* Client cards */
|
||||
.client-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.client-card {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.client-name { font-weight: 600; font-size: 1.05rem; }
|
||||
.client-id { font-family: monospace; font-size: 0.8rem; color: #7c3aed; }
|
||||
.client-field { margin-bottom: 0.75rem; }
|
||||
.client-field label { display: block; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
||||
.client-field .value { font-family: monospace; font-size: 0.85rem; word-break: break-all; color: #cbd5e1; }
|
||||
|
||||
/* Email tags */
|
||||
.email-list { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; min-height: 32px; }
|
||||
.email-tag {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3);
|
||||
border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; color: #c4b5fd;
|
||||
}
|
||||
.email-tag .remove {
|
||||
cursor: pointer; font-size: 1rem; line-height: 1; color: #94a3b8;
|
||||
background: none; border: none; padding: 0;
|
||||
}
|
||||
.email-tag .remove:hover { color: #ef4444; }
|
||||
.unrestricted { color: #94a3b8; font-size: 0.8rem; font-style: italic; }
|
||||
|
||||
/* Add email input */
|
||||
.add-email-row { display: flex; gap: 0.4rem; margin-top: 0.4rem; }
|
||||
.add-email-row input {
|
||||
flex: 1; padding: 0.35rem 0.6rem; border-radius: 0.4rem;
|
||||
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
|
||||
color: #fff; font-size: 0.85rem; outline: none;
|
||||
}
|
||||
.add-email-row input:focus { border-color: #7c3aed; }
|
||||
.add-email-row input::placeholder { color: #475569; }
|
||||
|
||||
/* Redirect URIs */
|
||||
.uri-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.uri-item { font-family: monospace; font-size: 0.8rem; color: #94a3b8; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.35rem 0.8rem; border-radius: 0.4rem; border: none;
|
||||
font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: opacity 0.15s;
|
||||
}
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; }
|
||||
.btn-primary { background: #7c3aed; color: #fff; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.1); color: #e2e8f0; }
|
||||
.btn-danger { background: rgba(239,68,68,0.2); color: #fca5a5; }
|
||||
.btn-group { display: flex; gap: 0.4rem; margin-top: 0.75rem; }
|
||||
|
||||
/* New client form */
|
||||
.new-client-form {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px dashed rgba(255,255,255,0.15);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
display: none;
|
||||
}
|
||||
.new-client-form.show { display: block; }
|
||||
.form-row { margin-bottom: 0.75rem; }
|
||||
.form-row label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.25rem; }
|
||||
.form-row input {
|
||||
width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.4rem;
|
||||
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
|
||||
color: #fff; font-size: 0.9rem; outline: none;
|
||||
}
|
||||
.form-row input:focus { border-color: #7c3aed; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
||||
padding: 0.6rem 1.2rem; border-radius: 0.5rem;
|
||||
font-size: 0.85rem; font-weight: 500; z-index: 100;
|
||||
transition: opacity 0.3s; opacity: 0;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast-success { background: rgba(34,197,94,0.9); color: #fff; }
|
||||
.toast-error { background: rgba(239,68,68,0.9); color: #fff; }
|
||||
|
||||
.secret-reveal { cursor: pointer; }
|
||||
.secret-reveal:hover { color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>OIDC Client Management</h1>
|
||||
<p class="sub">Manage which apps can authenticate via EncryptID and who has access</p>
|
||||
|
||||
<div id="loginPrompt" class="login-prompt" style="display:none;">
|
||||
<p style="color:#94a3b8; margin-bottom:1rem;">Admin access required. Sign in with your passkey.</p>
|
||||
<button class="btn btn-primary" onclick="login()" id="loginBtn">Sign in with Passkey</button>
|
||||
</div>
|
||||
|
||||
<div id="content" style="display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||||
<span style="color:#94a3b8; font-size:0.85rem;" id="clientCount"></span>
|
||||
<button class="btn btn-primary" onclick="toggleNewForm()">+ New Client</button>
|
||||
</div>
|
||||
|
||||
<div id="newClientForm" class="new-client-form">
|
||||
<div class="form-row">
|
||||
<label>Client ID (slug, e.g. "my-app")</label>
|
||||
<input type="text" id="newClientId" placeholder="my-app" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Display Name</label>
|
||||
<input type="text" id="newName" placeholder="My Application" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Redirect URI</label>
|
||||
<input type="text" id="newRedirectUri" placeholder="https://myapp.example.com/callback" />
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="createClient()">Create</button>
|
||||
<button class="btn btn-secondary" onclick="toggleNewForm()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clientList" class="client-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
let token = localStorage.getItem('eid_admin_token');
|
||||
let clients = [];
|
||||
|
||||
function toast(msg, type = 'success') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast toast-' + type + ' show';
|
||||
setTimeout(() => el.className = 'toast', 2500);
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(API + path, {
|
||||
...opts,
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
token = null;
|
||||
localStorage.removeItem('eid_admin_token');
|
||||
showLogin();
|
||||
}
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('loginPrompt').style.display = 'block';
|
||||
document.getElementById('content').style.display = 'none';
|
||||
}
|
||||
|
||||
function showContent() {
|
||||
document.getElementById('loginPrompt').style.display = 'none';
|
||||
document.getElementById('content').style.display = 'block';
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const btn = document.getElementById('loginBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const startRes = await fetch('/api/auth/start', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||||
});
|
||||
const { options } = await startRes.json();
|
||||
const challengeBytes = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
|
||||
const pubKeyOpts = { challenge: challengeBytes, rpId: options.rpId, userVerification: options.userVerification, timeout: options.timeout };
|
||||
const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
|
||||
const credentialId = btoa(String.fromCharCode(...new Uint8Array(assertion.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,'');
|
||||
const completeRes = await fetch('/api/auth/complete', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: options.challenge,
|
||||
credential: {
|
||||
credentialId,
|
||||
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
|
||||
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
|
||||
signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await completeRes.json();
|
||||
if (!result.success) throw new Error(result.error || 'Auth failed');
|
||||
token = result.token;
|
||||
localStorage.setItem('eid_admin_token', token);
|
||||
await loadClients();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const data = await api('/api/admin/oidc/clients');
|
||||
clients = data.clients;
|
||||
renderClients();
|
||||
showContent();
|
||||
} catch (err) {
|
||||
if (!token) return showLogin();
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderClients() {
|
||||
document.getElementById('clientCount').textContent = clients.length + ' client' + (clients.length !== 1 ? 's' : '');
|
||||
const list = document.getElementById('clientList');
|
||||
list.innerHTML = clients.map(cl => \`
|
||||
<div class="client-card" id="card-\${cl.clientId}">
|
||||
<div class="client-header">
|
||||
<div>
|
||||
<div class="client-name">\${esc(cl.name)}</div>
|
||||
<div class="client-id">\${esc(cl.clientId)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="client-field">
|
||||
<label>Client Secret</label>
|
||||
<div class="value secret-reveal" onclick="revealSecret('\${esc(cl.clientId)}', this)" title="Click to reveal">\${esc(cl.clientSecret)}</div>
|
||||
</div>
|
||||
<div class="client-field">
|
||||
<label>Redirect URIs</label>
|
||||
<div class="uri-list">\${cl.redirectUris.map(u => '<div class="uri-item">' + esc(u) + '</div>').join('')}</div>
|
||||
</div>
|
||||
<div class="client-field">
|
||||
<label>Allowed Emails</label>
|
||||
<div class="email-list" id="emails-\${cl.clientId}">
|
||||
\${cl.allowedEmails.length === 0
|
||||
? '<span class="unrestricted">Unrestricted (all emails allowed)</span>'
|
||||
: cl.allowedEmails.map(e => \`<span class="email-tag">\${esc(e)} <button class="remove" onclick="removeEmail('\${esc(cl.clientId)}', '\${esc(e)}')">×</button></span>\`).join('')}
|
||||
</div>
|
||||
<div class="add-email-row">
|
||||
<input type="email" placeholder="Add email..." id="email-input-\${cl.clientId}" onkeydown="if(event.key==='Enter')addEmail('\${esc(cl.clientId)}')" />
|
||||
<button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
async function revealSecret(clientId, el) {
|
||||
try {
|
||||
const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId));
|
||||
el.textContent = data.client.clientSecret;
|
||||
el.style.color = '#fbbf24';
|
||||
el.onclick = null;
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function addEmail(clientId) {
|
||||
const input = document.getElementById('email-input-' + clientId);
|
||||
const email = input.value.trim().toLowerCase();
|
||||
if (!email || !email.includes('@')) return;
|
||||
const cl = clients.find(c => c.clientId === clientId);
|
||||
const emails = [...(cl.allowedEmails || [])];
|
||||
if (emails.includes(email)) return;
|
||||
emails.push(email);
|
||||
try {
|
||||
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), {
|
||||
method: 'PUT', body: JSON.stringify({ allowedEmails: emails }),
|
||||
});
|
||||
cl.allowedEmails = emails;
|
||||
input.value = '';
|
||||
renderClients();
|
||||
toast('Added ' + email);
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function removeEmail(clientId, email) {
|
||||
const cl = clients.find(c => c.clientId === clientId);
|
||||
const emails = cl.allowedEmails.filter(e => e !== email);
|
||||
try {
|
||||
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), {
|
||||
method: 'PUT', body: JSON.stringify({ allowedEmails: emails }),
|
||||
});
|
||||
cl.allowedEmails = emails;
|
||||
renderClients();
|
||||
toast('Removed ' + email);
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function rotateSecret(clientId) {
|
||||
if (!confirm('Rotate secret for ' + clientId + '? The app will need updating.')) return;
|
||||
try {
|
||||
const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/rotate-secret', { method: 'POST' });
|
||||
toast('Secret rotated. New: ' + data.client.clientSecret.slice(0, 8) + '...');
|
||||
await loadClients();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteClient(clientId) {
|
||||
if (!confirm('Delete OIDC client ' + clientId + '? This cannot be undone.')) return;
|
||||
try {
|
||||
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), { method: 'DELETE' });
|
||||
toast('Deleted ' + clientId);
|
||||
await loadClients();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
function toggleNewForm() {
|
||||
document.getElementById('newClientForm').classList.toggle('show');
|
||||
}
|
||||
|
||||
async function createClient() {
|
||||
const clientId = document.getElementById('newClientId').value.trim();
|
||||
const name = document.getElementById('newName').value.trim();
|
||||
const redirectUri = document.getElementById('newRedirectUri').value.trim();
|
||||
if (!clientId || !name || !redirectUri) return toast('All fields required', 'error');
|
||||
try {
|
||||
const data = await api('/api/admin/oidc/clients', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ clientId, name, redirectUris: [redirectUri] }),
|
||||
});
|
||||
toast('Created ' + clientId + '. Secret: ' + data.client.clientSecret.slice(0, 12) + '...');
|
||||
document.getElementById('newClientId').value = '';
|
||||
document.getElementById('newName').value = '';
|
||||
document.getElementById('newRedirectUri').value = '';
|
||||
toggleNewForm();
|
||||
await loadClients();
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
// Init
|
||||
if (token) { loadClients(); } else { showLogin(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FUND CLAIM ROUTES
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue