@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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base API client — all dashboard HTTP calls go through this.
|
|
3
|
+
* Points to the hosted Railway instance.
|
|
4
|
+
* No direct fetch() allowed in other files.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const BASE = "https://authly.rblez.com";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} path - API path (e.g. '/api/users')
|
|
11
|
+
* @param {RequestInit} [options]
|
|
12
|
+
* @returns {Promise<any>}
|
|
13
|
+
*/
|
|
14
|
+
export async function api(path, options = {}) {
|
|
15
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
...options,
|
|
18
|
+
});
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state renderers for all sections.
|
|
3
|
+
* Every section uses exactly these 3 states.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function renderLoading(container, message = "Loading...") {
|
|
7
|
+
container.innerHTML = `<div class="state state--loading"><i class="ri-loader-4-line spinner"></i><span>${message}</span></div>`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderError(container, message) {
|
|
11
|
+
container.innerHTML = `<div class="state state--error"><i class="ri-error-warning-line"></i><span>${message}</span></div>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderSuccess(container, content) {
|
|
15
|
+
container.innerHTML = `<div class="state state--success">${content}</div>`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast notification system.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function showToast(message, type = "ok") {
|
|
6
|
+
const toast = document.getElementById("toast");
|
|
7
|
+
if (!toast) return;
|
|
8
|
+
toast.textContent = message;
|
|
9
|
+
toast.className = `toast toast--${type}`;
|
|
10
|
+
toast.classList.remove("hidden");
|
|
11
|
+
setTimeout(() => toast.classList.add("hidden"), 3500);
|
|
12
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable wizard/sidebar component.
|
|
3
|
+
* Opens a panel with tabbed content for provider setup, etc.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function openWizard(options = {}) {
|
|
7
|
+
const { title = "Setup", tabs = [], onClose } = options;
|
|
8
|
+
const existing = document.getElementById("wizard");
|
|
9
|
+
if (existing) existing.remove();
|
|
10
|
+
|
|
11
|
+
const wizard = document.createElement("aside");
|
|
12
|
+
wizard.id = "wizard";
|
|
13
|
+
wizard.className = "wizard";
|
|
14
|
+
wizard.innerHTML = `
|
|
15
|
+
<div class="wizard__header">
|
|
16
|
+
<h3>${title}</h3>
|
|
17
|
+
<button id="wizardClose" aria-label="Close"><i class="ri-close-line"></i></button>
|
|
18
|
+
</div>
|
|
19
|
+
<nav class="wizard__tabs" id="wizardTabs">
|
|
20
|
+
${tabs.map((t, i) => `<button class="wizard__tab${i === 0 ? " active" : ""}" data-tab="${i}">${t.label}</button>`).join("")}
|
|
21
|
+
</nav>
|
|
22
|
+
<div class="wizard__body" id="wizardBody">
|
|
23
|
+
${tabs[0]?.content ?? ""}
|
|
24
|
+
</div>
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
document.querySelector(".main")?.appendChild(wizard);
|
|
28
|
+
|
|
29
|
+
// Tab switching
|
|
30
|
+
wizard.querySelectorAll(".wizard__tab").forEach((tab) => {
|
|
31
|
+
tab.addEventListener("click", () => {
|
|
32
|
+
wizard.querySelectorAll(".wizard__tab").forEach((t) => t.classList.remove("active"));
|
|
33
|
+
tab.classList.add("active");
|
|
34
|
+
const idx = tab.dataset.tab;
|
|
35
|
+
// Replace body content with the tab's content
|
|
36
|
+
const content = tabs[parseInt(idx)]?.content ?? "";
|
|
37
|
+
document.getElementById("wizardBody").innerHTML = content;
|
|
38
|
+
// Call any attached handlers
|
|
39
|
+
if (tabs[parseInt(idx)]?.onShow) tabs[parseInt(idx)].onShow();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Close handler
|
|
44
|
+
document.getElementById("wizardClose")?.addEventListener("click", () => {
|
|
45
|
+
wizard.remove();
|
|
46
|
+
onClose?.();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function closeWizard() {
|
|
51
|
+
document.getElementById("wizard")?.remove();
|
|
52
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authly Dashboard — entry point.
|
|
3
|
+
* Bundles all sections into a single app.js by esbuild.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { api, BASE } from "./api.js";
|
|
7
|
+
import { state, setState } from "./state.js";
|
|
8
|
+
import { showToast } from "./components/toast.js";
|
|
9
|
+
import { loadSupabase, checkSupabaseStatus } from "./sections/supabase.js";
|
|
10
|
+
import { loadProviders } from "./sections/providers.js";
|
|
11
|
+
import { loadMigrations } from "./sections/migrations.js";
|
|
12
|
+
import { loadUsers } from "./sections/users.js";
|
|
13
|
+
import { loadRoles } from "./sections/roles.js";
|
|
14
|
+
import { loadApiKeys } from "./sections/apikeys.js";
|
|
15
|
+
import { loadAudit } from "./sections/audit.js";
|
|
16
|
+
import { loadMCP } from "./sections/mcp.js";
|
|
17
|
+
|
|
18
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
19
|
+
const navItems = document.querySelectorAll(".nav-item");
|
|
20
|
+
const sections = document.querySelectorAll(".section");
|
|
21
|
+
const headerTitle = document.getElementById("headerTitle");
|
|
22
|
+
const sidebar = document.getElementById("sidebar");
|
|
23
|
+
const menuBtn = document.getElementById("menuBtn");
|
|
24
|
+
const statusText = document.getElementById("statusText");
|
|
25
|
+
const statusDot = document.querySelector(".header__dot");
|
|
26
|
+
|
|
27
|
+
checkHealth();
|
|
28
|
+
|
|
29
|
+
// ── Health ──────────────────────────────────────────
|
|
30
|
+
async function checkHealth() {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${BASE}/health`);
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
statusText.textContent = "Connected";
|
|
35
|
+
if (statusDot) statusDot.style.background = "#22c55e";
|
|
36
|
+
} else { setDisconnected(); }
|
|
37
|
+
} catch { setDisconnected(); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setDisconnected() {
|
|
41
|
+
statusText.textContent = "Disconnected";
|
|
42
|
+
if (statusDot) statusDot.style.background = "#ef4444";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Navigate ────────────────────────────────────────
|
|
46
|
+
const sectionLoaders = {
|
|
47
|
+
init: null,
|
|
48
|
+
integration: loadSupabase,
|
|
49
|
+
providers: loadProviders,
|
|
50
|
+
ui: null,
|
|
51
|
+
routes: null,
|
|
52
|
+
roles: loadRoles,
|
|
53
|
+
"api-keys": loadApiKeys,
|
|
54
|
+
env: null,
|
|
55
|
+
migrate: loadMigrations,
|
|
56
|
+
users: loadUsers,
|
|
57
|
+
mcp: loadMCP,
|
|
58
|
+
audit: loadAudit,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const item of navItems) {
|
|
62
|
+
item.addEventListener("click", (e) => {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
switchSection(item.dataset.section);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function switchSection(id) {
|
|
69
|
+
for (const n of navItems) n.classList.remove("active");
|
|
70
|
+
document.querySelector(`[data-section="${id}"]`)?.classList.add("active");
|
|
71
|
+
for (const sec of sections) sec.classList.toggle("hidden", sec.id !== id);
|
|
72
|
+
if (headerTitle) headerTitle.textContent = document.querySelector(`[data-section="${id}"]`)?.textContent?.trim() ?? "";
|
|
73
|
+
sidebar?.classList.remove("open");
|
|
74
|
+
|
|
75
|
+
const container = document.getElementById(id);
|
|
76
|
+
const loader = sectionLoaders[id];
|
|
77
|
+
if (loader && container) loader(container);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Mobile menu ─────────────────────────────────────
|
|
81
|
+
if (menuBtn) menuBtn.addEventListener("click", () => sidebar?.classList.toggle("open"));
|
|
82
|
+
|
|
83
|
+
// ── Toggles ─────────────────────────────────────────
|
|
84
|
+
document.querySelectorAll(".toggle")?.forEach((toggle) => {
|
|
85
|
+
toggle.addEventListener("click", () => {
|
|
86
|
+
toggle.setAttribute("aria-checked", String(toggle.getAttribute("aria-checked") !== "true"));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Doc panel toggles ──────────────────────────────
|
|
91
|
+
document.querySelectorAll(".toggle-docs")?.forEach((btn) => {
|
|
92
|
+
btn.addEventListener("click", () => {
|
|
93
|
+
const target = document.getElementById(btn.dataset.target);
|
|
94
|
+
if (!target) return;
|
|
95
|
+
target.classList.toggle("collapsed");
|
|
96
|
+
target.classList.toggle("expanded");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── Scaffold (UI section) ───────────────────────────
|
|
101
|
+
document.querySelectorAll(".scaffold-card")?.forEach((card) => {
|
|
102
|
+
card.addEventListener("click", async () => {
|
|
103
|
+
const type = card.dataset.scaffold;
|
|
104
|
+
const preview = document.getElementById("scaffoldPreview");
|
|
105
|
+
const code = document.getElementById("scaffoldCode");
|
|
106
|
+
try {
|
|
107
|
+
const data = await api("/api/scaffold/preview", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
body: JSON.stringify({ type }),
|
|
110
|
+
});
|
|
111
|
+
if (data.success && code && preview) {
|
|
112
|
+
code.textContent = data.code;
|
|
113
|
+
preview.classList.remove("hidden");
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
showToast(e.message, "error");
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── Hash navigation ────────────────────────────────
|
|
122
|
+
const hash = location.hash.replace("#", "");
|
|
123
|
+
if (hash && document.getElementById(hash)) switchSection(hash);
|
|
124
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Keys section — generate and validate keys.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { api } from "../api.js";
|
|
6
|
+
import { renderLoading, renderError, renderSuccess } from "../components/states.js";
|
|
7
|
+
import { showToast } from "../components/toast.js";
|
|
8
|
+
|
|
9
|
+
export async function loadApiKeys(sectionContainer) {
|
|
10
|
+
renderLoading(sectionContainer, "Loading API keys…");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const data = await api("/api/keys");
|
|
14
|
+
const keys = data.keys || [];
|
|
15
|
+
|
|
16
|
+
const html = `
|
|
17
|
+
<h3>Generate API Key</h3>
|
|
18
|
+
<p class="text-muted" style="margin-bottom:12px">Create a key for programmatic access. The raw key is shown <strong>only once</strong>.</p>
|
|
19
|
+
<div style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:16px">
|
|
20
|
+
<input id="keyName" placeholder="Key name" style="background:#000;border:1px solid #333;color:#fff;padding:8px 12px;border-radius:4px;font-size:.85rem" />
|
|
21
|
+
<button id="generateKeyBtn" class="btn btn--primary btn--sm">Generate</button>
|
|
22
|
+
</div>
|
|
23
|
+
<pre id="keyResult" class="code-block hidden" style="white-space:pre-wrap;font-size:.8rem;color:#22c55e"></pre>
|
|
24
|
+
<h3 style="margin-top:16px">Existing Keys</h3>
|
|
25
|
+
${keys.length
|
|
26
|
+
? `<div style="display:flex;flex-direction:column;gap:6px">
|
|
27
|
+
${keys.map((k) => `
|
|
28
|
+
<div style="display:flex;gap:8px;align-items:center;padding:8px 12px;background:#111;border-radius:6px">
|
|
29
|
+
<code style="flex:1;font-size:.8rem">authly_...${k.key_hash?.slice?.(-6) || "—"}</code>
|
|
30
|
+
<span class="text-muted" style="font-size:.75rem">${k.name || ""}</span>
|
|
31
|
+
<span class="text-muted" style="font-size:.75rem">${k.scopes?.join?.(", ") || ""}</span>
|
|
32
|
+
<button class="btn btn--sm delete-key-btn" data-key-id="${k.id}">Delete</button>
|
|
33
|
+
</div>
|
|
34
|
+
`).join("")}
|
|
35
|
+
</div>`
|
|
36
|
+
: '<div class="text-muted">No API keys yet.</div>'
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
renderSuccess(sectionContainer, html);
|
|
41
|
+
|
|
42
|
+
document.getElementById("generateKeyBtn")?.addEventListener("click", generateKey);
|
|
43
|
+
sectionContainer.querySelectorAll(".delete-key-btn").forEach((btn) => {
|
|
44
|
+
btn.addEventListener("click", () => deleteKey(btn.dataset.keyId));
|
|
45
|
+
});
|
|
46
|
+
} catch (e) {
|
|
47
|
+
renderError(sectionContainer, `Failed to load API keys: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function generateKey() {
|
|
52
|
+
const name = document.getElementById("keyName").value.trim();
|
|
53
|
+
if (!name) return;
|
|
54
|
+
|
|
55
|
+
const result = document.getElementById("keyResult");
|
|
56
|
+
result.classList.remove("hidden");
|
|
57
|
+
result.textContent = "Generating…";
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const data = await api("/api/keys", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: JSON.stringify({ name }),
|
|
63
|
+
});
|
|
64
|
+
if (data.success && data.key) {
|
|
65
|
+
result.textContent = `Key (copy now):\n${data.key}`;
|
|
66
|
+
showToast("API key generated — copy it now!", "ok");
|
|
67
|
+
} else {
|
|
68
|
+
result.textContent = `Error: ${data.error || "Unknown error"}`;
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
result.textContent = e.message;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function deleteKey(id) {
|
|
76
|
+
if (!confirm("Delete this API key?")) return;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await api(`/api/keys/${id}`, { method: "DELETE" });
|
|
80
|
+
showToast("API key deleted", "ok");
|
|
81
|
+
loadApiKeys(document.querySelector('[data-section="api-keys"]') || document.getElementById("api-keys"));
|
|
82
|
+
} catch (e) {
|
|
83
|
+
showToast(e.message, "error");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit section — run configuration health checks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { api } from "../api.js";
|
|
6
|
+
import { renderLoading, renderError, renderSuccess } from "../components/states.js";
|
|
7
|
+
|
|
8
|
+
export async function loadAudit(sectionContainer) {
|
|
9
|
+
renderLoading(sectionContainer, "Running audit…");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const data = await api("/api/audit");
|
|
13
|
+
const issues = data.issues || [];
|
|
14
|
+
|
|
15
|
+
const html = `
|
|
16
|
+
<div style="margin-bottom:12px">
|
|
17
|
+
<strong style="font-size:.9rem">${issues.every(i => i.level !== "error") ? "✔ Audit passed" : `✘ ${issues.filter(i => i.level === "error").length} issue(s)`}</strong>
|
|
18
|
+
</div>
|
|
19
|
+
<div style="display:flex;flex-direction:column;gap:6px">
|
|
20
|
+
${issues.map(i => `
|
|
21
|
+
<div style="display:flex;gap:8px;align-items:center;padding:8px 12px;background:#111;border-radius:6px">
|
|
22
|
+
<span>${i.level === "error"
|
|
23
|
+
? '<i class="ri-error-warning-line" style="color:#f87171"></i>'
|
|
24
|
+
: i.level === "warn"
|
|
25
|
+
? '<i class="ri-alert-line" style="color:#f59e0b"></i>'
|
|
26
|
+
: '<i class="ri-check-line" style="color:#22c55e"></i>'
|
|
27
|
+
}</span>
|
|
28
|
+
<span style="font-size:.85rem">${i.message || i.check || ""}</span>
|
|
29
|
+
</div>
|
|
30
|
+
`).join("")}
|
|
31
|
+
</div>
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
renderSuccess(sectionContainer, html);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
renderError(sectionContainer, `Audit failed: ${e.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP section — info and connection instructions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderSuccess } from "../components/states.js";
|
|
6
|
+
|
|
7
|
+
export function loadMCP(sectionContainer) {
|
|
8
|
+
renderSuccess(sectionContainer, `
|
|
9
|
+
<div>
|
|
10
|
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
|
11
|
+
<i class="ri-robot-line" style="font-size:1.2rem;color:#fff"></i>
|
|
12
|
+
<h3 style="margin:0">MCP Server</h3>
|
|
13
|
+
<span class="badge">BETA</span>
|
|
14
|
+
</div>
|
|
15
|
+
<p class="text-muted" style="margin-bottom:12px">Connect an MCP client (Claude Desktop, Cursor) to manage Supabase through tools.</p>
|
|
16
|
+
<pre class="code-block"><code>Connect to:
|
|
17
|
+
http://localhost:${location.port || 1284}/mcp
|
|
18
|
+
|
|
19
|
+
Tools available:
|
|
20
|
+
execute_sql, list_tables, describe_table
|
|
21
|
+
list_auth_users, list_roles, assign_role_to_user
|
|
22
|
+
revoke_role_from_user, get_user_roles
|
|
23
|
+
list_migrations, get_migration_sql, run_migration
|
|
24
|
+
connection_info</code></pre>
|
|
25
|
+
<p style="font-size:.8rem;color:#666;margin-top:12px">
|
|
26
|
+
<i class="ri-error-warning-line"></i> MCP tools are experimental.
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations section — list and run SQL migrations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { api } from "../api.js";
|
|
6
|
+
import { renderLoading, renderError, renderSuccess } from "../components/states.js";
|
|
7
|
+
import { showToast } from "../components/toast.js";
|
|
8
|
+
|
|
9
|
+
export async function loadMigrations(sectionContainer) {
|
|
10
|
+
renderLoading(sectionContainer, "Loading migrations…");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const data = await api("/api/migrations");
|
|
14
|
+
const migrations = data.migrations || [];
|
|
15
|
+
|
|
16
|
+
const html = `
|
|
17
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
|
18
|
+
<h3>Migrations</h3>
|
|
19
|
+
<button id="runAllPending" class="btn btn--primary btn--sm">
|
|
20
|
+
<i class="ri-play-line"></i> Run all pending
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
<div id="migrationList">
|
|
24
|
+
${migrations.map((m) => `
|
|
25
|
+
<div class="migration-item">
|
|
26
|
+
<span class="badge badge--${m.status === 'applied' ? 'ok' : 'pending'}">${m.status}</span>
|
|
27
|
+
<code style="font-size:.8rem">${m.name}</code>
|
|
28
|
+
<span class="text-muted">${m.description || ""}</span>
|
|
29
|
+
${m.status === 'pending'
|
|
30
|
+
? `<button class="btn btn--sm run-migration" data-name="${m.name}">Run</button>`
|
|
31
|
+
: ""
|
|
32
|
+
}
|
|
33
|
+
</div>
|
|
34
|
+
`).join("")}
|
|
35
|
+
</div>
|
|
36
|
+
<pre id="migrationOutput" class="code-block hidden" style="white-space:pre-wrap;font-size:.75rem;color:#888"></pre>
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
renderSuccess(sectionContainer, html);
|
|
40
|
+
|
|
41
|
+
// Bind run buttons
|
|
42
|
+
sectionContainer.querySelectorAll(".run-migration").forEach((btn) => {
|
|
43
|
+
btn.addEventListener("click", () => runMigration(btn.dataset.name));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
document.getElementById("runAllPending")?.addEventListener("click", runAllPending);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
renderError(sectionContainer, `Failed to load migrations: ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runMigration(name) {
|
|
53
|
+
if (!confirm(`Run migration '${name}'? This executes SQL directly.`)) return;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const data = await api(`/api/migrations/${name}/run`, { method: "POST" });
|
|
57
|
+
const output = document.getElementById("migrationOutput");
|
|
58
|
+
if (output) {
|
|
59
|
+
output.classList.remove("hidden");
|
|
60
|
+
output.textContent = data.output || "Migration applied";
|
|
61
|
+
}
|
|
62
|
+
if (data.ok) {
|
|
63
|
+
showToast(`Migration '${name}' applied`, "ok");
|
|
64
|
+
} else {
|
|
65
|
+
showToast(`Migration '${name}' failed: ${data.error}`, "error");
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
showToast(`Migration failed: ${e.message}`, "error");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runAllPending() {
|
|
73
|
+
try {
|
|
74
|
+
const data = await api("/api/migrations/pending/run", { method: "POST" });
|
|
75
|
+
const output = document.getElementById("migrationOutput");
|
|
76
|
+
if (output) {
|
|
77
|
+
output.classList.remove("hidden");
|
|
78
|
+
output.textContent = data.output || "All pending migrations applied";
|
|
79
|
+
}
|
|
80
|
+
showToast("All pending migrations applied", "ok");
|
|
81
|
+
} catch (e) {
|
|
82
|
+
showToast(`Failed: ${e.message}`, "error");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers section — Google, GitHub, Discord.
|
|
3
|
+
* Shows provider status with Setup wizard.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { api } from "../api.js";
|
|
7
|
+
import { renderLoading, renderError, renderSuccess } from "../components/states.js";
|
|
8
|
+
import { openWizard } from "../components/wizard.js";
|
|
9
|
+
import { showToast } from "../components/toast.js";
|
|
10
|
+
|
|
11
|
+
const PROVIDERS = ["google", "github", "discord"];
|
|
12
|
+
|
|
13
|
+
const PROVIDER_LABELS = {
|
|
14
|
+
google: "Google",
|
|
15
|
+
github: "GitHub",
|
|
16
|
+
discord: "Discord",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const CALLBACK_URLS = {
|
|
20
|
+
google: "https://authly.rblez.com/api/auth/google/callback",
|
|
21
|
+
github: "https://authly.rblez.com/api/auth/github/callback",
|
|
22
|
+
discord: "https://authly.rblez.com/api/auth/discord/callback",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DOCS_URLS = {
|
|
26
|
+
google: "https://console.cloud.google.com/apis/credentials",
|
|
27
|
+
github: "https://github.com/settings/applications/new",
|
|
28
|
+
discord: "https://discord.com/developers/applications",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const GUIDE_STEPS = {
|
|
32
|
+
google: [
|
|
33
|
+
"Go to Google Cloud Console → APIs & Services → Credentials",
|
|
34
|
+
"Create a project or select an existing one",
|
|
35
|
+
'Click "Create Credentials" → OAuth 2.0 Client ID',
|
|
36
|
+
"Application type: Web application",
|
|
37
|
+
"Add the callback URL below as an Authorized redirect URI",
|
|
38
|
+
],
|
|
39
|
+
github: [
|
|
40
|
+
"Go to GitHub Settings → Developer settings → OAuth Apps",
|
|
41
|
+
'Click "New OAuth App"',
|
|
42
|
+
"Fill in Application name and Homepage URL",
|
|
43
|
+
"Add the callback URL below as Authorization callback URL",
|
|
44
|
+
],
|
|
45
|
+
discord: [
|
|
46
|
+
"Go to Discord Developer Portal → Applications",
|
|
47
|
+
"Create a New Application",
|
|
48
|
+
"Go to OAuth2 → Redirects → Add Redirect",
|
|
49
|
+
"Add the callback URL below",
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function loadProviders(sectionContainer) {
|
|
54
|
+
renderLoading(sectionContainer, "Loading providers…");
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const data = await api("/api/providers");
|
|
58
|
+
const providers = data.providers || [];
|
|
59
|
+
|
|
60
|
+
const html = providers.map((p) => {
|
|
61
|
+
const label = PROVIDER_LABELS[p.name] || p.name;
|
|
62
|
+
const iconUrl = `https://cdn.simpleicons.org/${p.name}/fff`;
|
|
63
|
+
const iconClass = p.enabled ? "enabled" : "disabled";
|
|
64
|
+
return `<div class="provider-card">
|
|
65
|
+
<img src="${iconUrl}" width="20" height="20" alt="${label}" />
|
|
66
|
+
<div class="provider-info">
|
|
67
|
+
<div class="provider-name">${label}</div>
|
|
68
|
+
<div class="provider-scopes">${p.scopes || ""}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<span class="provider-status ${iconClass}">${p.enabled ? "Enabled" : "Disabled"}</span>
|
|
71
|
+
${p.enabled
|
|
72
|
+
? `<button class="btn btn--sm edit-provider-btn" data-provider="${p.name}">Edit</button>`
|
|
73
|
+
: `<button class="btn btn--sm btn--primary setup-provider-btn" data-provider="${p.name}">Setup</button>`
|
|
74
|
+
}
|
|
75
|
+
</div>`;
|
|
76
|
+
}).join("");
|
|
77
|
+
|
|
78
|
+
renderSuccess(sectionContainer, html);
|
|
79
|
+
|
|
80
|
+
// Bind buttons
|
|
81
|
+
sectionContainer.querySelectorAll(".setup-provider-btn").forEach((btn) => {
|
|
82
|
+
btn.addEventListener("click", () => openProviderWizard(btn.dataset.provider));
|
|
83
|
+
});
|
|
84
|
+
sectionContainer.querySelectorAll(".edit-provider-btn").forEach((btn) => {
|
|
85
|
+
btn.addEventListener("click", () => openProviderWizard(btn.dataset.provider, 1));
|
|
86
|
+
});
|
|
87
|
+
} catch (e) {
|
|
88
|
+
renderError(sectionContainer, `Failed to load providers: ${e.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function openProviderWizard(name, defaultTab = 0) {
|
|
93
|
+
const label = PROVIDER_LABELS[name];
|
|
94
|
+
const callbackUrl = CALLBACK_URLS[name];
|
|
95
|
+
|
|
96
|
+
const tabs = [
|
|
97
|
+
{
|
|
98
|
+
label: "Guide",
|
|
99
|
+
content: `
|
|
100
|
+
<h4>${label} Setup Guide</h4>
|
|
101
|
+
<ol style="color:#aaa;font-size:.85rem;line-height:2;padding-left:18px">
|
|
102
|
+
${(GUIDE_STEPS[name] || []).map((s) => `<li>${s}</li>`).join("")}
|
|
103
|
+
</ol>
|
|
104
|
+
<div style="margin-top:12px;padding:8px 12px;background:#111;border-radius:6px">
|
|
105
|
+
<div style="font-size:.75rem;color:#555;margin-bottom:4px">Callback URL</div>
|
|
106
|
+
<div style="display:flex;gap:6px;align-items:center">
|
|
107
|
+
<code id="cbUrl" style="font-size:.8rem;color:#58a6ff;flex:1">${callbackUrl}</code>
|
|
108
|
+
<button class="btn btn--sm" id="copyCbUrl" title="Copy">Copy</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div style="margin-top:8px">
|
|
112
|
+
<a href="${DOCS_URLS[name]}" target="_blank" rel="noopener" class="btn btn--sm" style="background:#222;color:#888;text-decoration:none">
|
|
113
|
+
Open ${label} Console <i class="ri-external-link-line" style="font-size:.7rem"></i>
|
|
114
|
+
</a>
|
|
115
|
+
</div>
|
|
116
|
+
`,
|
|
117
|
+
onShow: () => {
|
|
118
|
+
document.getElementById("copyCbUrl")?.addEventListener("click", () => {
|
|
119
|
+
navigator.clipboard.writeText(callbackUrl);
|
|
120
|
+
showToast("Callback URL copied", "ok");
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: "Enter Keys",
|
|
126
|
+
content: `
|
|
127
|
+
<h4>${label} OAuth Credentials</h4>
|
|
128
|
+
<div style="display:flex;flex-direction:column;gap:8px;margin-top:12px">
|
|
129
|
+
<input id="providerClientId" placeholder="Client ID" style="background:#000;border:1px solid #333;color:#fff;padding:8px 12px;border-radius:4px;font-size:.85rem" />
|
|
130
|
+
<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" />
|
|
131
|
+
<div id="providerKeyResult" class="result hidden"></div>
|
|
132
|
+
<button id="providerSaveKeys" class="btn btn--primary btn--sm">Validate & Save</button>
|
|
133
|
+
</div>
|
|
134
|
+
`,
|
|
135
|
+
onShow: () => {
|
|
136
|
+
document.getElementById("providerSaveKeys")?.addEventListener("click", async () => {
|
|
137
|
+
const clientId = document.getElementById("providerClientId").value.trim();
|
|
138
|
+
const clientSecret = document.getElementById("providerClientSecret").value.trim();
|
|
139
|
+
if (!clientId || !clientSecret) return;
|
|
140
|
+
|
|
141
|
+
const resultDiv = document.getElementById("providerKeyResult");
|
|
142
|
+
resultDiv.classList.remove("hidden");
|
|
143
|
+
resultDiv.innerHTML = "Validating…";
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const res = await api(`/api/providers/${name}/keys`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
body: JSON.stringify({ clientId, clientSecret }),
|
|
149
|
+
});
|
|
150
|
+
if (res.valid) {
|
|
151
|
+
resultDiv.innerHTML = `<span style="color:#22c55e">Keys validated and saved</span>`;
|
|
152
|
+
showToast(`${label} provider configured`, "ok");
|
|
153
|
+
} else {
|
|
154
|
+
resultDiv.innerHTML = `<span style="color:#f87171">Validation failed: ${res.error || "invalid keys"}</span>`;
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
resultDiv.innerHTML = `<span style="color:#f87171">${e.message}</span>`;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
label: "Test",
|
|
164
|
+
content: `
|
|
165
|
+
<h4>Test ${label} Connection</h4>
|
|
166
|
+
<p class="text-muted" style="font-size:.8rem;margin:8px 0">Make a test OAuth request to verify your configuration.</p>
|
|
167
|
+
<pre id="providerTestResult" class="code-block" style="white-space:pre-wrap;font-size:.75rem;color:#888"></pre>
|
|
168
|
+
<button id="providerTestBtn" class="btn btn--sm">Run Test</button>
|
|
169
|
+
`,
|
|
170
|
+
onShow: () => {
|
|
171
|
+
document.getElementById("providerTestBtn")?.addEventListener("click", async () => {
|
|
172
|
+
const output = document.getElementById("providerTestResult");
|
|
173
|
+
output.textContent = "Testing…";
|
|
174
|
+
try {
|
|
175
|
+
const data = await api(`/api/providers/${name}/test`);
|
|
176
|
+
output.textContent = JSON.stringify(data, null, 2);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
output.textContent = e.message;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
openWizard({ title: `${label} Setup`, tabs, defaultTab });
|
|
186
|
+
}
|