@rblez/authly 0.4.1 → 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.
- package/dist/dashboard/app.js +915 -397
- package/dist/dashboard/app.js.map +38 -0
- package/dist/dashboard/index.html +12 -49
- package/dist/dashboard/styles.css +63 -0
- package/package.json +3 -1
- package/src/commands/serve.js +490 -263
- package/src/dashboard/api.js +22 -0
- package/src/dashboard/components/states.js +16 -0
- package/src/dashboard/components/toast.js +12 -0
- package/src/dashboard/components/wizard.js +52 -0
- package/src/dashboard/index.js +124 -0
- package/src/dashboard/sections/apikeys.js +85 -0
- package/src/dashboard/sections/audit.js +38 -0
- package/src/dashboard/sections/mcp.js +30 -0
- package/src/dashboard/sections/migrations.js +84 -0
- package/src/dashboard/sections/providers.js +186 -0
- package/src/dashboard/sections/roles.js +61 -0
- package/src/dashboard/sections/supabase.js +151 -0
- package/src/dashboard/sections/users.js +96 -0
- package/src/dashboard/state.js +30 -0
package/dist/dashboard/app.js
CHANGED
|
@@ -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(`${
|
|
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
|
-
// ──
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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("
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 & 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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
269
|
-
|
|
534
|
+
function loadMigrations(sectionContainer) {
|
|
535
|
+
renderLoading(sectionContainer, "Loading migrations…");
|
|
270
536
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
});
|
|
821
|
+
const result = document.getElementById("keyResult");
|
|
822
|
+
result.classList.remove("hidden");
|
|
823
|
+
result.textContent = "Generating…";
|
|
458
824
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
467
|
-
if (
|
|
841
|
+
async function deleteKey(id) {
|
|
842
|
+
if (!confirm("Delete this API key?")) return;
|
|
468
843
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
});
|