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> {
|
export async function getOidcClient(clientId: string): Promise<StoredOidcClient | null> {
|
||||||
const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`;
|
const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`;
|
||||||
if (!rows.length) return null;
|
return rows.length ? mapOidcClientRow(rows[0]) : 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(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOidcAuthCode(
|
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
|
// IDENTITY INVITES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ import {
|
||||||
claimIdentityInvite,
|
claimIdentityInvite,
|
||||||
revokeIdentityInvite,
|
revokeIdentityInvite,
|
||||||
cleanExpiredIdentityInvites,
|
cleanExpiredIdentityInvites,
|
||||||
|
listOidcClients,
|
||||||
|
updateOidcClient,
|
||||||
|
createOidcClient,
|
||||||
|
deleteOidcClient,
|
||||||
sql,
|
sql,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -2955,6 +2959,469 @@ app.delete('/api/admin/spaces/:slug/members', async (c) => {
|
||||||
return c.json({ ok: true, removed: count });
|
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
|
// FUND CLAIM ROUTES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue