@rblez/authly 0.1.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/README.md +215 -0
- package/bin/authly.js +53 -0
- package/dist/dashboard/app.js +326 -0
- package/dist/dashboard/index.html +238 -0
- package/dist/dashboard/styles.css +742 -0
- package/package.json +48 -0
- package/src/auth/index.js +134 -0
- package/src/commands/audit.js +82 -0
- package/src/commands/init.js +67 -0
- package/src/commands/serve.js +383 -0
- package/src/generators/env.js +37 -0
- package/src/generators/migrations.js +138 -0
- package/src/generators/roles.js +67 -0
- package/src/generators/ui.js +619 -0
- package/src/lib/framework.js +29 -0
- package/src/lib/jwt.js +107 -0
- package/src/lib/oauth.js +301 -0
- package/src/lib/supabase.js +58 -0
- package/src/mcp/server.js +281 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolds auth UI components for Next.js app router.
|
|
3
|
+
* Generates TSX pages that use the authly SDK (not Supabase auth)
|
|
4
|
+
* for email/password and OAuth login.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
// ── Icon map for providers (SimpleIcons CDN) ──────────
|
|
11
|
+
const PROVIDER_ICON = {
|
|
12
|
+
google: "https://cdn.simpleicons.org/google",
|
|
13
|
+
github: "https://cdn.simpleicons.org/github",
|
|
14
|
+
discord: "https://cdn.simpleicons.org/discord",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ── proxy.ts (replaces deprecated middleware.ts) ────
|
|
18
|
+
const proxyTS = (opts = {}) => {
|
|
19
|
+
const authlyUrl = opts.authlyUrl || "http://localhost:1284";
|
|
20
|
+
const origin = opts.origin || "http://localhost:3000";
|
|
21
|
+
return `'use strict';
|
|
22
|
+
|
|
23
|
+
import { NextResponse } from 'next/server';
|
|
24
|
+
import type { NextRequest } from 'next/server';
|
|
25
|
+
|
|
26
|
+
const AUTHLY_URL = '${authlyUrl}';
|
|
27
|
+
|
|
28
|
+
// Routes that require a valid session
|
|
29
|
+
const protectedPaths = ['/dashboard', '/profile', '/settings'];
|
|
30
|
+
|
|
31
|
+
// Routes that redirect away if already logged in
|
|
32
|
+
const authPaths = ['/auth/login', '/auth/signup'];
|
|
33
|
+
|
|
34
|
+
async function verifySession(token: string): Promise<object | null> {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(\`\${AUTHLY_URL}/api/auth/me\`, {
|
|
37
|
+
headers: { Authorization: \`Bearer \${token}\` },
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) return null;
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
return data.success ? data.session : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function middleware(req: NextRequest) {
|
|
48
|
+
const token = req.nextUrl.searchParams.get('token') ?? req.cookies.get('authly_session')?.value;
|
|
49
|
+
const session = token ? await verifySession(token) : null;
|
|
50
|
+
|
|
51
|
+
const path = req.nextUrl.pathname;
|
|
52
|
+
|
|
53
|
+
// Protected: redirect to login if no session
|
|
54
|
+
if (!session && protectedPaths.some(p => path.startsWith(p))) {
|
|
55
|
+
return NextResponse.redirect(new URL('/auth/login', req.url));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Auth pages: redirect to dashboard if already logged in
|
|
59
|
+
if (session && authPaths.some(p => path === p)) {
|
|
60
|
+
return NextResponse.redirect(new URL('/dashboard', req.url));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return NextResponse.next();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const config = {
|
|
67
|
+
matcher: ['/((?!_next|api|static|favicon|.*\\\\..*).*)'],
|
|
68
|
+
};
|
|
69
|
+
`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Login page (uses authly API, not Supabase auth) ────
|
|
73
|
+
// Fetches enabled providers dynamically, shows only enabled ones with SimpleIcons
|
|
74
|
+
const loginTSX = (opts = {}) => {
|
|
75
|
+
const authlyUrl = opts.authlyUrl || "http://localhost:1284";
|
|
76
|
+
const origin = opts.origin || "http://localhost:3000";
|
|
77
|
+
return `'use client';
|
|
78
|
+
|
|
79
|
+
import React, { useState, useEffect, FormEvent } from 'react';
|
|
80
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
81
|
+
|
|
82
|
+
interface Provider {
|
|
83
|
+
name: string;
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
scopes: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function LoginPage() {
|
|
89
|
+
const router = useRouter();
|
|
90
|
+
const params = useSearchParams();
|
|
91
|
+
const [email, setEmail] = useState('');
|
|
92
|
+
const [password, setPassword] = useState('');
|
|
93
|
+
const [error, setError] = useState('');
|
|
94
|
+
const [loading, setLoading] = useState(false);
|
|
95
|
+
const [providers, setProviders] = useState<Provider[]>([]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
// Fetch enabled providers from authly API
|
|
99
|
+
fetch('/api/auth/providers')
|
|
100
|
+
.then(res => res.ok ? res.json() : null)
|
|
101
|
+
.then(data => {
|
|
102
|
+
if (data?.providers) setProviders(data.providers.filter((p: Provider) => p.enabled));
|
|
103
|
+
})
|
|
104
|
+
.catch(() => {});
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
setError('');
|
|
110
|
+
setLoading(true);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch('/auth/api/login', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ email, password }),
|
|
117
|
+
});
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
setLoading(false);
|
|
120
|
+
|
|
121
|
+
if (!res.ok) { setError(data.error || 'Login failed'); return; }
|
|
122
|
+
|
|
123
|
+
const next = params.get('next') || '/dashboard';
|
|
124
|
+
router.push(next);
|
|
125
|
+
router.refresh();
|
|
126
|
+
} catch {
|
|
127
|
+
setLoading(false);
|
|
128
|
+
setError('Network error');
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleProvider = (provider: string) => {
|
|
133
|
+
window.location.href = \`/auth/api/\${provider}/login\`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const enabledProviders = providers.length > 0 ? providers : [];
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="auth-page">
|
|
140
|
+
<div className="auth-card">
|
|
141
|
+
<h1>Welcome back</h1>
|
|
142
|
+
<p className="text-muted">Sign in to your account</p>
|
|
143
|
+
|
|
144
|
+
{enabledProviders.length > 0 && (
|
|
145
|
+
<div className="auth-providers">
|
|
146
|
+
{enabledProviders.map((provider) => (
|
|
147
|
+
<button
|
|
148
|
+
key={provider.name}
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => handleProvider(provider.name)}
|
|
151
|
+
className={\`provider-btn provider-\${provider.name}\`}
|
|
152
|
+
>
|
|
153
|
+
<img
|
|
154
|
+
src={\`https://cdn.simpleicons.org/\${provider.name}\`}
|
|
155
|
+
alt={provider.name}
|
|
156
|
+
width="18"
|
|
157
|
+
height="18"
|
|
158
|
+
className="provider-icon"
|
|
159
|
+
/>
|
|
160
|
+
Continue with {provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{enabledProviders.length > 0 && <div className="auth-divider"><span>or</span></div>}
|
|
167
|
+
|
|
168
|
+
<form onSubmit={handleSubmit} className="auth-form">
|
|
169
|
+
<label htmlFor="email">Email</label>
|
|
170
|
+
<input
|
|
171
|
+
id="email" type="email" value={email}
|
|
172
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
173
|
+
placeholder="you@example.com" required
|
|
174
|
+
/>
|
|
175
|
+
<label htmlFor="password">Password</label>
|
|
176
|
+
<input
|
|
177
|
+
id="password" type="password" value={password}
|
|
178
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
179
|
+
placeholder="········" required
|
|
180
|
+
/>
|
|
181
|
+
{error && <p className="error">{error}</p>}
|
|
182
|
+
<button type="submit" disabled={loading}>
|
|
183
|
+
{loading ? 'Signing in…' : 'Sign in'}
|
|
184
|
+
</button>
|
|
185
|
+
</form>
|
|
186
|
+
|
|
187
|
+
<p className="auth-link">
|
|
188
|
+
Don't have an account?{' '}
|
|
189
|
+
<a href="/auth/signup">Create one</a>
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<style jsx>{\`
|
|
194
|
+
.auth-page {
|
|
195
|
+
display: flex; align-items: center; justify-content: center;
|
|
196
|
+
min-height: 100vh; background: #000;
|
|
197
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
|
198
|
+
}
|
|
199
|
+
.auth-card {
|
|
200
|
+
background: #111; border: 1px solid #222; border-radius: 12px;
|
|
201
|
+
padding: 32px; width: 100%; max-width: 400px;
|
|
202
|
+
}
|
|
203
|
+
.auth-card h1 { font-size: 1.5rem; color: #fff; margin: 0 0 4px; }
|
|
204
|
+
.text-muted { color: #888; margin: 0 0 20px; font-size: 0.875rem; }
|
|
205
|
+
.auth-providers { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
|
206
|
+
.provider-btn {
|
|
207
|
+
background: #1a1a1a; color: #fff; border: 1px solid #333;
|
|
208
|
+
border-radius: 6px; padding: 10px 12px; font-size: 0.875rem;
|
|
209
|
+
cursor: pointer; transition: background 0.15s;
|
|
210
|
+
display: flex; align-items: center; gap: 10px;
|
|
211
|
+
}
|
|
212
|
+
.provider-btn:hover { background: #2a2a2a; }
|
|
213
|
+
.provider-icon { flex-shrink: 0; }
|
|
214
|
+
.auth-divider {
|
|
215
|
+
display: flex; align-items: center; gap: 12px;
|
|
216
|
+
color: #555; font-size: 0.75rem; margin: 16px 0;
|
|
217
|
+
}
|
|
218
|
+
.auth-divider::before, .auth-divider::after {
|
|
219
|
+
content: ""; flex: 1; height: 1px; background: #222;
|
|
220
|
+
}
|
|
221
|
+
.auth-form { display: flex; flex-direction: column; gap: 8px; }
|
|
222
|
+
.auth-form label { color: #ccc; font-size: 0.8rem; font-weight: 500; }
|
|
223
|
+
.auth-form input {
|
|
224
|
+
background: #000; border: 1px solid #333; border-radius: 6px;
|
|
225
|
+
padding: 10px 12px; color: #fff; font-size: 0.875rem; outline: none;
|
|
226
|
+
}
|
|
227
|
+
.auth-form input:focus { border-color: #555; }
|
|
228
|
+
.error { color: #f87171; font-size: 0.8rem; margin: 4px 0; }
|
|
229
|
+
.auth-form button[type="submit"] {
|
|
230
|
+
background: #fff; color: #000; border: none; border-radius: 6px;
|
|
231
|
+
padding: 10px; font-weight: 600; font-size: 0.875rem;
|
|
232
|
+
cursor: pointer; margin-top: 8px;
|
|
233
|
+
}
|
|
234
|
+
.auth-form button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
235
|
+
.auth-link { color: #888; font-size: 0.8rem; text-align: center; margin-top: 20px; }
|
|
236
|
+
.auth-link a { color: #fff; text-decoration: underline; }
|
|
237
|
+
\`}</style>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
`;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ── Signup page ──────────────────────────────────────
|
|
245
|
+
const signupTSX = () =>
|
|
246
|
+
`'use client';
|
|
247
|
+
|
|
248
|
+
import React, { useState, FormEvent } from 'react';
|
|
249
|
+
import { useRouter } from 'next/navigation';
|
|
250
|
+
|
|
251
|
+
export default function SignupPage() {
|
|
252
|
+
const router = useRouter();
|
|
253
|
+
const [email, setEmail] = useState('');
|
|
254
|
+
const [password, setPassword] = useState('');
|
|
255
|
+
const [name, setName] = useState('');
|
|
256
|
+
const [error, setError] = useState('');
|
|
257
|
+
const [success, setSuccess] = useState(false);
|
|
258
|
+
const [loading, setLoading] = useState(false);
|
|
259
|
+
|
|
260
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
setError('');
|
|
263
|
+
setLoading(true);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch('/auth/api/signup', {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ email, password, name }),
|
|
270
|
+
});
|
|
271
|
+
const data = await res.json();
|
|
272
|
+
setLoading(false);
|
|
273
|
+
|
|
274
|
+
if (!res.ok) { setError(data.error || 'Signup failed'); return; }
|
|
275
|
+
setSuccess(true);
|
|
276
|
+
} catch {
|
|
277
|
+
setLoading(false);
|
|
278
|
+
setError('Network error');
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
if (success) {
|
|
283
|
+
return (
|
|
284
|
+
<div className="auth-page">
|
|
285
|
+
<div className="auth-card">
|
|
286
|
+
<h1>Account created</h1>
|
|
287
|
+
<p className="text-muted">Check your email to verify your account.</p>
|
|
288
|
+
<a href="/auth/login" className="auth-link">Back to login</a>
|
|
289
|
+
</div>
|
|
290
|
+
<style jsx>{\`
|
|
291
|
+
.auth-page {
|
|
292
|
+
display: flex; align-items: center; justify-content: center;
|
|
293
|
+
min-height: 100vh; background: #000;
|
|
294
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
|
295
|
+
}
|
|
296
|
+
.auth-card {
|
|
297
|
+
background: #111; border: 1px solid #222; border-radius: 12px;
|
|
298
|
+
padding: 32px; width: 100%; max-width: 400px; text-align: center;
|
|
299
|
+
}
|
|
300
|
+
.auth-card h1 { font-size: 1.5rem; color: #fff; margin: 0 0 4px; }
|
|
301
|
+
.text-muted { color: #888; font-size: 0.875rem; }
|
|
302
|
+
.auth-link { color: #fff; font-size: 0.875rem; }
|
|
303
|
+
\`}</style>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div className="auth-page">
|
|
310
|
+
<div className="auth-card">
|
|
311
|
+
<h1>Create account</h1>
|
|
312
|
+
<p className="text-muted">Sign up to get started</p>
|
|
313
|
+
<form onSubmit={handleSubmit} className="auth-form">
|
|
314
|
+
<label htmlFor="name">Full name</label>
|
|
315
|
+
<input
|
|
316
|
+
id="name" type="text" value={name}
|
|
317
|
+
onChange={(e) => setName(e.target.value)}
|
|
318
|
+
placeholder="Jane Doe"
|
|
319
|
+
/>
|
|
320
|
+
<label htmlFor="email">Email</label>
|
|
321
|
+
<input
|
|
322
|
+
id="email" type="email" value={email}
|
|
323
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
324
|
+
placeholder="you@example.com" required
|
|
325
|
+
/>
|
|
326
|
+
<label htmlFor="password">Password</label>
|
|
327
|
+
<input
|
|
328
|
+
id="password" type="password" value={password}
|
|
329
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
330
|
+
placeholder="Min. 8 characters" minLength={8} required
|
|
331
|
+
/>
|
|
332
|
+
{error && <p className="error">{error}</p>}
|
|
333
|
+
<button type="submit" disabled={loading}>
|
|
334
|
+
{loading ? 'Creating account…' : 'Create account'}
|
|
335
|
+
</button>
|
|
336
|
+
</form>
|
|
337
|
+
<p className="auth-link">
|
|
338
|
+
Already have an account?{' '}
|
|
339
|
+
<a href="/auth/login">Sign in</a>
|
|
340
|
+
</p>
|
|
341
|
+
</div>
|
|
342
|
+
<style jsx>{\`
|
|
343
|
+
.auth-page {
|
|
344
|
+
display: flex; align-items: center; justify-content: center;
|
|
345
|
+
min-height: 100vh; background: #000;
|
|
346
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
|
347
|
+
}
|
|
348
|
+
.auth-card {
|
|
349
|
+
background: #111; border: 1px solid #222; border-radius: 12px;
|
|
350
|
+
padding: 32px; width: 100%; max-width: 400px;
|
|
351
|
+
}
|
|
352
|
+
.auth-card h1 { font-size: 1.5rem; color: #fff; margin: 0 0 4px; }
|
|
353
|
+
.text-muted { color: #888; margin: 0 0 24px; font-size: 0.875rem; }
|
|
354
|
+
.auth-form { display: flex; flex-direction: column; gap: 8px; }
|
|
355
|
+
.auth-form label { color: #ccc; font-size: 0.8rem; font-weight: 500; }
|
|
356
|
+
.auth-form input {
|
|
357
|
+
background: #000; border: 1px solid #333; border-radius: 6px;
|
|
358
|
+
padding: 10px 12px; color: #fff; font-size: 0.875rem; outline: none;
|
|
359
|
+
}
|
|
360
|
+
.auth-form input:focus { border-color: #555; }
|
|
361
|
+
.error { color: #f87171; font-size: 0.8rem; margin: 4px 0; }
|
|
362
|
+
.auth-form button[type="submit"] {
|
|
363
|
+
background: #fff; color: #000; border: none; border-radius: 6px;
|
|
364
|
+
padding: 10px; font-weight: 600; font-size: 0.875rem;
|
|
365
|
+
cursor: pointer; margin-top: 8px;
|
|
366
|
+
}
|
|
367
|
+
.auth-form button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
368
|
+
.auth-link { color: #888; font-size: 0.8rem; text-align: center; margin-top: 20px; }
|
|
369
|
+
.auth-link a { color: #fff; text-decoration: underline; }
|
|
370
|
+
\`}</style>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
`;
|
|
375
|
+
|
|
376
|
+
// ── OAuth callback route ────────────────────────────
|
|
377
|
+
const oauthCallbackTSX = (provider) =>
|
|
378
|
+
`import { NextRequest, NextResponse } from 'next/server';
|
|
379
|
+
|
|
380
|
+
export async function GET(req: NextRequest) {
|
|
381
|
+
const code = req.nextUrl.searchParams.get('code');
|
|
382
|
+
const state = req.nextUrl.searchParams.get('state');
|
|
383
|
+
const error = req.nextUrl.searchParams.get('error');
|
|
384
|
+
|
|
385
|
+
if (error) {
|
|
386
|
+
return NextResponse.redirect(
|
|
387
|
+
new URL(\`/auth/login?error=\${encodeURIComponent(error)}\`, req.url)
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!code) {
|
|
392
|
+
return new NextResponse('Missing authorization code', { status: 400 });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Exchange the code via authly
|
|
396
|
+
const authlyUrl = process.env.AUTHLY_URL || 'http://localhost:1284';
|
|
397
|
+
const res = await fetch(\`\${authlyUrl}/api/auth/${provider}/callback\`, {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: { 'Content-Type': 'application/json' },
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
code,
|
|
402
|
+
state,
|
|
403
|
+
redirectUri: \`\${req.nextUrl.origin}/auth/api/${provider}/callback\`,
|
|
404
|
+
}),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const data = await res.json();
|
|
408
|
+
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
return NextResponse.redirect(
|
|
411
|
+
new URL(\`/auth/login?error=\${encodeURIComponent(data.error || 'OAuth failed')}\`, req.url)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Set session cookie and redirect
|
|
416
|
+
const response = NextResponse.redirect(new URL('/dashboard', req.url));
|
|
417
|
+
response.cookies.set('authly_session', data.token, {
|
|
418
|
+
httpOnly: true,
|
|
419
|
+
secure: process.env.NODE_ENV === 'production',
|
|
420
|
+
sameSite: 'lax',
|
|
421
|
+
maxAge: 60 * 60 * 24, // 24h
|
|
422
|
+
path: '/',
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return response;
|
|
426
|
+
}
|
|
427
|
+
`;
|
|
428
|
+
|
|
429
|
+
// ── Login API route (password-based) ─────────────────
|
|
430
|
+
const loginApiTSX = () =>
|
|
431
|
+
`import { NextRequest, NextResponse } from 'next/server';
|
|
432
|
+
|
|
433
|
+
export async function POST(req: NextRequest) {
|
|
434
|
+
const { email, password } = await req.json();
|
|
435
|
+
|
|
436
|
+
const authlyUrl = process.env.AUTHLY_URL || 'http://localhost:1284';
|
|
437
|
+
const res = await fetch(\`\${authlyUrl}/api/auth/login\`, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
headers: { 'Content-Type': 'application/json' },
|
|
440
|
+
body: JSON.stringify({ email, password }),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const data = await res.json();
|
|
444
|
+
if (!res.ok) {
|
|
445
|
+
return NextResponse.json({ error: data.error }, { status: res.status });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const response = NextResponse.json({ success: true });
|
|
449
|
+
response.cookies.set('authly_session', data.token, {
|
|
450
|
+
httpOnly: true,
|
|
451
|
+
secure: process.env.NODE_ENV === 'production',
|
|
452
|
+
sameSite: 'lax',
|
|
453
|
+
maxAge: 60 * 60 * 24,
|
|
454
|
+
path: '/',
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return response;
|
|
458
|
+
}
|
|
459
|
+
`;
|
|
460
|
+
|
|
461
|
+
// ── Signup API route ─────────────────────────────────
|
|
462
|
+
const signupApiTSX = () =>
|
|
463
|
+
`import { NextRequest, NextResponse } from 'next/server';
|
|
464
|
+
|
|
465
|
+
export async function POST(req: NextRequest) {
|
|
466
|
+
const { email, password, name } = await req.json();
|
|
467
|
+
|
|
468
|
+
const authlyUrl = process.env.AUTHLY_URL || 'http://localhost:1284';
|
|
469
|
+
const res = await fetch(\`\${authlyUrl}/api/auth/register\`, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'Content-Type': 'application/json' },
|
|
472
|
+
body: JSON.stringify({ email, password, name }),
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const data = await res.json();
|
|
476
|
+
if (!res.ok) {
|
|
477
|
+
return NextResponse.json({ error: data.error }, { status: res.status });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return NextResponse.json({ success: true });
|
|
481
|
+
}
|
|
482
|
+
`;
|
|
483
|
+
|
|
484
|
+
// ── Providers API route (returns enabled providers for login) ─────
|
|
485
|
+
const providersApiTSX = () =>
|
|
486
|
+
`import { NextRequest, NextResponse } from 'next/server';
|
|
487
|
+
|
|
488
|
+
export async function GET() {
|
|
489
|
+
const authlyUrl = process.env.AUTHLY_URL || 'http://localhost:1284';
|
|
490
|
+
const res = await fetch(\`\${authlyUrl}/api/auth/providers\`);
|
|
491
|
+
|
|
492
|
+
if (!res.ok) {
|
|
493
|
+
return NextResponse.json({ providers: [] });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const data = await res.json();
|
|
497
|
+
return NextResponse.json(data);
|
|
498
|
+
}
|
|
499
|
+
`;
|
|
500
|
+
|
|
501
|
+
// ── OAuth login redirect route ──────────────────────
|
|
502
|
+
const oauthRedirectTSX = (provider) =>
|
|
503
|
+
`import { NextRequest, NextResponse } from 'next/server';
|
|
504
|
+
|
|
505
|
+
export async function GET() {
|
|
506
|
+
const authlyUrl = process.env.AUTHLY_URL || 'http://localhost:1284';
|
|
507
|
+
const origin = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
|
508
|
+
|
|
509
|
+
const res = await fetch(\`\${authlyUrl}/api/auth/${provider}/authorize\`, {
|
|
510
|
+
method: 'POST',
|
|
511
|
+
headers: { 'Content-Type': 'application/json' },
|
|
512
|
+
body: JSON.stringify({
|
|
513
|
+
redirectUri: \`\${origin}/auth/api/${provider}/callback\`,
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const data = await res.json();
|
|
518
|
+
if (!res.ok) {
|
|
519
|
+
return NextResponse.json({ error: data.error }, { status: res.status });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return NextResponse.redirect(data.url);
|
|
523
|
+
}
|
|
524
|
+
`;
|
|
525
|
+
|
|
526
|
+
// ── Public API ───────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Generate all auth UI files into a Next.js project.
|
|
530
|
+
*
|
|
531
|
+
* @param {string} targetDir - Project root (where src/app lives)
|
|
532
|
+
* @param {{ proxy?: boolean; apiRoutes?: boolean }} opts
|
|
533
|
+
* @returns {{ files: string[] }}
|
|
534
|
+
*/
|
|
535
|
+
export async function scaffoldAuth(targetDir, opts = {}) {
|
|
536
|
+
const appDir = path.join(targetDir, 'src', 'app', 'auth');
|
|
537
|
+
const apiDir = path.join(appDir, 'api', 'auth');
|
|
538
|
+
const files = [];
|
|
539
|
+
|
|
540
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
541
|
+
|
|
542
|
+
// Login page (with dynamic SimpleIcon provider buttons)
|
|
543
|
+
const loginPath = path.join(appDir, 'login', 'page.tsx');
|
|
544
|
+
fs.mkdirSync(path.dirname(loginPath), { recursive: true });
|
|
545
|
+
fs.writeFileSync(loginPath, loginTSX(opts), 'utf-8');
|
|
546
|
+
files.push(loginPath);
|
|
547
|
+
|
|
548
|
+
// Signup page
|
|
549
|
+
const signupPath = path.join(appDir, 'signup', 'page.tsx');
|
|
550
|
+
fs.mkdirSync(path.dirname(signupPath), { recursive: true });
|
|
551
|
+
fs.writeFileSync(signupPath, signupTSX(), 'utf-8');
|
|
552
|
+
files.push(signupPath);
|
|
553
|
+
|
|
554
|
+
// proxy.ts (replaces deprecated middleware.ts)
|
|
555
|
+
if (opts.proxy !== false) {
|
|
556
|
+
const proxyPath = path.join(targetDir, 'proxy.ts');
|
|
557
|
+
fs.writeFileSync(proxyPath, proxyTS(opts), 'utf-8');
|
|
558
|
+
files.push(proxyPath);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// API routes
|
|
562
|
+
if (opts.apiRoutes) {
|
|
563
|
+
fs.mkdirSync(path.join(apiDir, 'login'), { recursive: true });
|
|
564
|
+
fs.mkdirSync(path.join(apiDir, 'signup'), { recursive: true });
|
|
565
|
+
fs.mkdirSync(path.join(apiDir, 'providers'), { recursive: true });
|
|
566
|
+
fs.mkdirSync(path.join(apiDir, 'github', 'callback'), { recursive: true });
|
|
567
|
+
fs.mkdirSync(path.join(apiDir, 'google', 'callback'), { recursive: true });
|
|
568
|
+
|
|
569
|
+
// Login/Signup API proxies
|
|
570
|
+
const loginApi = path.join(apiDir, 'login', 'route.ts');
|
|
571
|
+
fs.writeFileSync(loginApi, loginApiTSX(), 'utf-8');
|
|
572
|
+
files.push(loginApi);
|
|
573
|
+
|
|
574
|
+
const signupApi = path.join(apiDir, 'signup', 'route.ts');
|
|
575
|
+
fs.writeFileSync(signupApi, signupApiTSX(), 'utf-8');
|
|
576
|
+
files.push(signupApi);
|
|
577
|
+
|
|
578
|
+
// Providers list (returns enabled providers for login page)
|
|
579
|
+
// URL: /api/auth/providers → src/app/api/auth/providers/route.ts
|
|
580
|
+
// Note: this is OUTSIDE the auth/ scaffold directory
|
|
581
|
+
const providersDir = path.join(targetDir, 'src', 'app', 'api', 'auth', 'providers');
|
|
582
|
+
fs.mkdirSync(providersDir, { recursive: true });
|
|
583
|
+
const providersApi = path.join(providersDir, 'route.ts');
|
|
584
|
+
fs.writeFileSync(providersApi, providersApiTSX(), 'utf-8');
|
|
585
|
+
files.push(providersApi);
|
|
586
|
+
|
|
587
|
+
// OAuth redirects + callbacks
|
|
588
|
+
for (const provider of ['github', 'google']) {
|
|
589
|
+
const redirect = path.join(apiDir, provider, 'login', 'route.ts');
|
|
590
|
+
fs.mkdirSync(path.dirname(redirect), { recursive: true });
|
|
591
|
+
fs.writeFileSync(redirect, oauthRedirectTSX(provider), 'utf-8');
|
|
592
|
+
files.push(redirect);
|
|
593
|
+
|
|
594
|
+
const callback = path.join(apiDir, provider, 'callback', 'route.ts');
|
|
595
|
+
fs.mkdirSync(path.dirname(callback), { recursive: true });
|
|
596
|
+
fs.writeFileSync(callback, oauthCallbackTSX(provider), 'utf-8');
|
|
597
|
+
files.push(callback);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { files };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Preview generated code without writing to disk.
|
|
606
|
+
*
|
|
607
|
+
* @param {'login' | 'signup' | 'proxy' | 'oauth-login' | 'oauth-callback'} type
|
|
608
|
+
* @returns {string}
|
|
609
|
+
*/
|
|
610
|
+
export function previewGenerated(type, opts = {}) {
|
|
611
|
+
switch (type) {
|
|
612
|
+
case 'login': return loginTSX(opts);
|
|
613
|
+
case 'signup': return signupTSX();
|
|
614
|
+
case 'proxy': return proxyTS(opts);
|
|
615
|
+
case 'oauth-login': return loginApiTSX();
|
|
616
|
+
case 'oauth-callback': return oauthCallbackTSX('provider');
|
|
617
|
+
default: return '';
|
|
618
|
+
}
|
|
619
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect the framework of the current working directory.
|
|
6
|
+
* Currently supports Next.js detection.
|
|
7
|
+
*
|
|
8
|
+
* @returns {{ name: string, version: string } | null}
|
|
9
|
+
*/
|
|
10
|
+
export function detectFramework() {
|
|
11
|
+
// Next.js: check for next.config.{js,ts,mjs} and package.json with next dep
|
|
12
|
+
const nextConfigs = ["next.config.js", "next.config.ts", "next.config.mjs"];
|
|
13
|
+
const hasNextConfig = nextConfigs.some((f) => existsSync(f));
|
|
14
|
+
|
|
15
|
+
if (hasNextConfig) {
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
|
18
|
+
const nextVersion =
|
|
19
|
+
pkg.dependencies?.next ||
|
|
20
|
+
pkg.devDependencies?.next ||
|
|
21
|
+
"unknown";
|
|
22
|
+
return { name: "Next.js", version: nextVersion };
|
|
23
|
+
} catch {
|
|
24
|
+
return { name: "Next.js", version: "unknown" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|