@rblez/authly 0.1.0 → 0.2.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 +182 -32
- package/dist/dashboard/authorize.html +79 -0
- package/dist/dashboard/index.html +191 -15
- package/dist/dashboard/styles.css +10 -0
- package/package.json +1 -1
- package/src/auth/index.js +98 -0
- package/src/commands/serve.js +91 -14
- package/src/generators/env.js +3 -2
- package/src/generators/migrations.js +17 -0
- package/src/integrations/supabase.js +156 -0
- package/src/lib/oauth.js +23 -1
package/dist/dashboard/app.js
CHANGED
|
@@ -44,9 +44,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
44
44
|
if (res.ok) {
|
|
45
45
|
statusText.textContent = "Connected";
|
|
46
46
|
statusDot.style.background = "#22c55e";
|
|
47
|
-
} else {
|
|
48
|
-
setDisconnected();
|
|
49
|
-
}
|
|
47
|
+
} else { setDisconnected(); }
|
|
50
48
|
} catch { setDisconnected(); }
|
|
51
49
|
}
|
|
52
50
|
|
|
@@ -55,6 +53,132 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
55
53
|
statusDot.style.background = "#ef4444";
|
|
56
54
|
}
|
|
57
55
|
|
|
56
|
+
// ── Auto-detect Supabase ──────────────────────────────
|
|
57
|
+
async function autoDetectSupabase() {
|
|
58
|
+
const scanStatus = document.getElementById("scanStatus");
|
|
59
|
+
const scanDetail = document.getElementById("scanDetail");
|
|
60
|
+
const scanAction = document.getElementById("scanAction");
|
|
61
|
+
const intStatus = document.getElementById("integrationStatus");
|
|
62
|
+
const intDetail = document.getElementById("integrationDetail");
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const scan = await api("/init/scan");
|
|
66
|
+
|
|
67
|
+
if (scan.detected && scan.url && scan.anonKey && scan.serviceKey) {
|
|
68
|
+
// Auto-connect
|
|
69
|
+
scanStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Supabase credentials found — auto-connecting…</span>`;
|
|
70
|
+
const data = await api("/init/connect", { method: "POST" });
|
|
71
|
+
|
|
72
|
+
if (data.success) {
|
|
73
|
+
scanStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Auto-connected to Supabase</span>`;
|
|
74
|
+
const ref = scan.projectRef || url.ref;
|
|
75
|
+
|
|
76
|
+
if (scanDetail) {
|
|
77
|
+
scanDetail.classList.remove("hidden");
|
|
78
|
+
scanDetail.innerHTML = `
|
|
79
|
+
<div style="color:#aaa;font-size:.78rem;line-height:1.7">
|
|
80
|
+
<div>URL: <code style="color:#c9c">${maskKey(scan.url)}</code></div>
|
|
81
|
+
<div>Project ref: <code style="color:#c9c">${scan.projectRef || "—"}</code></div>
|
|
82
|
+
<div>Found in: <span style="color:#22c55e">${scan.sources.join(", ")}</span></div>
|
|
83
|
+
<div>Framework: <code style="color:#c9c">${scan.framework || "unknown"}</code></div>
|
|
84
|
+
</div>
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (intStatus) {
|
|
89
|
+
intStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Connected to Supabase</span>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (intDetail) {
|
|
93
|
+
intDetail.classList.remove("hidden");
|
|
94
|
+
intDetail.innerHTML = `
|
|
95
|
+
<div style="color:#aaa;font-size:.78rem;line-height:1.7">
|
|
96
|
+
<div>URL: <code style="color:#c9c">${maskKey(scan.url)}</code></div>
|
|
97
|
+
<div>Project ref: <code style="color:#c9c">${scan.projectRef || "—"}</code></div>
|
|
98
|
+
<div>Framework: <code style="color:#c9c">${scan.framework || "unknown"}</code></div>
|
|
99
|
+
<div>Sources: <span style="color:#22c55e">${scan.sources.join(", ")}</span></div>
|
|
100
|
+
<div>Can connect: <span style="color:#22c55e">● yes</span></div>
|
|
101
|
+
</div>
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
scanStatus.innerHTML = `<span style="color:#f87171">Auto-connect failed: ${data.error}</span>`;
|
|
106
|
+
showScanFallback(scan);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
showScanFallback(scan);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
scanStatus.textContent = "Scan failed: " + e.message;
|
|
113
|
+
intStatus.innerHTML = `<span style="color:#888">Could not reach scan endpoint</span>`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function showScanFallback(scan) {
|
|
118
|
+
const scanStatus = document.getElementById("scanStatus");
|
|
119
|
+
const scanDetail = document.getElementById("scanDetail");
|
|
120
|
+
const scanAction = document.getElementById("scanAction");
|
|
121
|
+
const intStatus = document.getElementById("integrationStatus");
|
|
122
|
+
const intDetail = document.getElementById("integrationDetail");
|
|
123
|
+
|
|
124
|
+
let msg = "No Supabase credentials detected";
|
|
125
|
+
let color = "#f87171";
|
|
126
|
+
let icon = "ri-error-warning-line";
|
|
127
|
+
|
|
128
|
+
if (scan.detected) {
|
|
129
|
+
msg = "Partial credentials found — manual config needed";
|
|
130
|
+
color = "#f59e0b";
|
|
131
|
+
icon = "ri-alert-line";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
scanStatus.innerHTML = `<span style="color:${color}"><i class="${icon}"></i> ${msg}</span>`;
|
|
135
|
+
|
|
136
|
+
if (scanDetail) {
|
|
137
|
+
scanDetail.classList.remove("hidden");
|
|
138
|
+
const details = [];
|
|
139
|
+
if (scan.url) details.push(`<div>URL: <code style="color:#c9c">${maskKey(scan.url)}</code></div>`);
|
|
140
|
+
if (scan.anonKey) details.push(`<div>Anon key: <span style="color:#22c55e">found</span></div>`);
|
|
141
|
+
if (scan.serviceKey) details.push(`<div>Service key: <span style="color:#22c55e">found</span></div>`);
|
|
142
|
+
if (scan.projectRef) details.push(`<div>Ref: <code style="color:#c9c">${scan.projectRef}</code></div>`);
|
|
143
|
+
if (scan.sources.length) details.push(`<div>Sources: ${scan.sources.join(", ")}</div>`);
|
|
144
|
+
|
|
145
|
+
if (!details.length) details.push('<div style="color:#555">No env files contain Supabase credentials</div>');
|
|
146
|
+
|
|
147
|
+
scanDetail.innerHTML = `<div style="color:#aaa;font-size:.78rem;line-height:1.7">${details.join("")}</div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (scanAction) {
|
|
151
|
+
scanAction.classList.remove("hidden");
|
|
152
|
+
scanAction.innerHTML = `
|
|
153
|
+
<button class="btn btn--primary" id="connectBtn" style="font-size:.8rem;padding:8px 16px">
|
|
154
|
+
<i class="ri-plug-line"></i> Connect manually
|
|
155
|
+
</button>
|
|
156
|
+
`;
|
|
157
|
+
document.getElementById("connectBtn")?.addEventListener("click", () => {
|
|
158
|
+
document.querySelector('[data-section="init"]')?.click();
|
|
159
|
+
document.getElementById("initUrl")?.focus();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (intStatus) {
|
|
164
|
+
intStatus.innerHTML = `<span style="color:#888">Not connected — enter credentials in Init</span>`;
|
|
165
|
+
}
|
|
166
|
+
if (intDetail) {
|
|
167
|
+
intDetail.classList.add("hidden");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Connect (auto or manual) ──────────────────────
|
|
172
|
+
async function connectSupabase(body = {}) {
|
|
173
|
+
const data = await api("/init/connect", { method: "POST", body: JSON.stringify(body) });
|
|
174
|
+
return data;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function maskKey(key) {
|
|
178
|
+
if (key.length < 30) return key;
|
|
179
|
+
return key.slice(0, 20) + "…" + key.slice(-8);
|
|
180
|
+
}
|
|
181
|
+
|
|
58
182
|
// ── Navigation ──────────────────────────────────────
|
|
59
183
|
for (const item of navItems) {
|
|
60
184
|
item.addEventListener("click", (e) => {
|
|
@@ -76,12 +200,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
76
200
|
if (id === "roles") loadRoles();
|
|
77
201
|
if (id === "env") loadEnv();
|
|
78
202
|
if (id === "migrate") loadMigrations();
|
|
79
|
-
if (id === "
|
|
203
|
+
if (id === "integration") loadIntegration();
|
|
80
204
|
}
|
|
81
205
|
|
|
82
|
-
|
|
83
|
-
|
|
206
|
+
async function loadIntegration() {
|
|
207
|
+
try {
|
|
208
|
+
const scan = await api("/init/scan");
|
|
209
|
+
const intStatus = document.getElementById("integrationStatus");
|
|
210
|
+
const intDetail = document.getElementById("integrationDetail");
|
|
211
|
+
|
|
212
|
+
if (scan.detected && scan.canConnect) {
|
|
213
|
+
intStatus.innerHTML = `<span style="color:#22c55e"><i class="ri-check-line"></i> Connected</span>`;
|
|
214
|
+
intDetail.classList.remove("hidden");
|
|
215
|
+
intDetail.innerHTML = `
|
|
216
|
+
<div style="color:#aaa;font-size:.78rem;line-height:1.7">
|
|
217
|
+
<div>URL: <code style="color:#c9c">${maskKey(scan.url || "")}</code></div>
|
|
218
|
+
<div>Project ref: <code style="color:#c9c">${scan.projectRef || "—"}</code></div>
|
|
219
|
+
<div>Sources: ${scan.sources.join(", ")}</div>
|
|
220
|
+
</div>
|
|
221
|
+
`;
|
|
222
|
+
} else {
|
|
223
|
+
intStatus.innerHTML = `<span style="color:#f87171"><i class="ri-close-line"></i> No connection</span>`;
|
|
224
|
+
intDetail.classList.remove("hidden");
|
|
225
|
+
intDetail.innerHTML = `<div style="color:#555">No Supabase credentials detected. Go to Init to configure.</div>`;
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Scan on load ──────────────────────────────────
|
|
231
|
+
autoDetectSupabase();
|
|
232
|
+
|
|
233
|
+
// Reconnect button
|
|
234
|
+
const reconnectBtn = document.getElementById("reconnectBtn");
|
|
235
|
+
if (reconnectBtn) {
|
|
236
|
+
reconnectBtn.addEventListener("click", async () => {
|
|
237
|
+
reconnectBtn.disabled = true;
|
|
238
|
+
reconnectBtn.textContent = "Scanning…";
|
|
239
|
+
await autoDetectSupabase();
|
|
240
|
+
reconnectBtn.disabled = false;
|
|
241
|
+
reconnectBtn.innerHTML = '<i class="ri-refresh-line"></i> Re-scan & reconnect';
|
|
242
|
+
});
|
|
243
|
+
}
|
|
84
244
|
|
|
245
|
+
// ── Manual connect form ──────────────────────────
|
|
85
246
|
const initForm = document.getElementById("initForm");
|
|
86
247
|
if (initForm) {
|
|
87
248
|
initForm.addEventListener("submit", async (e) => {
|
|
@@ -105,29 +266,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
105
266
|
btn.innerHTML = '<i class="ri-plug-line"></i> Connect & Configure';
|
|
106
267
|
|
|
107
268
|
if (data.success) {
|
|
108
|
-
showResult(result,
|
|
269
|
+
showResult(result, "Connected to Supabase. Framework: " + (data.framework || "unknown"), "ok");
|
|
109
270
|
showToast("Project connected successfully");
|
|
110
|
-
checkInitFiles();
|
|
111
271
|
checkHealth();
|
|
272
|
+
autoDetectSupabase();
|
|
112
273
|
} else {
|
|
113
274
|
showResult(result, data.error, "error");
|
|
114
275
|
}
|
|
115
276
|
});
|
|
116
277
|
}
|
|
117
278
|
|
|
118
|
-
async function checkInitFiles() {
|
|
119
|
-
try {
|
|
120
|
-
const res = await fetch("/api/config");
|
|
121
|
-
if (res.ok) {
|
|
122
|
-
const envCheck = await fetch("/api/health");
|
|
123
|
-
document.getElementById("envStatus").textContent = "checked";
|
|
124
|
-
document.getElementById("envStatus").className = "file-status file-status--ok";
|
|
125
|
-
document.getElementById("configStatus").textContent = "checked";
|
|
126
|
-
document.getElementById("configStatus").className = "file-status file-status--ok";
|
|
127
|
-
}
|
|
128
|
-
} catch {}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
279
|
// ── Users ───────────────────────────────────────────
|
|
132
280
|
window.loadUsers = async function() {
|
|
133
281
|
const body = document.getElementById("usersBody");
|
|
@@ -135,15 +283,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
135
283
|
try {
|
|
136
284
|
const data = await api("/users");
|
|
137
285
|
if (!data.success || !data.users?.length) {
|
|
138
|
-
body.innerHTML = '<tr><td colspan="4" class="text-muted">No users found. Connect Supabase
|
|
286
|
+
body.innerHTML = '<tr><td colspan="4" class="text-muted">No users found. Connect Supabase first.</td></tr>';
|
|
139
287
|
return;
|
|
140
288
|
}
|
|
141
289
|
body.innerHTML = data.users.map(u =>
|
|
142
290
|
`<tr>
|
|
143
|
-
<td style="font-family:var(--mono);font-size:.8rem">${
|
|
291
|
+
<td style="font-family:var(--mono);font-size:.8rem">${u.id.slice(0, 8)}…</td>
|
|
144
292
|
<td>${u.email ?? "—"}</td>
|
|
145
293
|
<td>${u.raw_user_meta_data?.role ?? "user"}</td>
|
|
146
|
-
<td class="text-muted">${
|
|
294
|
+
<td class="text-muted">${new Date(u.created_at).toLocaleDateString()}</td>
|
|
147
295
|
</tr>`
|
|
148
296
|
).join("");
|
|
149
297
|
} catch {
|
|
@@ -160,16 +308,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
160
308
|
const data = await api("/providers");
|
|
161
309
|
if (!data.providers) return;
|
|
162
310
|
|
|
163
|
-
container.innerHTML = data.providers.map(p =>
|
|
164
|
-
|
|
165
|
-
|
|
311
|
+
container.innerHTML = data.providers.map(p => {
|
|
312
|
+
const name = p.name === "magiclink" ? "Magic Link" : p.name;
|
|
313
|
+
const iconUrl = `https://cdn.simpleicons.org/${p.name === "magiclink" ? "resend" : p.name}/fff`;
|
|
314
|
+
return `<div class="provider-card">
|
|
315
|
+
<img src="${iconUrl}" width="20" height="20" alt="${name}" class="provider-icon-img" />
|
|
166
316
|
<div class="provider-info">
|
|
167
|
-
<div class="provider-name">${
|
|
168
|
-
<div class="provider-scopes"
|
|
317
|
+
<div class="provider-name">${name}</div>
|
|
318
|
+
<div class="provider-scopes">${p.scopes || ""}</div>
|
|
169
319
|
</div>
|
|
170
|
-
<span class="provider-status ${p.enabled ? "enabled" : "disabled"}">${p.enabled ? "
|
|
171
|
-
</div
|
|
172
|
-
).join("");
|
|
320
|
+
<span class="provider-status ${p.enabled ? "enabled" : "disabled"}">${p.enabled ? "Active" : "Off"}</span>
|
|
321
|
+
</div>`;
|
|
322
|
+
}).join("");
|
|
173
323
|
};
|
|
174
324
|
|
|
175
325
|
// ── Roles ───────────────────────────────────────────
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Authly — Authorize</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css">
|
|
9
|
+
<style>
|
|
10
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
11
|
+
.auth-container { width: 100%; max-width: 420px; padding: 20px; }
|
|
12
|
+
.auth-card {
|
|
13
|
+
background: #0a0a0a; border: 1px solid #1a1a1a; border-radius: 12px;
|
|
14
|
+
padding: 32px 24px; text-align: center;
|
|
15
|
+
}
|
|
16
|
+
.auth-card h1 { font-size: 1.3rem; color: #fff; margin: 0 0 4px; }
|
|
17
|
+
.auth-card p { color: #888; font-size: .85rem; margin: 0 0 24px; }
|
|
18
|
+
.provider-btn {
|
|
19
|
+
display: flex; align-items: center; gap: 12px; width: 100%;
|
|
20
|
+
padding: 12px 16px; margin-bottom: 8px; border: 1px solid #222;
|
|
21
|
+
border-radius: 8px; background: #111; color: #fff;
|
|
22
|
+
font-size: .9rem; cursor: pointer; transition: border-color .2s;
|
|
23
|
+
}
|
|
24
|
+
.provider-btn:hover { border-color: #444; }
|
|
25
|
+
.provider-btn.disabled { opacity: .4; pointer-events: none; }
|
|
26
|
+
.provider-btn img { width: 20px; height: 20px; flex-shrink: 0; }
|
|
27
|
+
.provider-btn .label { flex: 1; text-align: left; text-transform: capitalize; }
|
|
28
|
+
.provider-btn .status { font-size: .7rem; color: #555; text-transform: uppercase; }
|
|
29
|
+
.back-link {
|
|
30
|
+
display: inline-block; margin-top: 16px; color: #555;
|
|
31
|
+
font-size: .8rem; text-decoration: none;
|
|
32
|
+
}
|
|
33
|
+
.back-link:hover { color: #aaa; }
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="auth-container">
|
|
38
|
+
<div class="auth-card">
|
|
39
|
+
<h1><i class="ri-shield-keyhole-line"></i> Sign in</h1>
|
|
40
|
+
<p>Choose an authentication method</p>
|
|
41
|
+
<div id="providerList"></div>
|
|
42
|
+
</div>
|
|
43
|
+
<a href="/" class="back-link"><i class="ri-arrow-left-line"></i> Back to dashboard</a>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<script>
|
|
47
|
+
async function loadProviders() {
|
|
48
|
+
const container = document.getElementById("providerList");
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch("/api/providers");
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
if (!data.providers) { container.innerHTML = "<p style='color:#555'>—</p>"; return; }
|
|
53
|
+
|
|
54
|
+
container.innerHTML = data.providers.map(p => {
|
|
55
|
+
const name = p.name === "magiclink" ? "Magic Link" : p.name;
|
|
56
|
+
const iconUrl = `https://cdn.simpleicons.org/${p.name === "magiclink" ? "resend" : p.name}/fff`;
|
|
57
|
+
const cls = p.enabled ? "" : "disabled";
|
|
58
|
+
return `<div class="provider-btn ${cls}" data-provider="${p.name}" data-enabled="${p.enabled}">
|
|
59
|
+
<img src="${iconUrl}" alt="${name}" />
|
|
60
|
+
<span class="label">${name}</span>
|
|
61
|
+
<span class="status">${p.enabled ? "enabled" : "not configured"}</span>
|
|
62
|
+
</div>`;
|
|
63
|
+
}).join("");
|
|
64
|
+
|
|
65
|
+
// Attach click handlers
|
|
66
|
+
container.querySelectorAll(".provider-btn[data-enabled='true']").forEach(el => {
|
|
67
|
+
el.addEventListener("click", () => {
|
|
68
|
+
const provider = el.dataset.provider;
|
|
69
|
+
window.location.href = `/api/auth/${provider}/authorize`;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
container.innerHTML = "<p style='color:#555'>Failed to load providers</p>";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
loadProviders();
|
|
77
|
+
</script>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
@@ -4,8 +4,79 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Authly Dashboard</title>
|
|
7
|
-
<link rel="stylesheet" href="/styles.css">
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" integrity="sha384-PLACEHOLDER">
|
|
8
8
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css">
|
|
9
|
+
<style>
|
|
10
|
+
/* ── Provider SimpleIcons ───────────────────────── */
|
|
11
|
+
.provider-icon-img {
|
|
12
|
+
width: 20px; height: 20px; flex-shrink: 0;
|
|
13
|
+
}
|
|
14
|
+
.provider-card {
|
|
15
|
+
display: flex; align-items: center; gap: 12px;
|
|
16
|
+
padding: 12px 16px; background: #111; border: 1px solid #222;
|
|
17
|
+
border-radius: 8px; margin-bottom: 8px;
|
|
18
|
+
}
|
|
19
|
+
.provider-card .provider-info { flex: 1; }
|
|
20
|
+
.provider-card .provider-name { font-weight: 500; font-size: .875rem; color: #fff; text-transform: capitalize; }
|
|
21
|
+
.provider-card .provider-scopes { font-size: .75rem; color: #666; margin-top: 2px; }
|
|
22
|
+
.provider-card .provider-status {
|
|
23
|
+
font-size: .7rem; padding: 2px 8px; border-radius: 4px;
|
|
24
|
+
text-transform: uppercase; font-weight: 600; letter-spacing: .5px;
|
|
25
|
+
}
|
|
26
|
+
.provider-card .provider-status.enabled { background: #166534; color: #22c55e; }
|
|
27
|
+
.provider-card .provider-status.disabled { background: #222; color: #555; }
|
|
28
|
+
|
|
29
|
+
/* ── Inline docs ────────────────────────────────── */
|
|
30
|
+
.doc-panel {
|
|
31
|
+
background: #0a0a0a; border: 1px solid #1a1a1a; border-radius: 8px;
|
|
32
|
+
padding: 16px 20px; margin-bottom: 16px;
|
|
33
|
+
}
|
|
34
|
+
.doc-panel h4 {
|
|
35
|
+
font-size: .85rem; color: #fff; margin: 0 0 6px;
|
|
36
|
+
display: flex; align-items: center; gap: 6px;
|
|
37
|
+
}
|
|
38
|
+
.doc-panel p { color: #888; font-size: .8rem; margin: 0 0 8px; line-height: 1.5; }
|
|
39
|
+
.doc-panel ul {
|
|
40
|
+
margin: 0; padding-left: 18px; color: #aaa; font-size: .78rem; line-height: 1.8;
|
|
41
|
+
}
|
|
42
|
+
.doc-panel code {
|
|
43
|
+
background: #111; color: #c9c; padding: 1px 5px; border-radius: 3px;
|
|
44
|
+
font-size: .75rem; font-family: 'SF Mono', 'Fira Code', monospace;
|
|
45
|
+
}
|
|
46
|
+
.doc-panel .toggle-docs {
|
|
47
|
+
background: none; border: none; color: #555; cursor: pointer;
|
|
48
|
+
font-size: .75rem; margin-left: auto;
|
|
49
|
+
}
|
|
50
|
+
.doc-panel .toggle-docs:hover { color: #aaa; }
|
|
51
|
+
.doc-panel-body { transition: max-height .3s ease; overflow: hidden; }
|
|
52
|
+
.doc-panel-body.collapsed { max-height: 0; }
|
|
53
|
+
.doc-panel-body.expanded { max-height: 600px; }
|
|
54
|
+
|
|
55
|
+
/* ── UI improvements ───────────────────────────── */
|
|
56
|
+
.card h2 { margin-bottom: 4px; }
|
|
57
|
+
.card p { color: #666; font-size: .82rem; }
|
|
58
|
+
.card__icon {
|
|
59
|
+
width: 32px; height: 32px; display: flex; align-items: center;
|
|
60
|
+
justify-content: center; border-radius: 8px; margin-bottom: 12px;
|
|
61
|
+
background: #111; color: #fff; font-size: 1.2rem;
|
|
62
|
+
}
|
|
63
|
+
.text-muted { color: #555; font-size: .8rem; }
|
|
64
|
+
|
|
65
|
+
/* ── Toast ──────────────────────────────────────── */
|
|
66
|
+
.toast {
|
|
67
|
+
position: fixed; bottom: 20px; right: 20px; padding: 10px 16px;
|
|
68
|
+
border-radius: 6px; font-size: .82rem; z-index: 999;
|
|
69
|
+
animation: slideUp .2s ease;
|
|
70
|
+
}
|
|
71
|
+
.toast.toast--ok { background: #166534; color: #22c55e; border: 1px solid #22c55e44; }
|
|
72
|
+
.toast.toast--error { background: #7f1d1d; color: #f87171; border: 1px solid #f8717144; }
|
|
73
|
+
.toast.toast--info { background: #111; color: #888; border: 1px solid #333; }
|
|
74
|
+
.toast.hidden { display: none; }
|
|
75
|
+
@keyframes slideUp {
|
|
76
|
+
from { transform: translateY(20px); opacity: 0; }
|
|
77
|
+
to { transform: translateY(0); opacity: 1; }
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
9
80
|
</head>
|
|
10
81
|
<body>
|
|
11
82
|
<div class="layout">
|
|
@@ -21,17 +92,22 @@
|
|
|
21
92
|
</div>
|
|
22
93
|
<nav class="sidebar__nav">
|
|
23
94
|
<a href="#init" class="nav-item active" data-section="init"><i class="ri-flashlight-line"></i> Init</a>
|
|
95
|
+
<a href="#integration" class="nav-item" data-section="integration"><i class="ri-database-2-line"></i> Integration</a>
|
|
24
96
|
<a href="#providers" class="nav-item" data-section="providers"><i class="ri-shield-keyhole-line"></i> Providers</a>
|
|
25
97
|
<a href="#ui" class="nav-item" data-section="ui"><i class="ri-layout-masonry-line"></i> UI</a>
|
|
26
98
|
<a href="#routes" class="nav-item" data-section="routes"><i class="ri-route-line"></i> Routes</a>
|
|
27
99
|
<a href="#roles" class="nav-item" data-section="roles"><i class="ri-user-settings-line"></i> Roles</a>
|
|
28
100
|
<a href="#api-keys" class="nav-item" data-section="api-keys"><i class="ri-key-2-line"></i> API Keys</a>
|
|
29
101
|
<a href="#env" class="nav-item" data-section="env"><i class="ri-file-code-line"></i> Env</a>
|
|
30
|
-
<a href="#migrate" class="nav-item" data-section="migrate"><i class="ri-
|
|
102
|
+
<a href="#migrate" class="nav-item" data-section="migrate"><i class="ri-download-2-line"></i> Migrate</a>
|
|
31
103
|
<a href="#users" class="nav-item" data-section="users"><i class="ri-user-line"></i> Users</a>
|
|
32
104
|
<a href="#mcp" class="nav-item" data-section="mcp"><i class="ri-robot-line"></i> MCP <span class="badge">BETA</span></a>
|
|
33
105
|
<a href="#audit" class="nav-item" data-section="audit"><i class="ri-search-line"></i> Audit</a>
|
|
34
106
|
</nav>
|
|
107
|
+
<div class="sidebar__footer">
|
|
108
|
+
<span class="sidebar__version">v0.1.0</span>
|
|
109
|
+
<span class="sidebar__copy">MIT — rblez</span>
|
|
110
|
+
</div>
|
|
35
111
|
</aside>
|
|
36
112
|
|
|
37
113
|
<!-- Main -->
|
|
@@ -49,16 +125,37 @@
|
|
|
49
125
|
|
|
50
126
|
<!-- ═══ INIT ═══ -->
|
|
51
127
|
<section class="section" id="init">
|
|
128
|
+
<div class="doc-panel" id="initDocs">
|
|
129
|
+
<h4><i class="ri-book-line"></i> Auto-detection <button class="toggle-docs" data-target="initDocsBody">toggle</button></h4>
|
|
130
|
+
<div class="doc-panel-body expanded" id="initDocsBody">
|
|
131
|
+
<p>Authly scans your project files for Supabase credentials — no manual configuration needed.</p>
|
|
132
|
+
<ul>
|
|
133
|
+
<li>Checks <code>.env.local</code>, <code>.env.development.local</code>, <code>.env.development</code>, <code>.env</code></li>
|
|
134
|
+
<li>Also reads <code>supabase/config.toml</code> if present</li>
|
|
135
|
+
<li>If found — connects automatically</li>
|
|
136
|
+
<li>If not found — fill in manually below</li>
|
|
137
|
+
</ul>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
<!-- Auto-detected status -->
|
|
141
|
+
<div class="card" id="autoDetectCard">
|
|
142
|
+
<div class="card__icon"><i class="ri-search-eye-line"></i></div>
|
|
143
|
+
<h2>Project scan</h2>
|
|
144
|
+
<div id="scanStatus" style="font-size:.85rem;color:#888">Scanning…</div>
|
|
145
|
+
<div id="scanDetail" class="hidden" style="margin-top:12px;font-size:.8rem"></div>
|
|
146
|
+
<div id="scanAction" class="hidden" style="margin-top:12px"></div>
|
|
147
|
+
</div>
|
|
148
|
+
<!-- Manual fallback -->
|
|
52
149
|
<div class="card">
|
|
53
150
|
<div class="card__icon"><i class="ri-flashlight-line"></i></div>
|
|
54
|
-
<h2>
|
|
55
|
-
<p>
|
|
151
|
+
<h2>Manual connect</h2>
|
|
152
|
+
<p style="margin-bottom:12px">Or enter credentials manually if auto-detect didn't find them.</p>
|
|
56
153
|
<form id="initForm" class="init-form">
|
|
57
154
|
<label>Supabase Project URL</label>
|
|
58
155
|
<input type="url" id="initUrl" placeholder="https://xxxx.supabase.co" />
|
|
59
156
|
<label>Supabase Anon Key</label>
|
|
60
157
|
<input type="password" id="initAnon" placeholder="eyJh…" />
|
|
61
|
-
<label>Service Role Key (
|
|
158
|
+
<label>Service Role Key (admin)</label>
|
|
62
159
|
<input type="password" id="initService" placeholder="eyJh…" />
|
|
63
160
|
<div id="initResult" class="result hidden"></div>
|
|
64
161
|
<button type="submit" class="btn btn--primary" id="initBtn">
|
|
@@ -66,26 +163,68 @@
|
|
|
66
163
|
</button>
|
|
67
164
|
</form>
|
|
68
165
|
</div>
|
|
166
|
+
</section>
|
|
167
|
+
|
|
168
|
+
<!-- ═══ INTEGRATION ═══ -->
|
|
169
|
+
<section class="section hidden" id="integration">
|
|
170
|
+
<div class="doc-panel" id="intDocs">
|
|
171
|
+
<h4><i class="ri-book-line"></i> Supabase integration <button class="toggle-docs" data-target="intDocsBody">toggle</button></h4>
|
|
172
|
+
<div class="doc-panel-body expanded" id="intDocsBody">
|
|
173
|
+
<p>Authly connects directly to your Supabase database to manage users, roles, sessions, and migrations.</p>
|
|
174
|
+
<ul>
|
|
175
|
+
<li>Users are stored in <code>authly_users</code> table (not Supabase auth.users)</li>
|
|
176
|
+
<li>OAuth providers link via <code>authly_oauth_accounts</code></li>
|
|
177
|
+
<li>Role assignments via <code>authly_user_roles</code></li>
|
|
178
|
+
<li>Magic link tokens in <code>authly_magic_links</code></li>
|
|
179
|
+
</ul>
|
|
180
|
+
<p style="margin-top:8px">Connection is automatic if Supabase credentials exist in your env files.</p>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
69
183
|
<div class="card">
|
|
70
|
-
<div class="card__icon"><i class="ri-
|
|
71
|
-
<h2>
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
184
|
+
<div class="card__icon"><i class="ri-database-2-line"></i></div>
|
|
185
|
+
<h2>Supabase connection</h2>
|
|
186
|
+
<div id="integrationStatus" style="font-size:.85rem;color:#888">Checking…</div>
|
|
187
|
+
<div id="integrationDetail" class="hidden" style="margin-top:12px"></div>
|
|
188
|
+
<div style="margin-top:16px">
|
|
189
|
+
<button class="btn btn--primary btn--sm" id="reconnectBtn"><i class="ri-refresh-line"></i> Re-scan & reconnect</button>
|
|
190
|
+
</div>
|
|
76
191
|
</div>
|
|
77
192
|
</section>
|
|
78
193
|
|
|
79
194
|
<!-- ═══ PROVIDERS ═══ -->
|
|
80
195
|
<section class="section hidden" id="providers">
|
|
196
|
+
<div class="doc-panel" id="provDocs">
|
|
197
|
+
<h4><i class="ri-book-line"></i> Provider setup <button class="toggle-docs" data-target="provDocsBody">toggle</button></h4>
|
|
198
|
+
<div class="doc-panel-body expanded" id="provDocsBody">
|
|
199
|
+
<p>Enable authentication methods for your project. Each provider requires OAuth client credentials.</p>
|
|
200
|
+
<ul>
|
|
201
|
+
<li><img src="https://cdn.simpleicons.org/google/fff" width="14" height="14" alt="Google"> <strong>Google</strong> — Console → APIs → OAuth 2.0 → Credentials</li>
|
|
202
|
+
<li><img src="https://cdn.simpleicons.org/github/fff" width="14" height="14" alt="GitHub"> <strong>GitHub</strong> — Settings → Developer settings → OAuth Apps</li>
|
|
203
|
+
<li><img src="https://cdn.simpleicons.org/discord/fff" width="14" height="14" alt="Discord"> <strong>Discord</strong> — Developer Portal → Applications</li>
|
|
204
|
+
<li><img src="https://cdn.simpleicons.org/resend/fff" width="14" height="14" alt="Resend"> <strong>Magic Link</strong> — Resend dashboard (API key)</li>
|
|
205
|
+
</ul>
|
|
206
|
+
<p style="margin-top:8px">Set credentials in <code>GOOGLE_CLIENT_ID</code> / <code>GOOGLE_CLIENT_SECRET</code> etc. Buttons appear automatically when providers are enabled.</p>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
81
209
|
<div id="providerList"></div>
|
|
82
210
|
</section>
|
|
83
211
|
|
|
84
212
|
<!-- ═══ UI (Scaffold) ═══ -->
|
|
85
213
|
<section class="section hidden" id="ui">
|
|
214
|
+
<div class="doc-panel" id="scaffoldDocs">
|
|
215
|
+
<h4><i class="ri-book-line"></i> UI scaffolding <button class="toggle-docs" data-target="scaffoldDocsBody">toggle</button></h4>
|
|
216
|
+
<div class="doc-panel-body expanded" id="scaffoldDocsBody">
|
|
217
|
+
<p>Generates ready-to-use TSX pages for your Next.js App Router project. Click a scaffold card to preview the code.</p>
|
|
218
|
+
<ul>
|
|
219
|
+
<li><strong>Login</strong> — Email/password + dynamic SimpleIcon buttons for enabled providers</li>
|
|
220
|
+
<li><strong>Sign Up</strong> — Registration form with validation</li>
|
|
221
|
+
<li><strong>Middleware</strong> — Route protection for protected and auth paths</li>
|
|
222
|
+
</ul>
|
|
223
|
+
<p style="margin-top:8px">Files are generated into <code>src/app/auth/</code> in your Next.js project.</p>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
86
226
|
<div class="card">
|
|
87
227
|
<h2>Scaffold auth pages</h2>
|
|
88
|
-
<p>Generate ready-to-use TSX pages for your Next.js project.</p>
|
|
89
228
|
<div class="grid-3">
|
|
90
229
|
<div class="scaffold-card" data-scaffold="login">
|
|
91
230
|
<i class="ri-login-circle-line"></i>
|
|
@@ -109,15 +248,36 @@
|
|
|
109
248
|
|
|
110
249
|
<!-- ═══ ROUTES ═══ -->
|
|
111
250
|
<section class="section hidden" id="routes">
|
|
251
|
+
<div class="doc-panel" id="routesDocs">
|
|
252
|
+
<h4><i class="ri-book-line"></i> Route protection <button class="toggle-docs" data-target="routesDocsBody">toggle</button></h4>
|
|
253
|
+
<div class="doc-panel-body expanded" id="routesDocsBody">
|
|
254
|
+
<p>The scaffolded middleware intercepts requests and checks for a valid <code>authly_session</code> cookie. Missing cookies redirect to login.</p>
|
|
255
|
+
<ul>
|
|
256
|
+
<li><code>/dashboard</code>, <code>/profile</code>, <code>/settings</code> → unprotected redirects to <code>/auth/login</code></li>
|
|
257
|
+
<li><code>/auth/login</code>, <code>/auth/signup</code> → redirect to <code>/dashboard</code> if logged in</li>
|
|
258
|
+
</ul>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
112
261
|
<div class="card">
|
|
113
262
|
<h2>Protected routes</h2>
|
|
114
|
-
<p>Middleware scaffold protects these paths:</p>
|
|
115
263
|
<div class="code-block"><code>/dashboard, /profile, /settings → redirect to /auth/login if unauthenticated</code></div>
|
|
116
264
|
</div>
|
|
117
265
|
</section>
|
|
118
266
|
|
|
119
267
|
<!-- ═══ ROLES ═══ -->
|
|
120
268
|
<section class="section hidden" id="roles">
|
|
269
|
+
<div class="doc-panel" id="rolesDocs">
|
|
270
|
+
<h4><i class="ri-book-line"></i> Role-based access control <button class="toggle-docs" data-target="rolesDocsBody">toggle</button></h4>
|
|
271
|
+
<div class="doc-panel-body expanded" id="rolesDocsBody">
|
|
272
|
+
<p>Authly implements RBAC with three default roles:</p>
|
|
273
|
+
<ul>
|
|
274
|
+
<li><code>admin</code> — Full access to all resources</li>
|
|
275
|
+
<li><code>user</code> — Standard authenticated (auto-assigned on signup)</li>
|
|
276
|
+
<li><code>guest</code> — Limited access, read-only</li>
|
|
277
|
+
</ul>
|
|
278
|
+
<p style="margin-top:8px">Requires the <code>001_create_roles_table</code> and <code>004_create_user_roles_table</code> migrations.</p>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
121
281
|
<div class="card">
|
|
122
282
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
123
283
|
<h2>Roles</h2>
|
|
@@ -146,7 +306,7 @@
|
|
|
146
306
|
<section class="section hidden" id="api-keys">
|
|
147
307
|
<div class="card">
|
|
148
308
|
<h2>Generate API Key</h2>
|
|
149
|
-
<p>Create a key for programmatic access
|
|
309
|
+
<p>Create a key for programmatic access. The raw key is shown <strong>only once</strong>.</p>
|
|
150
310
|
<form id="keyForm" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap">
|
|
151
311
|
<div>
|
|
152
312
|
<label style="font-size:.75rem;color:#888;display:block;margin-bottom:2px">Key name</label>
|
|
@@ -162,12 +322,29 @@
|
|
|
162
322
|
<section class="section hidden" id="env">
|
|
163
323
|
<div class="card">
|
|
164
324
|
<h2>Environment variables</h2>
|
|
325
|
+
<p>Check which variables are set in the current environment.</p>
|
|
165
326
|
<div class="vars-list" id="envVars"></div>
|
|
166
327
|
</div>
|
|
167
328
|
</section>
|
|
168
329
|
|
|
169
330
|
<!-- ═══ MIGRATE ═══ -->
|
|
170
331
|
<section class="section hidden" id="migrate">
|
|
332
|
+
<div class="doc-panel" id="migrateDocs">
|
|
333
|
+
<h4><i class="ri-book-line"></i> Database migrations <button class="toggle-docs" data-target="migrateDocsBody">toggle</button></h4>
|
|
334
|
+
<div class="doc-panel-body expanded" id="migrateDocsBody">
|
|
335
|
+
<p>Authly manages its own schema via incremental SQL migrations. Click <strong>Run</strong> to execute a migration directly against your Supabase database.</p>
|
|
336
|
+
<ul>
|
|
337
|
+
<li><code>001</code> — Roles table (admin, user, guest)</li>
|
|
338
|
+
<li><code>002</code> — Users table (authly_users)</li>
|
|
339
|
+
<li><code>003</code> — OAuth accounts (provider linkages)</li>
|
|
340
|
+
<li><code>004</code> — User roles + auto-assign trigger</li>
|
|
341
|
+
<li><code>005</code> — API keys (hashed)</li>
|
|
342
|
+
<li><code>006</code> — Sessions</li>
|
|
343
|
+
<li><code>007</code> — Magic links (Resend)</li>
|
|
344
|
+
</ul>
|
|
345
|
+
<p style="margin-top:8px">You can run migrations in any order — each uses <code>CREATE TABLE IF NOT EXISTS</code> so they're idempotent.</p>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
171
348
|
<div class="card">
|
|
172
349
|
<h2>Migrations</h2>
|
|
173
350
|
<div class="migration-list" id="migrationList"></div>
|
|
@@ -211,7 +388,6 @@
|
|
|
211
388
|
</div>
|
|
212
389
|
<p style="font-size:.8rem;color:#666;margin-top:12px">
|
|
213
390
|
<i class="ri-error-warning-line"></i> MCP tools are experimental. All backend tools are wired directly via the REST API for now.
|
|
214
|
-
MCP will be fully trained after all tools are tested and stable.
|
|
215
391
|
</p>
|
|
216
392
|
</div>
|
|
217
393
|
</section>
|
|
@@ -124,6 +124,16 @@ code {
|
|
|
124
124
|
text-align: center;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
.sidebar__footer {
|
|
128
|
+
margin-top: auto;
|
|
129
|
+
padding: 10px 16px;
|
|
130
|
+
border-top: 1px solid var(--border);
|
|
131
|
+
display: flex;
|
|
132
|
+
justify-content: space-between;
|
|
133
|
+
font-size: 0.7rem;
|
|
134
|
+
color: #444;
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
/* ── Main ─────────────────────────────────────────────── */
|
|
128
138
|
.main {
|
|
129
139
|
flex: 1;
|
package/package.json
CHANGED
package/src/auth/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import bcrypt from "bcryptjs";
|
|
8
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
8
9
|
import { getSupabaseClient } from "../lib/supabase.js";
|
|
9
10
|
import { createSessionToken, verifySessionToken } from "../lib/jwt.js";
|
|
10
11
|
import { buildAuthorizeUrl, exchangeTokens, upsertUser, authWithProvider, listProviderStatus } from "../lib/oauth.js";
|
|
@@ -132,3 +133,100 @@ export async function handleOAuthCallback({ provider, code, redirectUri }) {
|
|
|
132
133
|
return { user: null, token: null, error: e.message };
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
|
|
137
|
+
// ── Magic Link (Resend) ──────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Send a magic link to the user's email via Resend.
|
|
141
|
+
* Creates or finds the user, generates a verification token.
|
|
142
|
+
*/
|
|
143
|
+
export async function sendMagicLink({ email, callbackUrl }) {
|
|
144
|
+
const { client, errors } = getSupabaseClient();
|
|
145
|
+
if (!client) return { sent: false, error: errors.join(", ") };
|
|
146
|
+
|
|
147
|
+
const resendKey = process.env.RESEND_API_KEY;
|
|
148
|
+
if (!resendKey) return { sent: false, error: "RESEND_API_KEY not configured" };
|
|
149
|
+
|
|
150
|
+
if (!email) return { sent: false, error: "Email is required" };
|
|
151
|
+
|
|
152
|
+
const emailLower = email.toLowerCase();
|
|
153
|
+
const token = randomBytes(32).toString("hex");
|
|
154
|
+
const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 min
|
|
155
|
+
|
|
156
|
+
// Check if user exists, create if not (without password)
|
|
157
|
+
const { data: existing } = await client
|
|
158
|
+
.from("authly_users")
|
|
159
|
+
.select("id")
|
|
160
|
+
.eq("email", emailLower)
|
|
161
|
+
.single();
|
|
162
|
+
|
|
163
|
+
let userId = existing?.id;
|
|
164
|
+
if (!existing) {
|
|
165
|
+
const { data: newUser } = await client
|
|
166
|
+
.from("authly_users")
|
|
167
|
+
.insert({ email: emailLower, password_hash: null, name: emailLower.split("@")[0] })
|
|
168
|
+
.select("id")
|
|
169
|
+
.single();
|
|
170
|
+
if (newUser) userId = newUser.id;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!userId) return { sent: false, error: "Failed to create user record" };
|
|
174
|
+
|
|
175
|
+
// Store magic link token in DB
|
|
176
|
+
const { error: tokenError } = await client
|
|
177
|
+
.from("authly_magic_links")
|
|
178
|
+
.insert({ user_id: userId, token_hash: createHash("sha256").update(token).digest("hex"), expires_at: expiresAt.toISOString(), used: false });
|
|
179
|
+
|
|
180
|
+
if (tokenError) return { sent: false, error: tokenError.message };
|
|
181
|
+
|
|
182
|
+
// Send email via Resend
|
|
183
|
+
const from = process.env.RESEND_FROM || "noreply@authly.dev";
|
|
184
|
+
const link = `${callbackUrl}?token=${token}`;
|
|
185
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Authorization": `Bearer ${resendKey}`, "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
from,
|
|
190
|
+
to: [emailLower],
|
|
191
|
+
subject: "Your Authly Magic Link",
|
|
192
|
+
html: `<p>Click the link to sign in:</p><p><a href="${link}">Sign In</a></p><p>This link expires in 30 minutes.</p>`,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const err = await res.json().catch(() => ({}));
|
|
198
|
+
return { sent: false, error: err.message || "Failed to send email" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { sent: true, error: null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Verify a magic link token and create a session.
|
|
206
|
+
*/
|
|
207
|
+
export async function verifyMagicLink({ token }) {
|
|
208
|
+
const { client, errors } = getSupabaseClient();
|
|
209
|
+
if (!client) return { user: null, error: errors.join(", ") };
|
|
210
|
+
|
|
211
|
+
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
212
|
+
|
|
213
|
+
const { data: record, error } = await client
|
|
214
|
+
.from("authly_magic_links")
|
|
215
|
+
.select("user_id, expires_at, used")
|
|
216
|
+
.eq("token_hash", tokenHash)
|
|
217
|
+
.single();
|
|
218
|
+
|
|
219
|
+
if (error || !record) return { user: null, error: "Invalid token" };
|
|
220
|
+
if (record.used) return { user: null, error: "Token already used" };
|
|
221
|
+
if (new Date(record.expires_at) < new Date()) return { user: null, error: "Token expired" };
|
|
222
|
+
|
|
223
|
+
// Mark as used
|
|
224
|
+
await client.from("authly_magic_links").update({ used: true }).eq("token_hash", tokenHash);
|
|
225
|
+
|
|
226
|
+
// Get user
|
|
227
|
+
const { data: user } = await client.from("authly_users").select("id, email, name").eq("id", record.user_id).single();
|
|
228
|
+
if (!user) return { user: null, error: "User not found" };
|
|
229
|
+
|
|
230
|
+
const sessionToken = await createSessionToken({ sub: user.id, role: "user" });
|
|
231
|
+
return { user, token: sessionToken, error: null };
|
|
232
|
+
}
|
package/src/commands/serve.js
CHANGED
|
@@ -8,7 +8,7 @@ import chalk from "chalk";
|
|
|
8
8
|
import ora from "ora";
|
|
9
9
|
import { getSupabaseClient, fetchUsers } from "../lib/supabase.js";
|
|
10
10
|
import { createSessionToken, verifySessionToken, authMiddleware, requireRole } from "../lib/jwt.js";
|
|
11
|
-
import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback } from "../auth/index.js";
|
|
11
|
+
import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback, sendMagicLink, verifyMagicLink } from "../auth/index.js";
|
|
12
12
|
import { buildAuthorizeUrl, exchangeTokens, listProviderStatus } from "../lib/oauth.js";
|
|
13
13
|
import {
|
|
14
14
|
createRole,
|
|
@@ -22,6 +22,7 @@ import { detectFramework } from "../lib/framework.js";
|
|
|
22
22
|
import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
|
|
23
23
|
import { generateEnv } from "../generators/env.js";
|
|
24
24
|
import { mountMcp } from "../mcp/server.js";
|
|
25
|
+
import { scanSupabase } from "../integrations/supabase.js";
|
|
25
26
|
|
|
26
27
|
const PORT = process.env.AUTHLY_PORT || 1284;
|
|
27
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -49,6 +50,13 @@ export async function cmdServe() {
|
|
|
49
50
|
// ── Public API ─────────────────────────────────────
|
|
50
51
|
app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
|
|
51
52
|
|
|
53
|
+
/** GET /api/integrations/supabase/scan — scan local project */
|
|
54
|
+
app.get("/api/integrations/supabase/scan", (c) => {
|
|
55
|
+
const projectRoot = path.resolve(process.cwd());
|
|
56
|
+
const scan = scanSupabase(projectRoot);
|
|
57
|
+
return c.json({ success: true, ...scan });
|
|
58
|
+
});
|
|
59
|
+
|
|
52
60
|
/** GET /api/users — list all users from Supabase */
|
|
53
61
|
app.get("/api/users", async (c) => {
|
|
54
62
|
const { client, errors } = getSupabaseClient();
|
|
@@ -63,7 +71,31 @@ export async function cmdServe() {
|
|
|
63
71
|
c.json({ providers: listProviderStatus() }),
|
|
64
72
|
);
|
|
65
73
|
|
|
66
|
-
/**
|
|
74
|
+
/** GET /api/auth/:provider/authorize — redirect to provider */
|
|
75
|
+
app.get("/api/auth/:provider/authorize", async (c) => {
|
|
76
|
+
const { provider } = c.req.param();
|
|
77
|
+
const query = c.req.query();
|
|
78
|
+
const redirectUri = query.redirectUri || `${c.req.url.split("/api/")[0]}/api/auth/${provider}/callback`;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = buildAuthorizeUrl({ provider, redirectUri, state: query.state, scope: query.scope });
|
|
82
|
+
return c.redirect(result.url);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Provider not found — redirect to authorize page
|
|
85
|
+
return c.redirect("/authorize");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** GET /authorize — sign-in page listing available providers */
|
|
90
|
+
app.get("/authorize", (c) => {
|
|
91
|
+
if (hasDashboard) {
|
|
92
|
+
const html = fs.readFileSync(path.join(dashboardPath, "authorize.html"), "utf-8");
|
|
93
|
+
return c.html(html);
|
|
94
|
+
}
|
|
95
|
+
return c.json({ providers: listProviderStatus() });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** POST /api/auth/:provider/authorize — get OAuth URL as JSON */
|
|
67
99
|
app.post("/api/auth/:provider/authorize", async (c) => {
|
|
68
100
|
const { provider } = c.req.param();
|
|
69
101
|
const body = await c.req.json();
|
|
@@ -142,6 +174,22 @@ export async function cmdServe() {
|
|
|
142
174
|
return c.json({ providers });
|
|
143
175
|
});
|
|
144
176
|
|
|
177
|
+
/** POST /api/auth/magic-link/send — send magic link email */
|
|
178
|
+
app.post("/api/auth/magic-link/send", async (c) => {
|
|
179
|
+
const body = await c.req.json();
|
|
180
|
+
const result = await sendMagicLink({ email: body.email, callbackUrl: body.callbackUrl || "/" });
|
|
181
|
+
if (result.error) return c.json({ success: false, error: result.error }, 400);
|
|
182
|
+
return c.json({ success: true, sent: true });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** POST /api/auth/magic-link/verify — verify magic link token */
|
|
186
|
+
app.post("/api/auth/magic-link/verify", async (c) => {
|
|
187
|
+
const body = await c.req.json();
|
|
188
|
+
const { user, token, error } = await verifyMagicLink({ token: body.token });
|
|
189
|
+
if (error) return c.json({ success: false, error }, 401);
|
|
190
|
+
return c.json({ success: true, user, token });
|
|
191
|
+
});
|
|
192
|
+
|
|
145
193
|
/** GET /api/config — non-sensitive project config */
|
|
146
194
|
app.get("/api/config", async (c) => {
|
|
147
195
|
const fw = detectFramework();
|
|
@@ -270,16 +318,33 @@ export async function cmdServe() {
|
|
|
270
318
|
});
|
|
271
319
|
|
|
272
320
|
// ── Init ──────────────────────────────────────────
|
|
273
|
-
/**
|
|
321
|
+
/** GET /api/init/scan — autodetect Supabase config in current project */
|
|
322
|
+
app.get("/api/init/scan", (c) => {
|
|
323
|
+
const projectRoot = path.resolve(process.cwd());
|
|
324
|
+
const scan = scanSupabase(projectRoot);
|
|
325
|
+
return c.json(scan);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/** POST /api/init/connect — connect with autodetected or manual config */
|
|
274
329
|
app.post("/api/init/connect", async (c) => {
|
|
275
330
|
const body = await c.req.json().catch(() => ({}));
|
|
276
|
-
const fw = detectFramework();
|
|
277
|
-
if (!fw) return c.json({ success: false, error: "No Next.js project detected" }, 400);
|
|
278
331
|
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
332
|
+
// Auto-detect first
|
|
333
|
+
const projectRoot = path.resolve(process.cwd());
|
|
334
|
+
const scan = scanSupabase(projectRoot);
|
|
335
|
+
|
|
336
|
+
// Merge scan results with any manually provided values
|
|
337
|
+
const url = scan.url || body.supabaseUrl || process.env.SUPABASE_URL || "";
|
|
338
|
+
const anonKey = scan.anonKey || body.supabaseAnonKey || process.env.SUPABASE_ANON_KEY || "";
|
|
339
|
+
const serviceKey = scan.serviceKey || body.supabaseServiceKey || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
|
340
|
+
|
|
341
|
+
// Set env vars from detected config
|
|
342
|
+
if (url) process.env.SUPABASE_URL = url;
|
|
343
|
+
if (anonKey) process.env.SUPABASE_ANON_KEY = anonKey;
|
|
344
|
+
if (serviceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = serviceKey;
|
|
345
|
+
|
|
346
|
+
const fw = scan.framework || detectFramework();
|
|
347
|
+
if (!fw && !body.supabaseUrl) return c.json({ success: false, error: "No Next.js project detected" }, 400);
|
|
283
348
|
|
|
284
349
|
// Generate .env.local if needed
|
|
285
350
|
if (!fs.existsSync(".env.local")) {
|
|
@@ -288,16 +353,28 @@ export async function cmdServe() {
|
|
|
288
353
|
|
|
289
354
|
// Write authly.config.json
|
|
290
355
|
const config = {
|
|
291
|
-
framework: fw,
|
|
356
|
+
framework: fw || "unknown",
|
|
292
357
|
supabase: {
|
|
293
|
-
url
|
|
294
|
-
|
|
295
|
-
|
|
358
|
+
url,
|
|
359
|
+
projectRef: scan.projectRef || "",
|
|
360
|
+
anonKey: anonKey ? "set" : "",
|
|
361
|
+
serviceKey: serviceKey ? "set" : "",
|
|
362
|
+
autoDetected: scan.detected,
|
|
363
|
+
sources: scan.sources,
|
|
296
364
|
},
|
|
297
365
|
};
|
|
298
366
|
fs.writeFileSync("authly.config.json", JSON.stringify(config, null, 2) + "\n");
|
|
299
367
|
|
|
300
|
-
return c.json({
|
|
368
|
+
return c.json({
|
|
369
|
+
success: true,
|
|
370
|
+
framework: fw,
|
|
371
|
+
supabase: {
|
|
372
|
+
url,
|
|
373
|
+
detected: scan.detected,
|
|
374
|
+
canConnect: scan.canConnect,
|
|
375
|
+
sources: scan.sources,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
301
378
|
});
|
|
302
379
|
|
|
303
380
|
// ── MCP (beta) ─────────────────────────────────────
|
package/src/generators/env.js
CHANGED
|
@@ -27,8 +27,9 @@ GOOGLE_CLIENT_SECRET=""
|
|
|
27
27
|
GITHUB_CLIENT_ID=""
|
|
28
28
|
GITHUB_CLIENT_SECRET=""
|
|
29
29
|
|
|
30
|
-
# Magic Link (optional)
|
|
31
|
-
|
|
30
|
+
# Magic Link via Resend (optional)
|
|
31
|
+
RESEND_API_KEY=""
|
|
32
|
+
RESEND_FROM="noreply@authly.dev"
|
|
32
33
|
|
|
33
34
|
# Dashboard (optional overrides)
|
|
34
35
|
# AUTHLY_PORT=1284
|
|
@@ -125,6 +125,23 @@ CREATE TABLE IF NOT EXISTS public.authly_sessions (
|
|
|
125
125
|
|
|
126
126
|
CREATE INDEX idx_sessions_user ON public.authly_sessions(user_id);
|
|
127
127
|
CREATE INDEX idx_sessions_token ON public.authly_sessions(token_hash);
|
|
128
|
+
`,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "007_create_magic_links_table",
|
|
132
|
+
description: "Magic Link auth via Resend — one-time-use tokens",
|
|
133
|
+
sql: `
|
|
134
|
+
CREATE TABLE IF NOT EXISTS public.authly_magic_links (
|
|
135
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
136
|
+
user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
|
|
137
|
+
token_hash text UNIQUE NOT NULL,
|
|
138
|
+
expires_at timestamptz NOT NULL,
|
|
139
|
+
used boolean DEFAULT false,
|
|
140
|
+
created_at timestamptz DEFAULT now()
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE INDEX idx_magic_links_token ON public.authly_magic_links(token_hash);
|
|
144
|
+
CREATE INDEX idx_magic_links_user ON public.authly_magic_links(user_id);
|
|
128
145
|
`,
|
|
129
146
|
},
|
|
130
147
|
];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase auto-detection integration.
|
|
3
|
+
*
|
|
4
|
+
* Scans the local Next.js project for Supabase credentials.
|
|
5
|
+
* No PAT or manual input needed — Authly finds them in env files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read a .env file format and return key-value pairs.
|
|
13
|
+
* @param {string} filePath
|
|
14
|
+
* @returns {Record<string, string>}
|
|
15
|
+
*/
|
|
16
|
+
function _parseEnv(filePath) {
|
|
17
|
+
const result = {};
|
|
18
|
+
if (!fs.existsSync(filePath)) return result;
|
|
19
|
+
|
|
20
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
21
|
+
for (const line of content.split("\n")) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
24
|
+
const idx = trimmed.indexOf("=");
|
|
25
|
+
if (idx === -1) continue;
|
|
26
|
+
const key = trimmed.slice(0, idx).trim();
|
|
27
|
+
let value = trimmed.slice(idx + 1).trim();
|
|
28
|
+
// Remove surrounding quotes
|
|
29
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
30
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
31
|
+
value = value.slice(1, -1);
|
|
32
|
+
}
|
|
33
|
+
result[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse supabase/config.toml if it exists.
|
|
40
|
+
* @param {string} projectRoot
|
|
41
|
+
* @returns {{ projectRef?: string; poolerUrl?: string }}
|
|
42
|
+
*/
|
|
43
|
+
function _parseSupabaseToml(projectRoot) {
|
|
44
|
+
const tomlPath = path.join(projectRoot, "supabase", "config.toml");
|
|
45
|
+
if (!fs.existsSync(tomlPath)) return {};
|
|
46
|
+
|
|
47
|
+
const content = fs.readFileSync(tomlPath, "utf-8");
|
|
48
|
+
const refMatch = content.match(/project_id\s*=\s*"?([a-zA-Z0-9]{20})"?/);
|
|
49
|
+
return refMatch ? { projectRef: refMatch[1] } : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Try to find a Supabase URL in a local project.
|
|
54
|
+
* Checks: supabase/.env, .env.local, .env, supabase/config.toml
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
function _findSupabaseUrl(cwd) {
|
|
59
|
+
// Check supabase/.env
|
|
60
|
+
const supabaseEnv = _parseEnv(path.join(cwd, "supabase", ".env"));
|
|
61
|
+
if (supabaseEnv.SUPABASE_URL) return supabaseEnv.SUPABASE_URL;
|
|
62
|
+
|
|
63
|
+
// Check common env files in order of preference
|
|
64
|
+
for (const envFile of [".env.local", ".env.development.local", ".env.development", ".env"]) {
|
|
65
|
+
const env = _parseEnv(path.join(cwd, envFile));
|
|
66
|
+
if (env.NEXT_PUBLIC_SUPABASE_URL) return env.NEXT_PUBLIC_SUPABASE_URL;
|
|
67
|
+
if (env.SUPABASE_URL) return env.SUPABASE_URL;
|
|
68
|
+
if (env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
|
|
69
|
+
// Some projects set the ref as NEXT_PUBLIC_SUPABASE_URL
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Try to find Supabase keys in a local project.
|
|
78
|
+
* @param {string} cwd
|
|
79
|
+
* @returns {{ anonKey?: string; serviceKey?: string }}
|
|
80
|
+
*/
|
|
81
|
+
function _findSupabaseKeys(cwd) {
|
|
82
|
+
const result = {};
|
|
83
|
+
|
|
84
|
+
for (const envFile of [".env.local", ".env", ".env.development.local", ".env.development"]) {
|
|
85
|
+
const env = _parseEnv(path.join(cwd, envFile));
|
|
86
|
+
if (env.NEXT_PUBLIC_SUPABASE_ANON_KEY) result.anonKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
87
|
+
if (env.SUPABASE_ANON_KEY) result.anonKey = env.SUPABASE_ANON_KEY;
|
|
88
|
+
if (env.SUPABASE_SERVICE_ROLE_KEY) result.serviceKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Scan the given project directory for Supabase configuration.
|
|
96
|
+
* Returns everything found — may be partial if not all vars are configured.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} cwd — Project root (where package.json lives)
|
|
99
|
+
* @returns {{
|
|
100
|
+
* detected: boolean;
|
|
101
|
+
* url?: string;
|
|
102
|
+
* anonKey?: string;
|
|
103
|
+
* serviceKey?: string;
|
|
104
|
+
* projectRef?: string;
|
|
105
|
+
* framework?: string;
|
|
106
|
+
* sources: string[];
|
|
107
|
+
* canConnect: boolean;
|
|
108
|
+
* }}
|
|
109
|
+
*/
|
|
110
|
+
export function scanSupabase(cwd) {
|
|
111
|
+
const sources = [];
|
|
112
|
+
|
|
113
|
+
const url = _findSupabaseUrl(cwd);
|
|
114
|
+
if (url) sources.push("env files");
|
|
115
|
+
|
|
116
|
+
const keys = _findSupabaseKeys(cwd);
|
|
117
|
+
if (keys.anonKey) sources.push("env files");
|
|
118
|
+
if (keys.serviceKey) sources.push("env files");
|
|
119
|
+
|
|
120
|
+
const { projectRef } = _parseSupabaseToml(cwd);
|
|
121
|
+
if (projectRef) sources.push("supabase/config.toml");
|
|
122
|
+
|
|
123
|
+
const canConnect = !!(url && keys.anonKey && keys.serviceKey);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
detected: !!url || !!keys.anonKey,
|
|
127
|
+
url,
|
|
128
|
+
anonKey: keys.anonKey,
|
|
129
|
+
serviceKey: keys.serviceKey,
|
|
130
|
+
projectRef,
|
|
131
|
+
framework: _detectFramework(cwd),
|
|
132
|
+
sources: [...new Set(sources)],
|
|
133
|
+
canConnect,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Detect if the project is Next.js, Remit, etc.
|
|
139
|
+
* @param {string} cwd
|
|
140
|
+
* @returns {string|null}
|
|
141
|
+
*/
|
|
142
|
+
function _detectFramework(cwd) {
|
|
143
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
144
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
148
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
149
|
+
if (deps.next) return "nextjs";
|
|
150
|
+
if (deps.remix) return "remix";
|
|
151
|
+
if (deps.sveltekit || deps["@sveltejs/kit"]) return "sveltekit";
|
|
152
|
+
if (deps.vite) return "vite";
|
|
153
|
+
} catch {}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
package/src/lib/oauth.js
CHANGED
|
@@ -255,13 +255,32 @@ export async function authWithProvider(opts) {
|
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* List all configured providers and their status.
|
|
260
|
+
*
|
|
261
|
+
* @returns {{ name: string; enabled: boolean; scopes: string }[]}
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* Include additional "providers" that are not in the PROVIDERS
|
|
265
|
+
* object (e.g. magic-link, password).
|
|
266
|
+
*
|
|
267
|
+
* @returns {{ name: string; enabled: boolean; scopes: string }[]}
|
|
268
|
+
*/
|
|
269
|
+
function _listAdditionalProviders() {
|
|
270
|
+
const extras = [];
|
|
271
|
+
if (process.env.RESEND_API_KEY) {
|
|
272
|
+
extras.push({ name: "magiclink", enabled: true, scopes: "email" });
|
|
273
|
+
}
|
|
274
|
+
return extras;
|
|
275
|
+
}
|
|
276
|
+
|
|
258
277
|
/**
|
|
259
278
|
* List all configured providers and their status.
|
|
260
279
|
*
|
|
261
280
|
* @returns {{ name: string; enabled: boolean; scopes: string }[]}
|
|
262
281
|
*/
|
|
263
282
|
export function listProviderStatus() {
|
|
264
|
-
|
|
283
|
+
const oauthProviders = Object.keys(PROVIDERS).map((name) => {
|
|
265
284
|
const upper = name.toUpperCase();
|
|
266
285
|
const clientId = process.env[`${upper}_CLIENT_ID`] || "";
|
|
267
286
|
const clientSecret = process.env[`${upper}_CLIENT_SECRET`] || "";
|
|
@@ -271,6 +290,9 @@ export function listProviderStatus() {
|
|
|
271
290
|
scopes: PROVIDERS[name].scopeDefault,
|
|
272
291
|
};
|
|
273
292
|
});
|
|
293
|
+
|
|
294
|
+
const extras = _listAdditionalProviders();
|
|
295
|
+
return [...oauthProviders, ...extras];
|
|
274
296
|
}
|
|
275
297
|
|
|
276
298
|
/**
|