@rblez/authly 0.1.0 → 0.3.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/bin/authly.js +3 -1
- package/dist/dashboard/app.js +182 -32
- package/dist/dashboard/authorize.html +117 -0
- package/dist/dashboard/index.html +192 -15
- package/dist/dashboard/styles.css +10 -0
- package/package.json +3 -2
- package/src/auth/index.js +98 -0
- package/src/commands/ext.js +107 -0
- package/src/commands/serve.js +218 -15
- package/src/generators/env.js +3 -2
- package/src/generators/migrations.js +33 -0
- package/src/integrations/supabase.js +156 -0
- package/src/lib/oauth.js +23 -1
- package/src/lib/supabase-api.js +152 -0
- package/src/lib/supabase-oauth.js +200 -0
|
@@ -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,69 @@
|
|
|
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;display:flex;gap:8px;flex-wrap:wrap">
|
|
189
|
+
<button class="btn btn--primary btn--sm" id="reconnectBtn"><i class="ri-refresh-line"></i> Re-scan & reconnect</button>
|
|
190
|
+
<a href="/api/auth/supabase/authorize" class="btn btn--sm" style="background:#0d1b3e;border:1px solid #1d355e;color:#58a6ff;text-decoration:none" id="connectSbPlatformBtn"><i class="ri-plug-line"></i> Connect via OAuth</a>
|
|
191
|
+
</div>
|
|
76
192
|
</div>
|
|
77
193
|
</section>
|
|
78
194
|
|
|
79
195
|
<!-- ═══ PROVIDERS ═══ -->
|
|
80
196
|
<section class="section hidden" id="providers">
|
|
197
|
+
<div class="doc-panel" id="provDocs">
|
|
198
|
+
<h4><i class="ri-book-line"></i> Provider setup <button class="toggle-docs" data-target="provDocsBody">toggle</button></h4>
|
|
199
|
+
<div class="doc-panel-body expanded" id="provDocsBody">
|
|
200
|
+
<p>Enable authentication methods for your project. Each provider requires OAuth client credentials.</p>
|
|
201
|
+
<ul>
|
|
202
|
+
<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>
|
|
203
|
+
<li><img src="https://cdn.simpleicons.org/github/fff" width="14" height="14" alt="GitHub"> <strong>GitHub</strong> — Settings → Developer settings → OAuth Apps</li>
|
|
204
|
+
<li><img src="https://cdn.simpleicons.org/discord/fff" width="14" height="14" alt="Discord"> <strong>Discord</strong> — Developer Portal → Applications</li>
|
|
205
|
+
<li><img src="https://cdn.simpleicons.org/resend/fff" width="14" height="14" alt="Resend"> <strong>Magic Link</strong> — Resend dashboard (API key)</li>
|
|
206
|
+
</ul>
|
|
207
|
+
<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>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
81
210
|
<div id="providerList"></div>
|
|
82
211
|
</section>
|
|
83
212
|
|
|
84
213
|
<!-- ═══ UI (Scaffold) ═══ -->
|
|
85
214
|
<section class="section hidden" id="ui">
|
|
215
|
+
<div class="doc-panel" id="scaffoldDocs">
|
|
216
|
+
<h4><i class="ri-book-line"></i> UI scaffolding <button class="toggle-docs" data-target="scaffoldDocsBody">toggle</button></h4>
|
|
217
|
+
<div class="doc-panel-body expanded" id="scaffoldDocsBody">
|
|
218
|
+
<p>Generates ready-to-use TSX pages for your Next.js App Router project. Click a scaffold card to preview the code.</p>
|
|
219
|
+
<ul>
|
|
220
|
+
<li><strong>Login</strong> — Email/password + dynamic SimpleIcon buttons for enabled providers</li>
|
|
221
|
+
<li><strong>Sign Up</strong> — Registration form with validation</li>
|
|
222
|
+
<li><strong>Middleware</strong> — Route protection for protected and auth paths</li>
|
|
223
|
+
</ul>
|
|
224
|
+
<p style="margin-top:8px">Files are generated into <code>src/app/auth/</code> in your Next.js project.</p>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
86
227
|
<div class="card">
|
|
87
228
|
<h2>Scaffold auth pages</h2>
|
|
88
|
-
<p>Generate ready-to-use TSX pages for your Next.js project.</p>
|
|
89
229
|
<div class="grid-3">
|
|
90
230
|
<div class="scaffold-card" data-scaffold="login">
|
|
91
231
|
<i class="ri-login-circle-line"></i>
|
|
@@ -109,15 +249,36 @@
|
|
|
109
249
|
|
|
110
250
|
<!-- ═══ ROUTES ═══ -->
|
|
111
251
|
<section class="section hidden" id="routes">
|
|
252
|
+
<div class="doc-panel" id="routesDocs">
|
|
253
|
+
<h4><i class="ri-book-line"></i> Route protection <button class="toggle-docs" data-target="routesDocsBody">toggle</button></h4>
|
|
254
|
+
<div class="doc-panel-body expanded" id="routesDocsBody">
|
|
255
|
+
<p>The scaffolded middleware intercepts requests and checks for a valid <code>authly_session</code> cookie. Missing cookies redirect to login.</p>
|
|
256
|
+
<ul>
|
|
257
|
+
<li><code>/dashboard</code>, <code>/profile</code>, <code>/settings</code> → unprotected redirects to <code>/auth/login</code></li>
|
|
258
|
+
<li><code>/auth/login</code>, <code>/auth/signup</code> → redirect to <code>/dashboard</code> if logged in</li>
|
|
259
|
+
</ul>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
112
262
|
<div class="card">
|
|
113
263
|
<h2>Protected routes</h2>
|
|
114
|
-
<p>Middleware scaffold protects these paths:</p>
|
|
115
264
|
<div class="code-block"><code>/dashboard, /profile, /settings → redirect to /auth/login if unauthenticated</code></div>
|
|
116
265
|
</div>
|
|
117
266
|
</section>
|
|
118
267
|
|
|
119
268
|
<!-- ═══ ROLES ═══ -->
|
|
120
269
|
<section class="section hidden" id="roles">
|
|
270
|
+
<div class="doc-panel" id="rolesDocs">
|
|
271
|
+
<h4><i class="ri-book-line"></i> Role-based access control <button class="toggle-docs" data-target="rolesDocsBody">toggle</button></h4>
|
|
272
|
+
<div class="doc-panel-body expanded" id="rolesDocsBody">
|
|
273
|
+
<p>Authly implements RBAC with three default roles:</p>
|
|
274
|
+
<ul>
|
|
275
|
+
<li><code>admin</code> — Full access to all resources</li>
|
|
276
|
+
<li><code>user</code> — Standard authenticated (auto-assigned on signup)</li>
|
|
277
|
+
<li><code>guest</code> — Limited access, read-only</li>
|
|
278
|
+
</ul>
|
|
279
|
+
<p style="margin-top:8px">Requires the <code>001_create_roles_table</code> and <code>004_create_user_roles_table</code> migrations.</p>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
121
282
|
<div class="card">
|
|
122
283
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
123
284
|
<h2>Roles</h2>
|
|
@@ -146,7 +307,7 @@
|
|
|
146
307
|
<section class="section hidden" id="api-keys">
|
|
147
308
|
<div class="card">
|
|
148
309
|
<h2>Generate API Key</h2>
|
|
149
|
-
<p>Create a key for programmatic access
|
|
310
|
+
<p>Create a key for programmatic access. The raw key is shown <strong>only once</strong>.</p>
|
|
150
311
|
<form id="keyForm" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap">
|
|
151
312
|
<div>
|
|
152
313
|
<label style="font-size:.75rem;color:#888;display:block;margin-bottom:2px">Key name</label>
|
|
@@ -162,12 +323,29 @@
|
|
|
162
323
|
<section class="section hidden" id="env">
|
|
163
324
|
<div class="card">
|
|
164
325
|
<h2>Environment variables</h2>
|
|
326
|
+
<p>Check which variables are set in the current environment.</p>
|
|
165
327
|
<div class="vars-list" id="envVars"></div>
|
|
166
328
|
</div>
|
|
167
329
|
</section>
|
|
168
330
|
|
|
169
331
|
<!-- ═══ MIGRATE ═══ -->
|
|
170
332
|
<section class="section hidden" id="migrate">
|
|
333
|
+
<div class="doc-panel" id="migrateDocs">
|
|
334
|
+
<h4><i class="ri-book-line"></i> Database migrations <button class="toggle-docs" data-target="migrateDocsBody">toggle</button></h4>
|
|
335
|
+
<div class="doc-panel-body expanded" id="migrateDocsBody">
|
|
336
|
+
<p>Authly manages its own schema via incremental SQL migrations. Click <strong>Run</strong> to execute a migration directly against your Supabase database.</p>
|
|
337
|
+
<ul>
|
|
338
|
+
<li><code>001</code> — Roles table (admin, user, guest)</li>
|
|
339
|
+
<li><code>002</code> — Users table (authly_users)</li>
|
|
340
|
+
<li><code>003</code> — OAuth accounts (provider linkages)</li>
|
|
341
|
+
<li><code>004</code> — User roles + auto-assign trigger</li>
|
|
342
|
+
<li><code>005</code> — API keys (hashed)</li>
|
|
343
|
+
<li><code>006</code> — Sessions</li>
|
|
344
|
+
<li><code>007</code> — Magic links (Resend)</li>
|
|
345
|
+
</ul>
|
|
346
|
+
<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>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
171
349
|
<div class="card">
|
|
172
350
|
<h2>Migrations</h2>
|
|
173
351
|
<div class="migration-list" id="migrationList"></div>
|
|
@@ -211,7 +389,6 @@
|
|
|
211
389
|
</div>
|
|
212
390
|
<p style="font-size:.8rem;color:#666;margin-top:12px">
|
|
213
391
|
<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
392
|
</p>
|
|
216
393
|
</div>
|
|
217
394
|
</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,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rblez/authly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Local auth dashboard for Next.js + Supabase",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"authly": "./bin/authly.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
+
"start": "node bin/authly.js serve",
|
|
10
11
|
"dev": "node bin/authly.js serve",
|
|
11
12
|
"lint": "prettier --check src/",
|
|
12
13
|
"format": "prettier --write src/"
|
|
13
14
|
},
|
|
14
15
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
16
|
+
"node": ">=20.0.0"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { detectFramework } from "../lib/framework.js";
|
|
6
|
+
import { scanSupabase } from "../integrations/supabase.js";
|
|
7
|
+
import { generateEnv } from "../generators/env.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `authly ext add <name>` — add an extension to the project.
|
|
11
|
+
* Currently supports: supabase
|
|
12
|
+
*/
|
|
13
|
+
export async function cmdExt(args) {
|
|
14
|
+
const subcmd = args[0];
|
|
15
|
+
const name = args[1];
|
|
16
|
+
|
|
17
|
+
if (!subcmd || subcmd === "add") {
|
|
18
|
+
if (!name) {
|
|
19
|
+
console.log(chalk.bold("\n Authly Extensions — add\n"));
|
|
20
|
+
console.log(chalk.bold(" Usage:"));
|
|
21
|
+
console.log(` npx @rblez/authly ext add <name>\n`);
|
|
22
|
+
console.log(chalk.bold(" Available:"));
|
|
23
|
+
console.log(` ${chalk.cyan("supabase")} Auto-detect & connect Supabase, show auth URL\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (name === "supabase") {
|
|
28
|
+
return cmdExtAddSupabase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.error(chalk.red(`\n Unknown extension: ${name}\n`));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.error(chalk.red(`\n Unknown subcommand: ${subcmd}\n`));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function cmdExtAddSupabase() {
|
|
40
|
+
console.log(chalk.bold("\n Authly — Supabase Extension\n"));
|
|
41
|
+
|
|
42
|
+
// Detect framework
|
|
43
|
+
const framework = detectFramework();
|
|
44
|
+
if (!framework) {
|
|
45
|
+
console.log(chalk.yellow(" ⚠ No framework detected, but continuing anyway.\n"));
|
|
46
|
+
} else {
|
|
47
|
+
console.log(`${chalk.green("✔")} Detected framework: ${chalk.cyan(framework.name ?? "unknown")}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const projectRoot = path.resolve(process.cwd());
|
|
51
|
+
|
|
52
|
+
// Scan for Supabase credentials
|
|
53
|
+
const spinner = ora("Scanning project for Supabase credentials").start();
|
|
54
|
+
const scan = scanSupabase(projectRoot);
|
|
55
|
+
|
|
56
|
+
if (scan.detected) {
|
|
57
|
+
let detail = [];
|
|
58
|
+
if (scan.url) detail.push(chalk.green("URL found"));
|
|
59
|
+
if (scan.anonKey) detail.push(chalk.green("Anon key found"));
|
|
60
|
+
if (scan.serviceKey) detail.push(chalk.green("Service key found"));
|
|
61
|
+
if (scan.projectRef) detail.push(`Ref: ${scan.projectRef}`);
|
|
62
|
+
spinner.succeed(`Found credentials: ${detail.join(" · ")}`);
|
|
63
|
+
} else {
|
|
64
|
+
spinner.warn("No Supabase credentials found in env files");
|
|
65
|
+
console.log(chalk.yellow("\n You can still connect manually. Run `authly serve` for the dashboard.\n"));
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Write authly.config.json if not exists
|
|
70
|
+
const configPath = path.join(projectRoot, "authly.config.json");
|
|
71
|
+
if (!fs.existsSync(configPath)) {
|
|
72
|
+
const config = {
|
|
73
|
+
$schema: "https://raw.githubusercontent.com/rblez/authly/main/schema/config.json",
|
|
74
|
+
framework: framework?.name ?? "unknown",
|
|
75
|
+
port: 1284,
|
|
76
|
+
supabase: {
|
|
77
|
+
url: scan.url ?? "",
|
|
78
|
+
anonKey: scan.anonKey ? "set" : "",
|
|
79
|
+
serviceRoleKey: scan.serviceKey ? "set" : "",
|
|
80
|
+
projectRef: scan.projectRef ?? "",
|
|
81
|
+
},
|
|
82
|
+
providers: {},
|
|
83
|
+
roles: ["admin", "user", "guest"],
|
|
84
|
+
};
|
|
85
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
86
|
+
console.log(`${chalk.green("✔")} Created authly.config.json`);
|
|
87
|
+
} else {
|
|
88
|
+
console.log(`${chalk.yellow("•")} authly.config.json already exists`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generate .env.local if not exists
|
|
92
|
+
const envPath = path.join(projectRoot, ".env.local");
|
|
93
|
+
if (!fs.existsSync(envPath)) {
|
|
94
|
+
const envSpinner = ora("Generating .env.local").start();
|
|
95
|
+
await generateEnv(envPath);
|
|
96
|
+
envSpinner.succeed("Generated .env.local with authly variables");
|
|
97
|
+
} else {
|
|
98
|
+
console.log(`${chalk.yellow("•")} .env.local already exists`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Show auth URL
|
|
102
|
+
const port = process.env.AUTHLY_PORT || 1284;
|
|
103
|
+
const authUrl = `http://localhost:${port}/authorize`;
|
|
104
|
+
console.log(chalk.bold(`\n Ready! Start the dashboard and go to:`));
|
|
105
|
+
console.log(` ${chalk.cyan.underline(authUrl)}`);
|
|
106
|
+
console.log(chalk.dim(`\n Run: npx @rblez/authly serve\n`));
|
|
107
|
+
}
|