@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.
@@ -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 === "init") checkInitFiles();
203
+ if (id === "integration") loadIntegration();
80
204
  }
81
205
 
82
- // ── Init ────────────────────────────────────────────
83
- checkInitFiles();
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 &amp; Configure';
106
267
 
107
268
  if (data.success) {
108
- showResult(result, `Connected to Supabase via ${data.framework.name} (${data.framework.version})`, "ok");
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 in Init.</td></tr>';
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">${(u.id ?? "").slice(0, 8)}…</td>
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">${u.created_at ? new Date(u.created_at).toLocaleDateString() : "—"}</td>
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
- `<div class="provider-card">
165
- <i class="ri-${p.name === "google" ? "google" : p.name === "github" ? "github" : p.name === "discord" ? "discord" : "shield-keyhole"}-line"></i>
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">${p.name}</div>
168
- <div class="provider-scopes">scope: ${p.scopes}</div>
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 ? "Configured" : "Not configured"}</span>
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'>&mdash;</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-database-2-line"></i> Migrate</a>
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>Connect your project</h2>
55
- <p>Detect your Next.js project and set up Supabase credentials.</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 (optional, for admin)</label>
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-folder-check-line"></i></div>
71
- <h2>Generated files</h2>
72
- <ul class="file-list" id="fileList">
73
- <li><i class="ri-file-line"></i> .env.local <span class="file-status" id="envStatus">checking…</span></li>
74
- <li><i class="ri-file-line"></i> authly.config.json <span class="file-status" id="configStatus">checking…</span></li>
75
- </ul>
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 &amp; 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.</p>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rblez/authly",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local auth dashboard for Next.js + Supabase",
5
5
  "type": "module",
6
6
  "bin": {
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
+ }
@@ -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
- /** POST /api/auth/:provider/authorize — get OAuth URL */
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
- /** POST /api/init/connectdetect project and generate config */
321
+ /** GET /api/init/scanautodetect 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
- // Store Supabase config if provided
280
- if (body.supabaseUrl) process.env.SUPABASE_URL = body.supabaseUrl;
281
- if (body.supabaseAnonKey) process.env.SUPABASE_ANON_KEY = body.supabaseAnonKey;
282
- if (body.supabaseServiceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = body.supabaseServiceKey;
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: body.supabaseUrl || "",
294
- anonKey: body.supabaseAnonKey ? "set" : "",
295
- serviceKey: body.supabaseServiceKey ? "set" : "",
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({ success: true, framework: fw });
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) ─────────────────────────────────────
@@ -27,8 +27,9 @@ GOOGLE_CLIENT_SECRET=""
27
27
  GITHUB_CLIENT_ID=""
28
28
  GITHUB_CLIENT_SECRET=""
29
29
 
30
- # Magic Link (optional)
31
- # RESEND_API_KEY=""
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
- return Object.keys(PROVIDERS).map((name) => {
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
  /**