@rblez/authly 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,13 @@
1
+ // Authly Dashboard — bundled from src/dashboard/
2
+ // @source-map-url=app.js.map
3
+
4
+ // ── dashboard/index.js ──
5
+ /**
6
+ * Authly Dashboard — entry point.
7
+ * Bundles all sections into a single app.js by esbuild.
8
+ */
9
+
10
+
1
11
  document.addEventListener("DOMContentLoaded", () => {
2
12
  const navItems = document.querySelectorAll(".nav-item");
3
13
  const sections = document.querySelectorAll(".section");
@@ -7,473 +17,981 @@ document.addEventListener("DOMContentLoaded", () => {
7
17
  const statusText = document.getElementById("statusText");
8
18
  const statusDot = document.querySelector(".header__dot");
9
19
 
10
- // All API calls go to the hosted authly instance
11
- const API_URL = "https://authly.rblez.com/api";
12
-
13
20
  checkHealth();
14
21
 
15
- // ── Helpers ─────────────────────────────────────────
16
- async function api(endpoint, opts = {}) {
17
- const res = await fetch(`${API_URL}${endpoint}`, {
18
- headers: { "Content-Type": "application/json" },
19
- ...opts,
20
- });
21
- return res.json();
22
- }
23
-
24
- function showResult(el, text, type = "info") {
25
- el.textContent = "";
26
- el.classList.remove("hidden", "result--ok", "result--error", "result--info");
27
- el.classList.add(`result--${type}`);
28
- el.textContent = text;
29
- }
30
-
31
- function hideResult(el) {
32
- el.classList.add("hidden");
33
- el.classList.remove("result--ok", "result--error", "result--info");
34
- }
35
-
36
- function showToast(msg, type = "ok") {
37
- const toast = document.getElementById("toast");
38
- toast.textContent = msg;
39
- toast.className = `toast toast--${type}`;
40
- setTimeout(() => toast.classList.add("hidden"), 3500);
41
- }
42
-
43
22
  // ── Health ──────────────────────────────────────────
44
23
  async function checkHealth() {
45
24
  try {
46
- const res = await fetch(`${API_URL}/health`);
25
+ const res = await fetch(`${BASE}/health`);
47
26
  if (res.ok) {
48
27
  statusText.textContent = "Connected";
49
- statusDot.style.background = "#22c55e";
28
+ if (statusDot) statusDot.style.background = "#22c55e";
50
29
  } else { setDisconnected(); }
51
30
  } catch { setDisconnected(); }
52
31
  }
53
32
 
54
33
  function setDisconnected() {
55
34
  statusText.textContent = "Disconnected";
56
- statusDot.style.background = "#ef4444";
35
+ if (statusDot) statusDot.style.background = "#ef4444";
57
36
  }
58
37
 
59
- // ── Auto-detect Supabase ──────────────────────────────
60
- async function autoDetectSupabase() {
61
- const scanStatus = document.getElementById("scanStatus");
62
- const scanDetail = document.getElementById("scanDetail");
63
- const scanAction = document.getElementById("scanAction");
64
- const intStatus = document.getElementById("integrationStatus");
65
- const intDetail = document.getElementById("integrationDetail");
38
+ // ── Navigate ────────────────────────────────────────
39
+ const sectionLoaders = {
40
+ init: null,
41
+ integration: loadSupabase,
42
+ providers: loadProviders,
43
+ ui: null,
44
+ routes: null,
45
+ roles: loadRoles,
46
+ "api-keys": loadApiKeys,
47
+ env: null,
48
+ migrate: loadMigrations,
49
+ users: loadUsers,
50
+ mcp: loadMCP,
51
+ audit: loadAudit,
52
+ };
66
53
 
67
- try {
68
- const scan = await api("/init/scan");
69
-
70
- if (scan.detected && scan.url && scan.anonKey && scan.serviceKey) {
71
- // Auto-connect
72
- scanStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Supabase credentials found — auto-connecting…</span>`;
73
- const data = await api("/init/connect", { method: "POST" });
74
-
75
- if (data.success) {
76
- scanStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Auto-connected to Supabase</span>`;
77
- const ref = scan.projectRef || url.ref;
78
-
79
- if (scanDetail) {
80
- scanDetail.classList.remove("hidden");
81
- scanDetail.innerHTML = `
82
- <div style="color:#aaa;font-size:.78rem;line-height:1.7">
83
- <div>URL: <code style="color:#c9c">${maskKey(scan.url)}</code></div>
84
- <div>Project ref: <code style="color:#c9c">${scan.projectRef || "—"}</code></div>
85
- <div>Found in: <span style="color:#22c55e">${scan.sources.join(", ")}</span></div>
86
- <div>Framework: <code style="color:#c9c">${scan.framework || "unknown"}</code></div>
87
- </div>
88
- `;
89
- }
54
+ for (const item of navItems) {
55
+ item.addEventListener("click", (e) => {
56
+ e.preventDefault();
57
+ switchSection(item.dataset.section);
58
+ });
59
+ }
90
60
 
91
- if (intStatus) {
92
- intStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Connected to Supabase</span>`;
93
- }
61
+ function switchSection(id) {
62
+ for (const n of navItems) n.classList.remove("active");
63
+ document.querySelector(`[data-section="${id}"]`)?.classList.add("active");
64
+ for (const sec of sections) sec.classList.toggle("hidden", sec.id !== id);
65
+ if (headerTitle) headerTitle.textContent = document.querySelector(`[data-section="${id}"]`)?.textContent?.trim() ?? "";
66
+ sidebar?.classList.remove("open");
94
67
 
95
- if (intDetail) {
96
- intDetail.classList.remove("hidden");
97
- intDetail.innerHTML = `
98
- <div style="color:#aaa;font-size:.78rem;line-height:1.7">
99
- <div>URL: <code style="color:#c9c">${maskKey(scan.url)}</code></div>
100
- <div>Project ref: <code style="color:#c9c">${scan.projectRef || "—"}</code></div>
101
- <div>Framework: <code style="color:#c9c">${scan.framework || "unknown"}</code></div>
102
- <div>Sources: <span style="color:#22c55e">${scan.sources.join(", ")}</span></div>
103
- <div>Can connect: <span style="color:#22c55e">● yes</span></div>
104
- </div>
105
- `;
106
- }
107
- } else {
108
- scanStatus.innerHTML = `<span style="color:#f87171">Auto-connect failed: ${data.error}</span>`;
109
- showScanFallback(scan);
110
- }
111
- } else {
112
- showScanFallback(scan);
113
- }
114
- } catch (e) {
115
- scanStatus.textContent = "Scan failed: " + e.message;
116
- intStatus.innerHTML = `<span style="color:#888">Could not reach scan endpoint</span>`;
117
- }
68
+ const container = document.getElementById(id);
69
+ const loader = sectionLoaders[id];
70
+ if (loader && container) loader(container);
118
71
  }
119
72
 
120
- function showScanFallback(scan) {
121
- const scanStatus = document.getElementById("scanStatus");
122
- const scanDetail = document.getElementById("scanDetail");
123
- const scanAction = document.getElementById("scanAction");
124
- const intStatus = document.getElementById("integrationStatus");
125
- const intDetail = document.getElementById("integrationDetail");
126
-
127
- let msg = "No Supabase credentials detected";
128
- let color = "#f87171";
129
- let icon = "ri-error-warning-line";
130
-
131
- if (scan.detected) {
132
- msg = "Partial credentials found — manual config needed";
133
- color = "#f59e0b";
134
- icon = "ri-alert-line";
135
- }
73
+ // ── Mobile menu ─────────────────────────────────────
74
+ if (menuBtn) menuBtn.addEventListener("click", () => sidebar?.classList.toggle("open"));
75
+
76
+ // ── Toggles ─────────────────────────────────────────
77
+ document.querySelectorAll(".toggle")?.forEach((toggle) => {
78
+ toggle.addEventListener("click", () => {
79
+ toggle.setAttribute("aria-checked", String(toggle.getAttribute("aria-checked") !== "true"));
80
+ });
81
+ });
82
+
83
+ // ── Doc panel toggles ──────────────────────────────
84
+ document.querySelectorAll(".toggle-docs")?.forEach((btn) => {
85
+ btn.addEventListener("click", () => {
86
+ const target = document.getElementById(btn.dataset.target);
87
+ if (!target) return;
88
+ target.classList.toggle("collapsed");
89
+ target.classList.toggle("expanded");
90
+ });
91
+ });
92
+
93
+ // ── Scaffold (UI section) ───────────────────────────
94
+ document.querySelectorAll(".scaffold-card")?.forEach((card) => {
95
+ card.addEventListener("click", async () => {
96
+ const type = card.dataset.scaffold;
97
+ const preview = document.getElementById("scaffoldPreview");
98
+ const code = document.getElementById("scaffoldCode");
99
+ try {
100
+ const data = await api("/api/scaffold/preview", {
101
+ method: "POST",
102
+ body: JSON.stringify({ type }),
103
+ });
104
+ if (data.success && code && preview) {
105
+ code.textContent = data.code;
106
+ preview.classList.remove("hidden");
107
+ }
108
+ } catch (e) {
109
+ showToast(e.message, "error");
110
+ }
111
+ });
112
+ });
136
113
 
137
- scanStatus.innerHTML = `<span style="color:${color}"><i class="${icon}"></i> ${msg}</span>`;
114
+ // ── Hash navigation ────────────────────────────────
115
+ const hash = location.hash.replace("#", "");
116
+ if (hash && document.getElementById(hash)) switchSection(hash);
117
+ });
138
118
 
139
- if (scanDetail) {
140
- scanDetail.classList.remove("hidden");
141
- const details = [];
142
- if (scan.url) details.push(`<div>URL: <code style="color:#c9c">${maskKey(scan.url)}</code></div>`);
143
- if (scan.anonKey) details.push(`<div>Anon key: <span style="color:#22c55e">found</span></div>`);
144
- if (scan.serviceKey) details.push(`<div>Service key: <span style="color:#22c55e">found</span></div>`);
145
- if (scan.projectRef) details.push(`<div>Ref: <code style="color:#c9c">${scan.projectRef}</code></div>`);
146
- if (scan.sources.length) details.push(`<div>Sources: ${scan.sources.join(", ")}</div>`);
147
119
 
148
- if (!details.length) details.push('<div style="color:#555">No env files contain Supabase credentials</div>');
120
+ // ── dashboard/api.js ──
121
+ /**
122
+ * Base API client — all dashboard HTTP calls go through this.
123
+ * Points to the hosted Railway instance.
124
+ * No direct fetch() allowed in other files.
125
+ */
126
+
127
+ const BASE = "https://authly.rblez.com";
128
+
129
+ /**
130
+ * @param {string} path - API path (e.g. '/api/users')
131
+ * @param {RequestInit} [options]
132
+ * @returns {Promise<any>}
133
+ */
134
+ function api(path, options = {}) {
135
+ const res = await fetch(`${BASE}${path}`, {
136
+ headers: { "Content-Type": "application/json" },
137
+ ...options,
138
+ });
139
+ const data = await res.json();
140
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
141
+ return data;
142
+ }
143
+
144
+
145
+ // ── dashboard/state.js ──
146
+ /**
147
+ * Global state for the dashboard.
148
+ * Shared across sections so they don't re-fetch unnecessarily.
149
+ */
150
+
151
+ const state = {
152
+ supabase: {
153
+ connected: false,
154
+ project: null,
155
+ scannedFrom: null, // "env" | "oauth" | null
156
+ },
157
+ providers: [],
158
+ users: [],
159
+ roles: [],
160
+ migrations: [],
161
+ keys: [],
162
+ config: {},
163
+ };
164
+
165
+ /**
166
+ * Update a slice of state and optionally re-render.
167
+ */
168
+ function setState(path, value) {
169
+ const parts = path.split(".");
170
+ let obj = state;
171
+ for (let i = 0; i < parts.length - 1; i++) {
172
+ obj = obj[parts[i]];
173
+ }
174
+ obj[parts[parts.length - 1]] = value;
175
+ }
176
+
177
+
178
+ // ── dashboard/components/toast.js ──
179
+ /**
180
+ * Toast notification system.
181
+ */
182
+
183
+ function showToast(message, type = "ok") {
184
+ const toast = document.getElementById("toast");
185
+ if (!toast) return;
186
+ toast.textContent = message;
187
+ toast.className = `toast toast--${type}`;
188
+ toast.classList.remove("hidden");
189
+ setTimeout(() => toast.classList.add("hidden"), 3500);
190
+ }
191
+
192
+
193
+ // ── dashboard/sections/supabase.js ──
194
+ /**
195
+ * Supabase integration section.
196
+ * Handles auto-config via scan or OAuth flow.
197
+ */
198
+
199
+
200
+ function loadSupabase(sectionContainer) {
201
+ renderLoading(sectionContainer, "Checking Supabase connection…");
202
+
203
+ try {
204
+ const status = await api("/api/supabase/status");
205
+
206
+ if (status.connected) {
207
+ setState("supabase.connected", true);
208
+ setState("supabase.project", status.project);
209
+ setState("supabase.scannedFrom", status.scannedFrom);
210
+
211
+ const content = `
212
+ <div class="supabase-connected">
213
+ <i class="ri-check-line" style="color:#22c55e;font-size:1.2rem"></i>
214
+ <h4>Connected to Supabase</h4>
215
+ <p class="text-muted">Project: <strong>${status.project || "—"}</strong></p>
216
+ <p class="text-muted">Source: ${status.scannedFrom || "environment"}</p>
217
+ <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
218
+ <button id="disconnectBtn" class="btn btn--sm" style="background:#444;color:#fff">
219
+ <i class="ri-plug-line"></i> Disconnect
220
+ </button>
221
+ </div>
222
+ </div>
223
+ `;
224
+ renderSuccess(sectionContainer, content);
149
225
 
150
- scanDetail.innerHTML = `<div style="color:#aaa;font-size:.78rem;line-height:1.7">${details.join("")}</div>`;
226
+ document.getElementById("disconnectBtn")?.addEventListener("click", () => {
227
+ showToast("Disconnect not yet implemented", "info");
228
+ });
229
+ } else {
230
+ setState("supabase.connected", false);
231
+ showConnectFlow(sectionContainer);
151
232
  }
152
-
153
- if (scanAction) {
154
- scanAction.classList.remove("hidden");
155
- scanAction.innerHTML = `
156
- <button class="btn btn--primary" id="connectBtn" style="font-size:.8rem;padding:8px 16px">
157
- <i class="ri-plug-line"></i> Connect manually
233
+ } catch (e) {
234
+ renderError(sectionContainer, `Failed to check connection: ${e.message}`);
235
+ }
236
+ }
237
+
238
+ async function showConnectFlow(container) {
239
+ container.innerHTML = `
240
+ <div class="supabase-connect">
241
+ <h4>Connect to Supabase</h4>
242
+ <p class="text-muted">Authly can find your credentials automatically, or you can connect via OAuth.</p>
243
+ <div id="supabaseFlowAction" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
244
+ <button id="startScanBtn" class="btn btn--primary btn--sm">
245
+ <i class="ri-search-eye-line"></i> Scan project files
158
246
  </button>
247
+ </div>
248
+ <div id="supabaseFlowResult"></div>
249
+ </div>
250
+ `;
251
+
252
+ const scanBtn = document.getElementById("startScanBtn");
253
+ scanBtn?.addEventListener("click", startScan);
254
+ }
255
+
256
+ async function startScan() {
257
+ const actionDiv = document.getElementById("supabaseFlowAction");
258
+ const resultDiv = document.getElementById("supabaseFlowResult");
259
+
260
+ actionDiv.innerHTML = '<span class="text-muted"><i class="ri-loader-4-line spinner"></i> Scanning project files…</span>';
261
+ resultDiv.innerHTML = "";
262
+
263
+ try {
264
+ const scan = await api("/api/supabase/scan");
265
+
266
+ if (scan.found) {
267
+ // Found all credentials
268
+ const fields = scan.fields || [];
269
+ actionDiv.innerHTML = "";
270
+ resultDiv.innerHTML = `
271
+ <div class="state state--success">
272
+ <p>Found ${fields.length} credentials in your project files.</p>
273
+ <button id="useCredsBtn" class="btn btn--sm btn--primary" style="margin-top:8px">
274
+ <i class="ri-check-line"></i> Use these credentials
275
+ </button>
276
+ </div>
159
277
  `;
160
- document.getElementById("connectBtn")?.addEventListener("click", () => {
161
- document.querySelector('[data-section="init"]')?.click();
162
- document.getElementById("initUrl")?.focus();
278
+ document.getElementById("useCredsBtn")?.addEventListener("click", () => {
279
+ applyScanResult(scan);
163
280
  });
281
+ } else {
282
+ // Nothing found → offer OAuth
283
+ const fields = scan.fields || [];
284
+ actionDiv.innerHTML = `
285
+ <button id="oauthBtn" class="btn btn--sm" style="background:#0d1b3e;border:1px solid #1d355e;color:#58a6ff">
286
+ <i class="ri-plug-line"></i> Connect with Supabase OAuth
287
+ </button>
288
+ `;
289
+ resultDiv.innerHTML = `
290
+ <div class="state state--error">
291
+ <p>Missing: <code>${fields.join(", ")}</code></p>
292
+ <p class="text-muted">Connect via Supabase Platform OAuth instead.</p>
293
+ </div>
294
+ `;
295
+ document.getElementById("oauthBtn")?.addEventListener("click", redirectToOAuth);
164
296
  }
165
-
166
- if (intStatus) {
167
- intStatus.innerHTML = `<span style="color:#888">Not connected — enter credentials in Init</span>`;
168
- }
169
- if (intDetail) {
170
- intDetail.classList.add("hidden");
171
- }
297
+ } catch (e) {
298
+ actionDiv.innerHTML = "";
299
+ resultDiv.innerHTML = `<div class="state state--error"><p>Scan failed: ${e.message}</p></div>`;
172
300
  }
173
-
174
- // ── Connect (auto or manual) ──────────────────────
175
- async function connectSupabase(body = {}) {
176
- const data = await api("/init/connect", { method: "POST", body: JSON.stringify(body) });
177
- return data;
301
+ }
302
+
303
+ async function applyScanResult(scan) {
304
+ const actionDiv = document.getElementById("supabaseFlowAction");
305
+ const resultDiv = document.getElementById("supabaseFlowResult");
306
+ actionDiv.innerHTML = '<span class="text-muted">Saving credentials…</span>';
307
+
308
+ try {
309
+ await api("/api/config", {
310
+ method: "POST",
311
+ body: JSON.stringify({
312
+ type: "supabase",
313
+ fields: scan.fields,
314
+ }),
315
+ });
316
+ resultDiv.innerHTML = `<div class="state state--success"><p><i class="ri-check-line"></i> Credentials applied. Reload section to verify.</p></div>`;
317
+ showToast("Supabase credentials saved", "ok");
318
+ setState("supabase.connected", true);
319
+ } catch (e) {
320
+ resultDiv.innerHTML = `<div class="state state--error"><p>Failed to save: ${e.message}</p></div>`;
178
321
  }
179
-
180
- function maskKey(key) {
181
- if (key.length < 30) return key;
182
- return key.slice(0, 20) + "" + key.slice(-8);
322
+ }
323
+
324
+ function redirectToOAuth() {
325
+ const actionDiv = document.getElementById("supabaseFlowAction");
326
+ actionDiv.innerHTML = '<span class="text-muted"><i class="ri-external-link-line"></i> Redirecting to Supabase…</span>';
327
+ // Redirect the entire browser to Supabase OAuth
328
+ window.location.href = `${window.location.origin}/api/auth/supabase/authorize`;
329
+ }
330
+
331
+ function checkSupabaseStatus() {
332
+ try {
333
+ const status = await api("/api/supabase/status");
334
+ setState("supabase.connected", !!status.connected);
335
+ setState("supabase.project", status.project || null);
336
+ setState("supabase.scannedFrom", status.scannedFrom || null);
337
+ } catch {
338
+ setState("supabase.connected", false);
183
339
  }
340
+ }
341
+
342
+
343
+ // ── dashboard/sections/providers.js ──
344
+ /**
345
+ * Providers section — Google, GitHub, Discord.
346
+ * Shows provider status with Setup wizard.
347
+ */
348
+
349
+
350
+ const PROVIDERS = ["google", "github", "discord"];
351
+
352
+ const PROVIDER_LABELS = {
353
+ google: "Google",
354
+ github: "GitHub",
355
+ discord: "Discord",
356
+ };
357
+
358
+ const CALLBACK_URLS = {
359
+ google: "https://authly.rblez.com/api/auth/google/callback",
360
+ github: "https://authly.rblez.com/api/auth/github/callback",
361
+ discord: "https://authly.rblez.com/api/auth/discord/callback",
362
+ };
363
+
364
+ const DOCS_URLS = {
365
+ google: "https://console.cloud.google.com/apis/credentials",
366
+ github: "https://github.com/settings/applications/new",
367
+ discord: "https://discord.com/developers/applications",
368
+ };
369
+
370
+ const GUIDE_STEPS = {
371
+ google: [
372
+ "Go to Google Cloud Console → APIs & Services → Credentials",
373
+ "Create a project or select an existing one",
374
+ 'Click "Create Credentials" → OAuth 2.0 Client ID',
375
+ "Application type: Web application",
376
+ "Add the callback URL below as an Authorized redirect URI",
377
+ ],
378
+ github: [
379
+ "Go to GitHub Settings → Developer settings → OAuth Apps",
380
+ 'Click "New OAuth App"',
381
+ "Fill in Application name and Homepage URL",
382
+ "Add the callback URL below as Authorization callback URL",
383
+ ],
384
+ discord: [
385
+ "Go to Discord Developer Portal → Applications",
386
+ "Create a New Application",
387
+ "Go to OAuth2 → Redirects → Add Redirect",
388
+ "Add the callback URL below",
389
+ ],
390
+ };
391
+
392
+ function loadProviders(sectionContainer) {
393
+ renderLoading(sectionContainer, "Loading providers…");
394
+
395
+ try {
396
+ const data = await api("/api/providers");
397
+ const providers = data.providers || [];
398
+
399
+ const html = providers.map((p) => {
400
+ const label = PROVIDER_LABELS[p.name] || p.name;
401
+ const iconUrl = `https://cdn.simpleicons.org/${p.name}/fff`;
402
+ const iconClass = p.enabled ? "enabled" : "disabled";
403
+ return `<div class="provider-card">
404
+ <img src="${iconUrl}" width="20" height="20" alt="${label}" />
405
+ <div class="provider-info">
406
+ <div class="provider-name">${label}</div>
407
+ <div class="provider-scopes">${p.scopes || ""}</div>
408
+ </div>
409
+ <span class="provider-status ${iconClass}">${p.enabled ? "Enabled" : "Disabled"}</span>
410
+ ${p.enabled
411
+ ? `<button class="btn btn--sm edit-provider-btn" data-provider="${p.name}">Edit</button>`
412
+ : `<button class="btn btn--sm btn--primary setup-provider-btn" data-provider="${p.name}">Setup</button>`
413
+ }
414
+ </div>`;
415
+ }).join("");
184
416
 
185
- // ── Navigation ──────────────────────────────────────
186
- for (const item of navItems) {
187
- item.addEventListener("click", (e) => {
188
- e.preventDefault();
189
- switchSection(item.dataset.section);
417
+ renderSuccess(sectionContainer, html);
418
+
419
+ // Bind buttons
420
+ sectionContainer.querySelectorAll(".setup-provider-btn").forEach((btn) => {
421
+ btn.addEventListener("click", () => openProviderWizard(btn.dataset.provider));
422
+ });
423
+ sectionContainer.querySelectorAll(".edit-provider-btn").forEach((btn) => {
424
+ btn.addEventListener("click", () => openProviderWizard(btn.dataset.provider, 1));
190
425
  });
426
+ } catch (e) {
427
+ renderError(sectionContainer, `Failed to load providers: ${e.message}`);
191
428
  }
429
+ }
430
+
431
+ function openProviderWizard(name, defaultTab = 0) {
432
+ const label = PROVIDER_LABELS[name];
433
+ const callbackUrl = CALLBACK_URLS[name];
434
+
435
+ const tabs = [
436
+ {
437
+ label: "Guide",
438
+ content: `
439
+ <h4>${label} Setup Guide</h4>
440
+ <ol style="color:#aaa;font-size:.85rem;line-height:2;padding-left:18px">
441
+ ${(GUIDE_STEPS[name] || []).map((s) => `<li>${s}</li>`).join("")}
442
+ </ol>
443
+ <div style="margin-top:12px;padding:8px 12px;background:#111;border-radius:6px">
444
+ <div style="font-size:.75rem;color:#555;margin-bottom:4px">Callback URL</div>
445
+ <div style="display:flex;gap:6px;align-items:center">
446
+ <code id="cbUrl" style="font-size:.8rem;color:#58a6ff;flex:1">${callbackUrl}</code>
447
+ <button class="btn btn--sm" id="copyCbUrl" title="Copy">Copy</button>
448
+ </div>
449
+ </div>
450
+ <div style="margin-top:8px">
451
+ <a href="${DOCS_URLS[name]}" target="_blank" rel="noopener" class="btn btn--sm" style="background:#222;color:#888;text-decoration:none">
452
+ Open ${label} Console <i class="ri-external-link-line" style="font-size:.7rem"></i>
453
+ </a>
454
+ </div>
455
+ `,
456
+ onShow: () => {
457
+ document.getElementById("copyCbUrl")?.addEventListener("click", () => {
458
+ navigator.clipboard.writeText(callbackUrl);
459
+ showToast("Callback URL copied", "ok");
460
+ });
461
+ },
462
+ },
463
+ {
464
+ label: "Enter Keys",
465
+ content: `
466
+ <h4>${label} OAuth Credentials</h4>
467
+ <div style="display:flex;flex-direction:column;gap:8px;margin-top:12px">
468
+ <input id="providerClientId" placeholder="Client ID" style="background:#000;border:1px solid #333;color:#fff;padding:8px 12px;border-radius:4px;font-size:.85rem" />
469
+ <input id="providerClientSecret" type="password" placeholder="Client Secret" style="background:#000;border:1px solid #333;color:#fff;padding:8px 12px;border-radius:4px;font-size:.85rem" />
470
+ <div id="providerKeyResult" class="result hidden"></div>
471
+ <button id="providerSaveKeys" class="btn btn--primary btn--sm">Validate &amp; Save</button>
472
+ </div>
473
+ `,
474
+ onShow: () => {
475
+ document.getElementById("providerSaveKeys")?.addEventListener("click", async () => {
476
+ const clientId = document.getElementById("providerClientId").value.trim();
477
+ const clientSecret = document.getElementById("providerClientSecret").value.trim();
478
+ if (!clientId || !clientSecret) return;
479
+
480
+ const resultDiv = document.getElementById("providerKeyResult");
481
+ resultDiv.classList.remove("hidden");
482
+ resultDiv.innerHTML = "Validating…";
483
+
484
+ try {
485
+ const res = await api(`/api/providers/${name}/keys`, {
486
+ method: "POST",
487
+ body: JSON.stringify({ clientId, clientSecret }),
488
+ });
489
+ if (res.valid) {
490
+ resultDiv.innerHTML = `<span style="color:#22c55e">Keys validated and saved</span>`;
491
+ showToast(`${label} provider configured`, "ok");
492
+ } else {
493
+ resultDiv.innerHTML = `<span style="color:#f87171">Validation failed: ${res.error || "invalid keys"}</span>`;
494
+ }
495
+ } catch (e) {
496
+ resultDiv.innerHTML = `<span style="color:#f87171">${e.message}</span>`;
497
+ }
498
+ });
499
+ },
500
+ },
501
+ {
502
+ label: "Test",
503
+ content: `
504
+ <h4>Test ${label} Connection</h4>
505
+ <p class="text-muted" style="font-size:.8rem;margin:8px 0">Make a test OAuth request to verify your configuration.</p>
506
+ <pre id="providerTestResult" class="code-block" style="white-space:pre-wrap;font-size:.75rem;color:#888"></pre>
507
+ <button id="providerTestBtn" class="btn btn--sm">Run Test</button>
508
+ `,
509
+ onShow: () => {
510
+ document.getElementById("providerTestBtn")?.addEventListener("click", async () => {
511
+ const output = document.getElementById("providerTestResult");
512
+ output.textContent = "Testing…";
513
+ try {
514
+ const data = await api(`/api/providers/${name}/test`);
515
+ output.textContent = JSON.stringify(data, null, 2);
516
+ } catch (e) {
517
+ output.textContent = e.message;
518
+ }
519
+ });
520
+ },
521
+ },
522
+ ];
192
523
 
193
- function switchSection(id) {
194
- for (const n of navItems) n.classList.remove("active");
195
- document.querySelector(`[data-section="${id}"]`)?.classList.add("active");
196
- for (const sec of sections) sec.classList.toggle("hidden", sec.id !== id);
197
- headerTitle.textContent = document.querySelector(`[data-section="${id}"]`)?.textContent?.trim() ?? "";
198
- sidebar.classList.remove("open");
199
-
200
- // Lazy-load section data
201
- if (id === "users") loadUsers();
202
- if (id === "providers") loadProviders();
203
- if (id === "roles") loadRoles();
204
- if (id === "env") loadEnv();
205
- if (id === "migrate") loadMigrations();
206
- if (id === "integration") loadIntegration();
207
- }
524
+ openWizard({ title: `${label} Setup`, tabs, defaultTab });
525
+ }
208
526
 
209
- async function loadIntegration() {
210
- try {
211
- const scan = await api("/init/scan");
212
- const intStatus = document.getElementById("integrationStatus");
213
- const intDetail = document.getElementById("integrationDetail");
214
-
215
- if (scan.detected && scan.canConnect) {
216
- intStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Connected</span>`;
217
- intDetail.classList.remove("hidden");
218
- intDetail.innerHTML = `
219
- <div style="color:#aaa;font-size:.78rem;line-height:1.7">
220
- <div>URL: <code style="color:#c9c">${maskKey(scan.url || "")}</code></div>
221
- <div>Project ref: <code style="color:#c9c">${scan.projectRef || "—"}</code></div>
222
- <div>Sources: ${scan.sources.join(", ")}</div>
223
- </div>
224
- `;
225
- } else {
226
- intStatus.innerHTML = `<span style="color:#f87171"><i class="ri-close-line"></i> No connection</span>`;
227
- intDetail.classList.remove("hidden");
228
- intDetail.innerHTML = `<div style="color:#555">No Supabase credentials detected. Go to Init to configure.</div>`;
229
- }
230
- } catch {}
231
- }
232
527
 
233
- // ── Scan on load ──────────────────────────────────
234
- autoDetectSupabase();
235
-
236
- // Reconnect button
237
- const reconnectBtn = document.getElementById("reconnectBtn");
238
- if (reconnectBtn) {
239
- reconnectBtn.addEventListener("click", async () => {
240
- reconnectBtn.disabled = true;
241
- reconnectBtn.textContent = "Scanning…";
242
- await autoDetectSupabase();
243
- reconnectBtn.disabled = false;
244
- reconnectBtn.innerHTML = '<i class="ri-refresh-line"></i> Re-scan & reconnect';
245
- });
246
- }
528
+ // ── dashboard/sections/migrations.js ──
529
+ /**
530
+ * Migrations section — list and run SQL migrations.
531
+ */
247
532
 
248
- // ── Manual connect form ──────────────────────────
249
- const initForm = document.getElementById("initForm");
250
- if (initForm) {
251
- initForm.addEventListener("submit", async (e) => {
252
- e.preventDefault();
253
- const btn = document.getElementById("initBtn");
254
- const result = document.getElementById("initResult");
255
- btn.disabled = true;
256
- btn.textContent = "Connecting…";
257
- hideResult(result);
258
-
259
- const data = await api("/init/connect", {
260
- method: "POST",
261
- body: JSON.stringify({
262
- supabaseUrl: document.getElementById("initUrl").value.trim(),
263
- supabaseAnonKey: document.getElementById("initAnon").value.trim(),
264
- supabaseServiceKey: document.getElementById("initService").value.trim(),
265
- }),
266
- });
267
533
 
268
- btn.disabled = false;
269
- btn.innerHTML = '<i class="ri-plug-line"></i> Connect &amp; Configure';
534
+ function loadMigrations(sectionContainer) {
535
+ renderLoading(sectionContainer, "Loading migrations…");
270
536
 
271
- if (data.success) {
272
- showResult(result, "Connected to Supabase. Framework: " + (data.framework || "unknown"), "ok");
273
- showToast("Project connected successfully");
274
- checkHealth();
275
- autoDetectSupabase();
276
- } else {
277
- showResult(result, data.error, "error");
278
- }
537
+ try {
538
+ const data = await api("/api/migrations");
539
+ const migrations = data.migrations || [];
540
+
541
+ const html = `
542
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
543
+ <h3>Migrations</h3>
544
+ <button id="runAllPending" class="btn btn--primary btn--sm">
545
+ <i class="ri-play-line"></i> Run all pending
546
+ </button>
547
+ </div>
548
+ <div id="migrationList">
549
+ ${migrations.map((m) => `
550
+ <div class="migration-item">
551
+ <span class="badge badge--${m.status === 'applied' ? 'ok' : 'pending'}">${m.status}</span>
552
+ <code style="font-size:.8rem">${m.name}</code>
553
+ <span class="text-muted">${m.description || ""}</span>
554
+ ${m.status === 'pending'
555
+ ? `<button class="btn btn--sm run-migration" data-name="${m.name}">Run</button>`
556
+ : ""
557
+ }
558
+ </div>
559
+ `).join("")}
560
+ </div>
561
+ <pre id="migrationOutput" class="code-block hidden" style="white-space:pre-wrap;font-size:.75rem;color:#888"></pre>
562
+ `;
563
+
564
+ renderSuccess(sectionContainer, html);
565
+
566
+ // Bind run buttons
567
+ sectionContainer.querySelectorAll(".run-migration").forEach((btn) => {
568
+ btn.addEventListener("click", () => runMigration(btn.dataset.name));
279
569
  });
570
+
571
+ document.getElementById("runAllPending")?.addEventListener("click", runAllPending);
572
+ } catch (e) {
573
+ renderError(sectionContainer, `Failed to load migrations: ${e.message}`);
280
574
  }
575
+ }
281
576
 
282
- // ── Users ───────────────────────────────────────────
283
- window.loadUsers = async function() {
284
- const body = document.getElementById("usersBody");
285
- body.innerHTML = '<tr><td colspan="4" class="text-muted">Loading…</td></tr>';
286
- try {
287
- const data = await api("/users");
288
- if (!data.success || !data.users?.length) {
289
- body.innerHTML = '<tr><td colspan="4" class="text-muted">No users found. Connect Supabase first.</td></tr>';
290
- return;
291
- }
292
- body.innerHTML = data.users.map(u =>
293
- `<tr>
294
- <td style="font-family:var(--mono);font-size:.8rem">${u.id.slice(0, 8)}…</td>
295
- <td>${u.email ?? "—"}</td>
296
- <td>${u.raw_user_meta_data?.role ?? "user"}</td>
297
- <td class="text-muted">${new Date(u.created_at).toLocaleDateString()}</td>
298
- </tr>`
299
- ).join("");
300
- } catch {
301
- body.innerHTML = '<tr><td colspan="4" class="text-muted">Failed to load users</td></tr>';
577
+ async function runMigration(name) {
578
+ if (!confirm(`Run migration '${name}'? This executes SQL directly.`)) return;
579
+
580
+ try {
581
+ const data = await api(`/api/migrations/${name}/run`, { method: "POST" });
582
+ const output = document.getElementById("migrationOutput");
583
+ if (output) {
584
+ output.classList.remove("hidden");
585
+ output.textContent = data.output || "Migration applied";
302
586
  }
303
- };
587
+ if (data.ok) {
588
+ showToast(`Migration '${name}' applied`, "ok");
589
+ } else {
590
+ showToast(`Migration '${name}' failed: ${data.error}`, "error");
591
+ }
592
+ } catch (e) {
593
+ showToast(`Migration failed: ${e.message}`, "error");
594
+ }
595
+ }
596
+
597
+ async function runAllPending() {
598
+ try {
599
+ const data = await api("/api/migrations/pending/run", { method: "POST" });
600
+ const output = document.getElementById("migrationOutput");
601
+ if (output) {
602
+ output.classList.remove("hidden");
603
+ output.textContent = data.output || "All pending migrations applied";
604
+ }
605
+ showToast("All pending migrations applied", "ok");
606
+ } catch (e) {
607
+ showToast(`Failed: ${e.message}`, "error");
608
+ }
609
+ }
304
610
 
305
- const refreshBtn = document.getElementById("refreshUsers");
306
- if (refreshBtn) refreshBtn.addEventListener("click", () => loadUsers());
307
611
 
308
- // ── Providers ───────────────────────────────────────
309
- window.loadProviders = async function() {
310
- const container = document.getElementById("providerList");
311
- const data = await api("/providers");
312
- if (!data.providers) return;
612
+ // ── dashboard/sections/users.js ──
613
+ /**
614
+ * Users section — list users and manage roles.
615
+ */
313
616
 
314
- container.innerHTML = data.providers.map(p => {
315
- const name = p.name === "magiclink" ? "Magic Link" : p.name;
316
- const iconUrl = `https://cdn.simpleicons.org/${p.name === "magiclink" ? "resend" : p.name}/fff`;
317
- return `<div class="provider-card">
318
- <img src="${iconUrl}" width="20" height="20" alt="${name}" class="provider-icon-img" />
319
- <div class="provider-info">
320
- <div class="provider-name">${name}</div>
321
- <div class="provider-scopes">${p.scopes || ""}</div>
322
- </div>
323
- <span class="provider-status ${p.enabled ? "enabled" : "disabled"}">${p.enabled ? "Active" : "Off"}</span>
324
- </div>`;
325
- }).join("");
326
- };
327
617
 
328
- // ── Roles ───────────────────────────────────────────
329
- window.loadRoles = async function() {
330
- const container = document.getElementById("rolesList");
331
- const data = await api("/roles");
332
- if (!data.success || !data.roles?.length) {
333
- container.innerHTML = '<span class="text-muted">No roles. Connect Supabase and run migration.</span>';
618
+ function loadUsers(sectionContainer) {
619
+ renderLoading(sectionContainer, "Loading users…");
620
+
621
+ try {
622
+ const data = await api("/api/users");
623
+ const users = data.users || [];
624
+
625
+ if (!users.length) {
626
+ renderSuccess(sectionContainer, '<div class="text-muted">No users found.</div>');
334
627
  return;
335
628
  }
336
- container.innerHTML = data.roles.map(r =>
337
- `<span class="role-chip">${r.name}</span>`
338
- ).join("");
339
- };
340
629
 
341
- const addRoleBtn = document.getElementById("addRoleBtn");
342
- if (addRoleBtn) {
343
- addRoleBtn.addEventListener("click", async () => {
344
- const name = prompt("Role name (e.g. editor):");
345
- if (!name) return;
346
- const data = await api("/roles", {
347
- method: "POST",
348
- body: JSON.stringify({ name, description: "Custom role" }),
349
- });
350
- if (data.success) { showToast(`Role '${name}' created`); loadRoles(); }
351
- else showToast(data.error || "Failed to create role", "error");
630
+ const html = `
631
+ <div class="table-wrapper">
632
+ <table class="data-table">
633
+ <thead><tr><th>ID</th><th>Email</th><th>Role</th><th>Created</th><th>Roles</th></tr></thead>
634
+ <tbody>
635
+ ${users.map((u) => `
636
+ <tr>
637
+ <td style="font-family:var(--mono);font-size:.8rem">${u.id?.slice?.(0, 8) || "—" }…</td>
638
+ <td>${u.email || "—"}</td>
639
+ <td>${u.role || "user"}</td>
640
+ <td class="text-muted">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ""}</td>
641
+ <td><button class="btn btn--sm manage-roles-btn" data-user-id="${u.id}" style="font-size:.7rem">Manage roles</button></td>
642
+ </tr>
643
+ `).join("")}
644
+ </tbody>
645
+ </table>
646
+ </div>
647
+ <div id="userRolePanel" class="hidden" style="margin-top:12px"></div>
648
+ `;
649
+
650
+ renderSuccess(sectionContainer, html);
651
+
652
+ sectionContainer.querySelectorAll(".manage-roles-btn").forEach((btn) => {
653
+ btn.addEventListener("click", () => showRolePanel(btn.dataset.userId));
352
654
  });
655
+ } catch (e) {
656
+ renderError(sectionContainer, `Failed to load users: ${e.message}`);
353
657
  }
354
-
355
- const assignForm = document.getElementById("roleAssignForm");
356
- if (assignForm) {
357
- assignForm.addEventListener("submit", async (e) => {
358
- e.preventDefault();
359
- const userId = document.getElementById("assignUserId").value.trim();
360
- const roleName = document.getElementById("assignRoleName").value.trim();
361
- if (!userId || !roleName) return;
362
- const result = document.getElementById("roleAssignResult");
363
- const data = await api(`/roles/${roleName}/users/${userId}/assign`, { method: "POST" });
364
- if (data.success) { showResult(result, `Role '${roleName}' assigned to ${userId.slice(0,8)}…`, "ok"); }
365
- else { showResult(result, data.error || "Failed", "error"); }
658
+ }
659
+
660
+ async function showRolePanel(userId) {
661
+ const panel = document.getElementById("userRolePanel");
662
+ panel.classList.remove("hidden");
663
+ panel.innerHTML = 'Loading…';
664
+
665
+ try {
666
+ const data = await api(`/api/users/${userId}/roles`);
667
+ const roles = data.roles || [];
668
+ const availableRoles = ["admin", "user", "guest"];
669
+
670
+ panel.innerHTML = `
671
+ <div style="padding:8px 12px;background:#111;border-radius:6px">
672
+ <div style="font-size:.8rem;color:#888;margin-bottom:8px">User: ${userId.slice(0, 8)}…</div>
673
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
674
+ ${availableRoles.map((r) => `
675
+ <button class="btn btn--sm ${roles.includes(r) ? "" : "btn--primary"} role-toggle-btn" data-role="${r}" data-user-id="${userId}" data-active="${roles.includes(r)}">
676
+ ${r} ${roles.includes(r) ? "✓" : ""}
677
+ </button>
678
+ `).join("")}
679
+ </div>
680
+ </div>
681
+ `;
682
+
683
+ panel.querySelectorAll(".role-toggle-btn").forEach((btn) => {
684
+ btn.addEventListener("click", async () => {
685
+ const role = btn.dataset.role;
686
+ const uid = btn.dataset.userId;
687
+ const isActive = btn.dataset.active === "true";
688
+
689
+ try {
690
+ if (isActive) {
691
+ await api(`/api/users/${uid}/roles/${role}`, { method: "DELETE" });
692
+ } else {
693
+ await api(`/api/users/${uid}/roles`, { method: "POST", body: JSON.stringify({ role }) });
694
+ }
695
+ showToast(`Role '${role}' ${isActive ? "removed" : "assigned"}`, "ok");
696
+ showRolePanel(uid);
697
+ } catch (e) {
698
+ showToast(e.message, "error");
699
+ }
700
+ });
366
701
  });
702
+ } catch (e) {
703
+ panel.innerHTML = `<div class="text-muted">Failed to load roles: ${e.message}</div>`;
367
704
  }
705
+ }
368
706
 
369
- // ── API Keys ────────────────────────────────────────
370
- const keyForm = document.getElementById("keyForm");
371
- if (keyForm) {
372
- keyForm.addEventListener("submit", async (e) => {
373
- e.preventDefault();
374
- const name = document.getElementById("keyName").value.trim();
375
- if (!name) return;
376
- const result = document.getElementById("keyResult");
377
- const data = await api("/keys", { method: "POST", body: JSON.stringify({ name }) });
378
- if (data.success && data.key) {
379
- showResult(result, `Key generated (shown once):\n${data.key}`, "ok");
380
- } else {
381
- showResult(result, data.error || "Failed to generate key", "error");
382
- }
383
- });
707
+
708
+ // ── dashboard/sections/roles.js ──
709
+ /**
710
+ * Roles section — list roles and role assignments.
711
+ */
712
+
713
+
714
+ function loadRoles(sectionContainer) {
715
+ renderLoading(sectionContainer, "Loading roles…");
716
+
717
+ try {
718
+ const data = await api("/api/roles");
719
+ const roles = data.roles || [];
720
+
721
+ if (!roles.length) {
722
+ renderSuccess(sectionContainer, `
723
+ <div style="display:flex;justify-content:space-between;align-items:center">
724
+ <div class="text-muted">No roles defined.</div>
725
+ <button id="addRoleBtn" class="btn btn--primary btn--sm"><i class="ri-add-line"></i> Add role</button>
726
+ </div>
727
+ `);
728
+ document.getElementById("addRoleBtn")?.addEventListener("click", createRoleInline);
729
+ return;
730
+ }
731
+
732
+ const html = `
733
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
734
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
735
+ ${roles.map((r) => `<span class="role-chip">${r.name}</span>`).join("")}
736
+ </div>
737
+ <button id="addRoleBtn" class="btn btn--primary btn--sm"><i class="ri-add-line"></i> Add role</button>
738
+ </div>
739
+ `;
740
+
741
+ renderSuccess(sectionContainer, html);
742
+ document.getElementById("addRoleBtn")?.addEventListener("click", createRoleInline);
743
+ } catch (e) {
744
+ renderError(sectionContainer, `Failed to load roles: ${e.message}`);
384
745
  }
746
+ }
385
747
 
386
- // ── Env ─────────────────────────────────────────────
387
- window.loadEnv = async function() {
388
- const container = document.getElementById("envVars");
389
- const vars = ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "AUTHLY_SECRET", "GOOGLE_CLIENT_ID", "GITHUB_CLIENT_ID"];
390
- const data = await api("/audit", { method: "POST" });
391
- const checks = new Map();
392
- if (data.issues) data.issues.forEach(i => checks.set(i.check, i.status));
393
-
394
- container.innerHTML = vars.map(v => {
395
- const status = checks.has(v) ? (checks.get(v) === "ok" ? "set" : "unset") : "unknown";
396
- return `<div class="var-row">
397
- <span class="var-name">${v}</span>
398
- <span class="var-status ${status === "set" ? "var-status--set" : ""}">${status === "set" ? "● set" : status === "unknown" ? "— unknown" : "○ not set"}</span>
399
- </div>`;
400
- }).join("");
401
- };
748
+ async function createRoleInline() {
749
+ const name = prompt("Role name (e.g. editor):");
750
+ if (!name) return;
402
751
 
403
- // ── Migrations ──────────────────────────────────────
404
- window.loadMigrations = async function() {
405
- const container = document.getElementById("migrationList");
406
- const data = await api("/migrations");
407
- if (!data.success) return;
408
-
409
- container.innerHTML = data.migrations.map(m =>
410
- `<div class="migration-item">
411
- <i class="ri-file-code-line"></i>
412
- <span>${m.name}</span>
413
- <span class="text-muted" style="margin-left:4px">${m.description}</span>
414
- <button class="btn btn--sm btn--primary" onclick="runMigration('${m.name}')">Run</button>
415
- </div>`
416
- ).join("");
417
- };
752
+ try {
753
+ const data = await api("/api/roles", {
754
+ method: "POST",
755
+ body: JSON.stringify({ name, description: "Custom role" }),
756
+ });
757
+ if (data.success) {
758
+ showToast(`Role '${name}' created`, "ok");
759
+ loadRoles(document.querySelector('[data-section="roles"]') || document.getElementById("roles"));
760
+ } else {
761
+ showToast(data.error || "Failed", "error");
762
+ }
763
+ } catch (e) {
764
+ showToast(e.message, "error");
765
+ }
766
+ }
767
+
768
+
769
+ // ── dashboard/sections/apikeys.js ──
770
+ /**
771
+ * API Keys section — generate and validate keys.
772
+ */
773
+
774
+
775
+ function loadApiKeys(sectionContainer) {
776
+ renderLoading(sectionContainer, "Loading API keys…");
777
+
778
+ try {
779
+ const data = await api("/api/keys");
780
+ const keys = data.keys || [];
781
+
782
+ const html = `
783
+ <h3>Generate API Key</h3>
784
+ <p class="text-muted" style="margin-bottom:12px">Create a key for programmatic access. The raw key is shown <strong>only once</strong>.</p>
785
+ <div style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:16px">
786
+ <input id="keyName" placeholder="Key name" style="background:#000;border:1px solid #333;color:#fff;padding:8px 12px;border-radius:4px;font-size:.85rem" />
787
+ <button id="generateKeyBtn" class="btn btn--primary btn--sm">Generate</button>
788
+ </div>
789
+ <pre id="keyResult" class="code-block hidden" style="white-space:pre-wrap;font-size:.8rem;color:#22c55e"></pre>
790
+ <h3 style="margin-top:16px">Existing Keys</h3>
791
+ ${keys.length
792
+ ? `<div style="display:flex;flex-direction:column;gap:6px">
793
+ ${keys.map((k) => `
794
+ <div style="display:flex;gap:8px;align-items:center;padding:8px 12px;background:#111;border-radius:6px">
795
+ <code style="flex:1;font-size:.8rem">authly_...${k.key_hash?.slice?.(-6) || "—"}</code>
796
+ <span class="text-muted" style="font-size:.75rem">${k.name || ""}</span>
797
+ <span class="text-muted" style="font-size:.75rem">${k.scopes?.join?.(", ") || ""}</span>
798
+ <button class="btn btn--sm delete-key-btn" data-key-id="${k.id}">Delete</button>
799
+ </div>
800
+ `).join("")}
801
+ </div>`
802
+ : '<div class="text-muted">No API keys yet.</div>'
803
+ }
804
+ `;
418
805
 
419
- window.runMigration = async function(name) {
420
- const result = confirm(`Run migration '${name}'? This executes SQL directly.`);
421
- if (!result) return;
422
- const data = await api(`/migrations/${name}/run`, { method: "POST" });
423
- if (data.success) showToast(`Migration '${name}' applied`);
424
- else showToast(data.error || "Migration failed", "error");
425
- };
806
+ renderSuccess(sectionContainer, html);
426
807
 
427
- // ── Audit ───────────────────────────────────────────
428
- const auditBtn = document.getElementById("runAudit");
429
- if (auditBtn) {
430
- auditBtn.addEventListener("click", async () => {
431
- const resultDiv = document.getElementById("auditResult");
432
- resultDiv.classList.remove("hidden");
433
- resultDiv.textContent = "Running…";
434
-
435
- const data = await api("/audit", { method: "POST" });
436
- let html = `<p style="margin-bottom:8px">${data.success ? "✔ All checks passed" : `✘ ${data.issues.length} issue(s)`}</p>`;
437
- html += data.issues.map(i =>
438
- `<div style="font-size:.8rem;padding:4px 0;display:flex;gap:6px">
439
- <span>${i.status === "ok" ? "<span style='color:#22c55e'>✔</span>" : "<span style='color:#f87171'>✘</span>"}</span>
440
- <span>${i.check}${i.detail ? `: ${i.detail}` : ""}</span>
441
- </div>`
442
- ).join("");
443
- resultDiv.innerHTML = html;
808
+ document.getElementById("generateKeyBtn")?.addEventListener("click", generateKey);
809
+ sectionContainer.querySelectorAll(".delete-key-btn").forEach((btn) => {
810
+ btn.addEventListener("click", () => deleteKey(btn.dataset.keyId));
444
811
  });
812
+ } catch (e) {
813
+ renderError(sectionContainer, `Failed to load API keys: ${e.message}`);
445
814
  }
815
+ }
446
816
 
447
- // ── Scaffold ────────────────────────────────────────
448
- for (const card of document.querySelectorAll(".scaffold-card")) {
449
- card.addEventListener("click", async () => {
450
- const type = card.dataset.scaffold;
451
- const preview = document.getElementById("scaffoldPreview");
452
- const code = document.getElementById("scaffoldCode");
817
+ async function generateKey() {
818
+ const name = document.getElementById("keyName").value.trim();
819
+ if (!name) return;
453
820
 
454
- const data = await api("/scaffold/preview", {
455
- method: "POST",
456
- body: JSON.stringify({ type }),
457
- });
821
+ const result = document.getElementById("keyResult");
822
+ result.classList.remove("hidden");
823
+ result.textContent = "Generating…";
458
824
 
459
- if (data.success) {
460
- code.textContent = data.code;
461
- preview.classList.remove("hidden");
462
- }
825
+ try {
826
+ const data = await api("/api/keys", {
827
+ method: "POST",
828
+ body: JSON.stringify({ name }),
463
829
  });
830
+ if (data.success && data.key) {
831
+ result.textContent = `Key (copy now):\n${data.key}`;
832
+ showToast("API key generated — copy it now!", "ok");
833
+ } else {
834
+ result.textContent = `Error: ${data.error || "Unknown error"}`;
835
+ }
836
+ } catch (e) {
837
+ result.textContent = e.message;
464
838
  }
839
+ }
465
840
 
466
- // ── Mobile menu ─────────────────────────────────────
467
- if (menuBtn) menuBtn.addEventListener("click", () => sidebar.classList.toggle("open"));
841
+ async function deleteKey(id) {
842
+ if (!confirm("Delete this API key?")) return;
468
843
 
469
- // ── Toggles ─────────────────────────────────────────
470
- for (const toggle of document.querySelectorAll(".toggle")) {
471
- toggle.addEventListener("click", () => {
472
- toggle.setAttribute("aria-checked", String(toggle.getAttribute("aria-checked") !== "true"));
473
- });
844
+ try {
845
+ await api(`/api/keys/${id}`, { method: "DELETE" });
846
+ showToast("API key deleted", "ok");
847
+ loadApiKeys(document.querySelector('[data-section="api-keys"]') || document.getElementById("api-keys"));
848
+ } catch (e) {
849
+ showToast(e.message, "error");
474
850
  }
851
+ }
852
+
853
+
854
+ // ── dashboard/sections/audit.js ──
855
+ /**
856
+ * Audit section — run configuration health checks.
857
+ */
858
+
859
+
860
+ function loadAudit(sectionContainer) {
861
+ renderLoading(sectionContainer, "Running audit…");
862
+
863
+ try {
864
+ const data = await api("/api/audit");
865
+ const issues = data.issues || [];
866
+
867
+ const html = `
868
+ <div style="margin-bottom:12px">
869
+ <strong style="font-size:.9rem">${issues.every(i => i.level !== "error") ? "✔ Audit passed" : `✘ ${issues.filter(i => i.level === "error").length} issue(s)`}</strong>
870
+ </div>
871
+ <div style="display:flex;flex-direction:column;gap:6px">
872
+ ${issues.map(i => `
873
+ <div style="display:flex;gap:8px;align-items:center;padding:8px 12px;background:#111;border-radius:6px">
874
+ <span>${i.level === "error"
875
+ ? '<i class="ri-error-warning-line" style="color:#f87171"></i>'
876
+ : i.level === "warn"
877
+ ? '<i class="ri-alert-line" style="color:#f59e0b"></i>'
878
+ : '<i class="ri-check-line" style="color:#22c55e"></i>'
879
+ }</span>
880
+ <span style="font-size:.85rem">${i.message || i.check || ""}</span>
881
+ </div>
882
+ `).join("")}
883
+ </div>
884
+ `;
885
+
886
+ renderSuccess(sectionContainer, html);
887
+ } catch (e) {
888
+ renderError(sectionContainer, `Audit failed: ${e.message}`);
889
+ }
890
+ }
891
+
892
+
893
+ // ── dashboard/sections/mcp.js ──
894
+ /**
895
+ * MCP section — info and connection instructions.
896
+ */
897
+
898
+
899
+ function loadMCP(sectionContainer) {
900
+ renderSuccess(sectionContainer, `
901
+ <div>
902
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
903
+ <i class="ri-robot-line" style="font-size:1.2rem;color:#fff"></i>
904
+ <h3 style="margin:0">MCP Server</h3>
905
+ <span class="badge">BETA</span>
906
+ </div>
907
+ <p class="text-muted" style="margin-bottom:12px">Connect an MCP client (Claude Desktop, Cursor) to manage Supabase through tools.</p>
908
+ <pre class="code-block"><code>Connect to:
909
+ http://localhost:${location.port || 1284}/mcp
910
+
911
+ Tools available:
912
+ execute_sql, list_tables, describe_table
913
+ list_auth_users, list_roles, assign_role_to_user
914
+ revoke_role_from_user, get_user_roles
915
+ list_migrations, get_migration_sql, run_migration
916
+ connection_info</code></pre>
917
+ <p style="font-size:.8rem;color:#666;margin-top:12px">
918
+ <i class="ri-error-warning-line"></i> MCP tools are experimental.
919
+ </p>
920
+ </div>
921
+ `);
922
+ }
923
+
924
+
925
+ // ── dashboard/components/states.js ──
926
+ /**
927
+ * Shared state renderers for all sections.
928
+ * Every section uses exactly these 3 states.
929
+ */
930
+
931
+ function renderLoading(container, message = "Loading...") {
932
+ container.innerHTML = `<div class="state state--loading"><i class="ri-loader-4-line spinner"></i><span>${message}</span></div>`;
933
+ }
934
+
935
+ function renderError(container, message) {
936
+ container.innerHTML = `<div class="state state--error"><i class="ri-error-warning-line"></i><span>${message}</span></div>`;
937
+ }
938
+
939
+ function renderSuccess(container, content) {
940
+ container.innerHTML = `<div class="state state--success">${content}</div>`;
941
+ }
942
+
943
+
944
+ // ── dashboard/components/wizard.js ──
945
+ /**
946
+ * Reusable wizard/sidebar component.
947
+ * Opens a panel with tabbed content for provider setup, etc.
948
+ */
949
+
950
+ function openWizard(options = {}) {
951
+ const { title = "Setup", tabs = [], onClose } = options;
952
+ const existing = document.getElementById("wizard");
953
+ if (existing) existing.remove();
954
+
955
+ const wizard = document.createElement("aside");
956
+ wizard.id = "wizard";
957
+ wizard.className = "wizard";
958
+ wizard.innerHTML = `
959
+ <div class="wizard__header">
960
+ <h3>${title}</h3>
961
+ <button id="wizardClose" aria-label="Close"><i class="ri-close-line"></i></button>
962
+ </div>
963
+ <nav class="wizard__tabs" id="wizardTabs">
964
+ ${tabs.map((t, i) => `<button class="wizard__tab${i === 0 ? " active" : ""}" data-tab="${i}">${t.label}</button>`).join("")}
965
+ </nav>
966
+ <div class="wizard__body" id="wizardBody">
967
+ ${tabs[0]?.content ?? ""}
968
+ </div>
969
+ `;
970
+
971
+ document.querySelector(".main")?.appendChild(wizard);
972
+
973
+ // Tab switching
974
+ wizard.querySelectorAll(".wizard__tab").forEach((tab) => {
975
+ tab.addEventListener("click", () => {
976
+ wizard.querySelectorAll(".wizard__tab").forEach((t) => t.classList.remove("active"));
977
+ tab.classList.add("active");
978
+ const idx = tab.dataset.tab;
979
+ // Replace body content with the tab's content
980
+ const content = tabs[parseInt(idx)]?.content ?? "";
981
+ document.getElementById("wizardBody").innerHTML = content;
982
+ // Call any attached handlers
983
+ if (tabs[parseInt(idx)]?.onShow) tabs[parseInt(idx)].onShow();
984
+ });
985
+ });
986
+
987
+ // Close handler
988
+ document.getElementById("wizardClose")?.addEventListener("click", () => {
989
+ wizard.remove();
990
+ onClose?.();
991
+ });
992
+ }
993
+
994
+ function closeWizard() {
995
+ document.getElementById("wizard")?.remove();
996
+ }
475
997
 
476
- // ── Hash navigation ────────────────────────────────
477
- const hash = location.hash.replace("#", "");
478
- if (hash && document.getElementById(hash)) switchSection(hash);
479
- });