@webjsdev/cli 0.10.43 → 0.10.45
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 +2 -3
- package/bin/webjs.js +18 -18
- package/lib/api-gallery.js +1 -1
- package/lib/create.js +52 -108
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +11 -1
- package/templates/.agents/skills/webjs/references/components.md +19 -4
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
- package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
- package/templates/gallery/app/examples/layout.ts +11 -0
- package/templates/gallery/app/features/auth/dashboard/layout.ts +20 -0
- package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
- package/templates/gallery/app/features/auth/dashboard/page.ts +18 -0
- package/templates/gallery/app/features/auth/dashboard/settings/page.ts +21 -0
- package/templates/gallery/app/features/auth/login/page.ts +40 -0
- package/templates/gallery/app/features/auth/page.ts +33 -0
- package/templates/gallery/app/features/auth/signup/page.ts +58 -0
- package/templates/gallery/app/features/frames/page.ts +79 -0
- package/templates/gallery/app/features/layout.ts +12 -0
- package/templates/gallery/app/features/server-actions/page.ts +10 -0
- package/templates/gallery/app/features/stream/page.ts +45 -0
- package/templates/gallery/app/features/streaming/page.ts +31 -0
- package/templates/gallery/app/features/suspense/page.ts +34 -0
- package/templates/gallery/app/features/view-transitions/page.ts +41 -0
- package/templates/gallery/app/features/view-transitions/second/page.ts +28 -0
- package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
- package/templates/gallery/modules/auth/auth.server.ts +53 -0
- package/templates/gallery/modules/auth/password.server.ts +20 -0
- package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
- package/templates/gallery/modules/auth/types.ts +9 -0
- package/templates/gallery/modules/frames/utils/tasks.ts +27 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +40 -9
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +47 -11
- package/templates/gallery/modules/server-actions/components/greeter.ts +7 -1
- package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +47 -0
- package/templates/gallery/modules/stream/components/stream-demo.ts +76 -0
- package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
- package/templates/gallery/modules/streaming/components/token-stream.ts +46 -0
- package/templates/gallery/modules/suspense/components/slow-fact.ts +19 -0
- package/templates/gallery/test/auth/auth.test.ts +81 -0
- package/templates/scripts/clear-api-gallery.mjs +55 -0
- package/templates/scripts/clear-gallery.mjs +24 -12
- package/lib/lean-copy.js +0 -43
- package/lib/saas-template.js +0 -568
package/lib/saas-template.js
DELETED
|
@@ -1,568 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SaaS template files for `webjs create --template saas`.
|
|
3
|
-
* Extracted to avoid nested template literal escaping issues.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
7
|
-
import { bunifyProse } from './runtime-rewrite.js';
|
|
8
|
-
import { leanComponentSource } from './lean-copy.js';
|
|
9
|
-
import { existsSync } from 'node:fs';
|
|
10
|
-
import { join, resolve, dirname } from 'node:path';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
|
|
13
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const UI_REGISTRY_ROOT = resolve(
|
|
15
|
-
__dirname, '..', '..', 'ui', 'packages', 'registry',
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Read a registry component and rewrite its `'#lib/utils.ts'` import for
|
|
20
|
-
* the scaffolded app's `components/ui/<name>.ts` layout (the `#lib/utils/cn.ts`
|
|
21
|
-
* alias). Mirrors the helper in `create.js`, kept private here to avoid coupling.
|
|
22
|
-
*/
|
|
23
|
-
async function readUiComponent(name) {
|
|
24
|
-
const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
|
|
25
|
-
if (!existsSync(src)) return null;
|
|
26
|
-
const raw = await readFile(src, 'utf8');
|
|
27
|
-
// The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
|
|
28
|
-
// it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
|
|
29
|
-
const rewritten = raw
|
|
30
|
-
.replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
|
|
31
|
-
.replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
|
|
32
|
-
// onBeforeCache lives in its own client-only module so cn() stays pure (#819).
|
|
33
|
-
// Without this rewrite dialog.ts keeps the registry-relative `../lib/dom.ts`
|
|
34
|
-
// (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck.
|
|
35
|
-
.replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
|
|
36
|
-
.replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
|
|
37
|
-
// Strip a Tier-1 helper's worked @example (same as create.js + `webjs ui add`)
|
|
38
|
-
// so switch / checkbox are lean, not just the full-stack base set (#983).
|
|
39
|
-
return leanComponentSource(rewritten, name);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Copy named registry components into `<appDir>/components/ui/`. */
|
|
43
|
-
async function copyUiComponents(appDir, names) {
|
|
44
|
-
const uiDir = join(appDir, 'components', 'ui');
|
|
45
|
-
await mkdir(uiDir, { recursive: true });
|
|
46
|
-
for (const n of names) {
|
|
47
|
-
const content = await readUiComponent(n);
|
|
48
|
-
if (content == null) continue;
|
|
49
|
-
await writeFile(join(uiDir, `${n}.ts`), content);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {string} appDir
|
|
55
|
-
* @param {{ runtime?: 'node'|'bun' }} [opts]
|
|
56
|
-
*/
|
|
57
|
-
export async function writeSaasFiles(appDir, opts = {}) {
|
|
58
|
-
const isBun = opts.runtime === 'bun';
|
|
59
|
-
// SaaS pages use auth forms, so copy the extra ui-* components on top of
|
|
60
|
-
// the standard set the full-stack scaffold already wrote. Pre-importing
|
|
61
|
-
// them in login/signup/dashboard pages below means the dev server will
|
|
62
|
-
// SSR these elements with full styling on first paint.
|
|
63
|
-
// `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) -
|
|
64
|
-
// the saas auth pages use raw <form> + label/input class helpers instead.
|
|
65
|
-
await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']);
|
|
66
|
-
|
|
67
|
-
// The db/ layer (columns/connection) is written by the full-stack scaffold
|
|
68
|
-
// already; this template overwrites db/schema.server.ts below to add the
|
|
69
|
-
// User.passwordHash column auth needs.
|
|
70
|
-
await mkdir(join(appDir, 'lib'), { recursive: true });
|
|
71
|
-
|
|
72
|
-
// lib/password.server.ts
|
|
73
|
-
await writeFile(join(appDir, 'lib', 'password.server.ts'), [
|
|
74
|
-
"import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';",
|
|
75
|
-
"import { promisify } from 'node:util';",
|
|
76
|
-
"",
|
|
77
|
-
"const scryptAsync = promisify(scrypt);",
|
|
78
|
-
"",
|
|
79
|
-
"export async function hash(password: string): Promise<string> {",
|
|
80
|
-
" const salt = randomBytes(16).toString('hex');",
|
|
81
|
-
" const buf = (await scryptAsync(password, salt, 64)) as Buffer;",
|
|
82
|
-
" return salt + ':' + buf.toString('hex');",
|
|
83
|
-
"}",
|
|
84
|
-
"",
|
|
85
|
-
"export async function compare(password: string, stored: string): Promise<boolean> {",
|
|
86
|
-
" const [salt, key] = stored.split(':');",
|
|
87
|
-
" const buf = (await scryptAsync(password, salt, 64)) as Buffer;",
|
|
88
|
-
" return timingSafeEqual(buf, Buffer.from(key, 'hex'));",
|
|
89
|
-
"}",
|
|
90
|
-
"",
|
|
91
|
-
].join('\n'));
|
|
92
|
-
|
|
93
|
-
// lib/auth.server.ts
|
|
94
|
-
await writeFile(join(appDir, 'lib', 'auth.server.ts'), [
|
|
95
|
-
"import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server';",
|
|
96
|
-
"import { db } from '#db/connection.server.ts';",
|
|
97
|
-
"import { compare } from './password.server.ts';",
|
|
98
|
-
"",
|
|
99
|
-
"// AUTH_SECRET signs session tokens. Set a strong value in .env for any real",
|
|
100
|
-
"// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT",
|
|
101
|
-
"// safe for production, so we fail fast if it is missing or blank there.",
|
|
102
|
-
"const trimmedSecret = process.env.AUTH_SECRET?.trim();",
|
|
103
|
-
"if (process.env.NODE_ENV === 'production' && !trimmedSecret) {",
|
|
104
|
-
" throw new Error('AUTH_SECRET must be set in production');",
|
|
105
|
-
"}",
|
|
106
|
-
"const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';",
|
|
107
|
-
"",
|
|
108
|
-
"export const { auth, signIn, signOut, handlers } = createAuth({",
|
|
109
|
-
" providers: [",
|
|
110
|
-
" Credentials({",
|
|
111
|
-
" async authorize(credentials: { email: string; password: string }) {",
|
|
112
|
-
" const user = await db.query.users.findFirst({ where: { email: credentials.email } });",
|
|
113
|
-
" if (!user || !await compare(credentials.password, user.passwordHash)) return null;",
|
|
114
|
-
" return { id: String(user.id), name: user.name, email: user.email };",
|
|
115
|
-
" },",
|
|
116
|
-
" }),",
|
|
117
|
-
" // OAuth providers: add GitHub / Google sign-in by setting the matching",
|
|
118
|
-
" // env vars. Each preset (GitHub(), Google()) reads AUTH_<PROVIDER>_ID /",
|
|
119
|
-
" // _SECRET, so they only activate once configured and a fresh scaffold",
|
|
120
|
-
" // still boots with just Credentials.",
|
|
121
|
-
" ...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []),",
|
|
122
|
-
" ...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []),",
|
|
123
|
-
" ],",
|
|
124
|
-
" secret: authSecret,",
|
|
125
|
-
" // A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`.",
|
|
126
|
-
" // Point it at /login so app/login/page.ts reads searchParams.error and shows a",
|
|
127
|
-
" // message, instead of the createAuth default (the home page) swallowing the error.",
|
|
128
|
-
" pages: { error: '/login' },",
|
|
129
|
-
"});",
|
|
130
|
-
"",
|
|
131
|
-
].join('\n'));
|
|
132
|
-
|
|
133
|
-
// db/schema.server.ts: overwrite the full-stack scaffold's example User to
|
|
134
|
-
// add passwordHash (the column auth needs). Drizzle, dialect-agnostic.
|
|
135
|
-
await writeFile(join(appDir, 'db', 'schema.server.ts'), [
|
|
136
|
-
"import { defineRelations } from 'drizzle-orm';",
|
|
137
|
-
"import { table, pk, uuidPk, text, bool, createdAt } from './columns.server.ts';",
|
|
138
|
-
"",
|
|
139
|
-
"export const users = table('users', {",
|
|
140
|
-
" id: pk(),",
|
|
141
|
-
" email: text().notNull().unique(),",
|
|
142
|
-
" name: text(),",
|
|
143
|
-
" passwordHash: text().notNull(),",
|
|
144
|
-
" createdAt: createdAt(),",
|
|
145
|
-
"});",
|
|
146
|
-
"",
|
|
147
|
-
"// Backs the example-gallery /examples/todo route (modules/todo). Delete it",
|
|
148
|
-
"// with the gallery when you prune the examples you do not use.",
|
|
149
|
-
"export const todos = table('todos', {",
|
|
150
|
-
" id: uuidPk(),",
|
|
151
|
-
" title: text().notNull(),",
|
|
152
|
-
" completed: bool().notNull().default(false),",
|
|
153
|
-
" createdAt: createdAt(),",
|
|
154
|
-
"});",
|
|
155
|
-
"",
|
|
156
|
-
"export const relations = defineRelations({ users, todos }, () => ({}));",
|
|
157
|
-
"",
|
|
158
|
-
"export type User = typeof users.$inferSelect;",
|
|
159
|
-
"",
|
|
160
|
-
].join('\n'));
|
|
161
|
-
|
|
162
|
-
// modules/auth/actions/signup.server.ts
|
|
163
|
-
await mkdir(join(appDir, 'modules', 'auth', 'actions'), { recursive: true });
|
|
164
|
-
await mkdir(join(appDir, 'modules', 'auth', 'queries'), { recursive: true });
|
|
165
|
-
|
|
166
|
-
await writeFile(join(appDir, 'modules', 'auth', 'actions', 'signup.server.ts'), [
|
|
167
|
-
"'use server';",
|
|
168
|
-
"",
|
|
169
|
-
"import { db } from '#db/connection.server.ts';",
|
|
170
|
-
"import { users } from '#db/schema.server.ts';",
|
|
171
|
-
"import { hash } from '#lib/password.server.ts';",
|
|
172
|
-
"import { signIn } from '#lib/auth.server.ts';",
|
|
173
|
-
"",
|
|
174
|
-
"// Creates the account, then signs the new user in and lands on the",
|
|
175
|
-
"// dashboard. signIn returns a 302 Response carrying the session cookie; the",
|
|
176
|
-
"// signup page action returns that Response as-is (a page action may return a",
|
|
177
|
-
"// Response). signIn lives in the server-only auth module, imported here",
|
|
178
|
-
"// server-to-server, so it never reaches the browser (the signup page only",
|
|
179
|
-
"// imports this action's RPC stub).",
|
|
180
|
-
"export async function signup(input: { name: string; email: string; password: string }) {",
|
|
181
|
-
" const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });",
|
|
182
|
-
" if (exists) return { success: false as const, error: 'Email already registered', status: 409 };",
|
|
183
|
-
" await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });",
|
|
184
|
-
" return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/dashboard' });",
|
|
185
|
-
"}",
|
|
186
|
-
"",
|
|
187
|
-
].join('\n'));
|
|
188
|
-
|
|
189
|
-
// modules/auth/queries/current-user.server.ts
|
|
190
|
-
await writeFile(join(appDir, 'modules', 'auth', 'queries', 'current-user.server.ts'), [
|
|
191
|
-
"'use server';",
|
|
192
|
-
"",
|
|
193
|
-
"import { auth } from '#lib/auth.server.ts';",
|
|
194
|
-
"",
|
|
195
|
-
"// This read deliberately stays POST-default (no 'method' export). A GET",
|
|
196
|
-
"// server action (#488) is cacheable and SSR-seeded, which is wrong for a",
|
|
197
|
-
"// per-session read: the result differs per user and changes on sign-in /",
|
|
198
|
-
"// sign-out, so it must never be browser-cached or shared. Reserve GET +",
|
|
199
|
-
"// cache + tags for data identical for every visitor (see",
|
|
200
|
-
"// modules/users/queries/list-users.server.ts in the full-stack template).",
|
|
201
|
-
"export async function currentUser() {",
|
|
202
|
-
" const session = await auth();",
|
|
203
|
-
" return session?.user ?? null;",
|
|
204
|
-
"}",
|
|
205
|
-
"",
|
|
206
|
-
].join('\n'));
|
|
207
|
-
|
|
208
|
-
// modules/auth/types.ts
|
|
209
|
-
await writeFile(join(appDir, 'modules', 'auth', 'types.ts'), [
|
|
210
|
-
"export interface User {",
|
|
211
|
-
" id: number;",
|
|
212
|
-
" name: string | null;",
|
|
213
|
-
" email: string;",
|
|
214
|
-
"}",
|
|
215
|
-
"",
|
|
216
|
-
"export type ActionResult<T> =",
|
|
217
|
-
" | { success: true; data: T }",
|
|
218
|
-
" | { success: false; error: string; status: number };",
|
|
219
|
-
"",
|
|
220
|
-
].join('\n'));
|
|
221
|
-
|
|
222
|
-
// test/auth/auth.test.ts: a REAL auth-flow test driven through the framework
|
|
223
|
-
// request pipeline with the @webjsdev/server test harness (createRequestHandler
|
|
224
|
-
// + the handle() helpers from @webjsdev/server/testing). It lives under the
|
|
225
|
-
// documented test/<feature>/ convention (test/auth/), not the old test/unit/
|
|
226
|
-
// path.
|
|
227
|
-
//
|
|
228
|
-
// Two layers, by DB availability:
|
|
229
|
-
// - The protected-route gate (unauthenticated /dashboard -> 302 /login) runs
|
|
230
|
-
// ALWAYS once the app modules import: auth() only reads a cookie, no DB
|
|
231
|
-
// query. This is the headline security assertion and it is REAL.
|
|
232
|
-
// The signup, login, and protected-route flow writes + reads a user, so it
|
|
233
|
-
// needs the migrated users table (`npm run db:generate`, then `npm run dev`
|
|
234
|
-
// applies it via webjs.dev.before). Until then those flows error, so the
|
|
235
|
-
// suite probes readiness and skips with a clear message instead of crashing,
|
|
236
|
-
// then runs for real once the db is set up.
|
|
237
|
-
await mkdir(join(appDir, 'test', 'auth'), { recursive: true });
|
|
238
|
-
// The generated comments reference `npm run db:*` setup; bun-ify them so a
|
|
239
|
-
// bun-flavored saas app reads `bun run db:*` (#541; db is Node tooling, so a
|
|
240
|
-
// plain `bun run`, not the --bun server form). The transform is a no-op on Node.
|
|
241
|
-
const authTest = [
|
|
242
|
-
"import { test } from 'node:test';",
|
|
243
|
-
"import assert from 'node:assert/strict';",
|
|
244
|
-
"import { fileURLToPath } from 'node:url';",
|
|
245
|
-
"import { dirname, resolve } from 'node:path';",
|
|
246
|
-
"",
|
|
247
|
-
"import { createRequestHandler } from '@webjsdev/server';",
|
|
248
|
-
"import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';",
|
|
249
|
-
"",
|
|
250
|
-
"const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');",
|
|
251
|
-
"",
|
|
252
|
-
"// The auth pages + dashboard middleware query the users table via Drizzle.",
|
|
253
|
-
"// Until `npm run db:generate` has authored the migration (then `npm run dev`",
|
|
254
|
-
"// applies it via webjs.dev.before, or `npm run db:migrate` directly), a",
|
|
255
|
-
"// request hitting those modules 500s; we detect that at the RESPONSE level",
|
|
256
|
-
"// (a 5xx on the dashboard) and SKIP with a clear message rather than report",
|
|
257
|
-
"// a misleading failure. After the db is set up every assertion runs for real.",
|
|
258
|
-
"process.env.DATABASE_URL ||= 'file:./dev.db';",
|
|
259
|
-
"process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';",
|
|
260
|
-
"",
|
|
261
|
-
"function makeHandler() {",
|
|
262
|
-
" // createRequestHandler builds lazily, so it succeeds even before the DB",
|
|
263
|
-
" // is migrated; the missing table only surfaces when a request reaches a",
|
|
264
|
-
" // module that queries it. That is why readiness is probed per-response.",
|
|
265
|
-
" return createRequestHandler({ appDir, dev: true });",
|
|
266
|
-
"}",
|
|
267
|
-
"",
|
|
268
|
-
"test('protected route redirects to /login when unauthenticated', async (t) => {",
|
|
269
|
-
" const app = await makeHandler();",
|
|
270
|
-
" const res = await testRequest(app.handle, '/dashboard');",
|
|
271
|
-
" if (res.status >= 500) {",
|
|
272
|
-
" t.skip('app deps not ready (run `npm run db:generate` + `npm run db:migrate`)');",
|
|
273
|
-
" return;",
|
|
274
|
-
" }",
|
|
275
|
-
" // The dashboard middleware calls auth(); with no session cookie it 302s to",
|
|
276
|
-
" // /login. This needs no DB row, only a cookie read, so it is always real",
|
|
277
|
-
" // once the modules import.",
|
|
278
|
-
" assert.equal(res.status, 302, 'unauthenticated dashboard is gated');",
|
|
279
|
-
" assert.equal(res.headers.get('location'), '/login');",
|
|
280
|
-
"});",
|
|
281
|
-
"",
|
|
282
|
-
"test('signup -> login -> dashboard renders for the authenticated user', async (t) => {",
|
|
283
|
-
" const app = await makeHandler();",
|
|
284
|
-
" // Probe readiness: a 5xx on the dashboard means deps/DB are not set up.",
|
|
285
|
-
" const probe = await testRequest(app.handle, '/dashboard');",
|
|
286
|
-
" if (probe.status >= 500) { t.skip('app deps not ready; run `npm run db:generate` + `npm run db:migrate`'); return; }",
|
|
287
|
-
"",
|
|
288
|
-
" const email = `harness+${Date.now()}@example.com`;",
|
|
289
|
-
" const password = 'password123';",
|
|
290
|
-
"",
|
|
291
|
-
" // Real signup through the page server action (the no-JS form write-path).",
|
|
292
|
-
" let canSignup = true;",
|
|
293
|
-
" try {",
|
|
294
|
-
" const signupRes = await testRequest(app.handle, '/signup', {",
|
|
295
|
-
" method: 'POST',",
|
|
296
|
-
" headers: { 'content-type': 'application/x-www-form-urlencoded' },",
|
|
297
|
-
" body: new URLSearchParams({ name: 'Harness', email, password }).toString(),",
|
|
298
|
-
" });",
|
|
299
|
-
" // Success auto-logs-in and 302s to /dashboard (carrying the session",
|
|
300
|
-
" // cookie); a 422 means validation failed. Either way the action ran.",
|
|
301
|
-
" assert.ok([302, 422].includes(signupRes.status), 'signup action ran');",
|
|
302
|
-
" if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/dashboard', 'signup lands on the dashboard');",
|
|
303
|
-
" if (signupRes.status !== 302) canSignup = false;",
|
|
304
|
-
" } catch {",
|
|
305
|
-
" // No migrated DB table -> the action throws. Skip the DB-backed assertions.",
|
|
306
|
-
" canSignup = false;",
|
|
307
|
-
" }",
|
|
308
|
-
" if (!canSignup) { t.skip('no migrated DB; run `npm run db:migrate` to enable the full flow'); return; }",
|
|
309
|
-
"",
|
|
310
|
-
" // Real login captures the genuine signed session cookie.",
|
|
311
|
-
" const { cookies } = await loginAndGetCookies(app.handle, { email, password });",
|
|
312
|
-
"",
|
|
313
|
-
" // With the session cookie the protected route now renders (200).",
|
|
314
|
-
" const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));",
|
|
315
|
-
" assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');",
|
|
316
|
-
" const body = await dash.text();",
|
|
317
|
-
" assert.match(body, /Dashboard/, 'the dashboard content rendered');",
|
|
318
|
-
" // The greeting interpolates the real user, so the name renders and the",
|
|
319
|
-
" // literal template source never leaks (a counterfactual for the escaping bug).",
|
|
320
|
-
" assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name');",
|
|
321
|
-
" assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string');",
|
|
322
|
-
"});",
|
|
323
|
-
"",
|
|
324
|
-
].join('\n');
|
|
325
|
-
await writeFile(
|
|
326
|
-
join(appDir, 'test', 'auth', 'auth.test.ts'),
|
|
327
|
-
isBun ? bunifyProse(authTest) : authTest,
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
// app/api/auth/[...path]/route.ts
|
|
331
|
-
await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true });
|
|
332
|
-
await writeFile(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts'), [
|
|
333
|
-
"import { handlers } from '#lib/auth.server.ts';",
|
|
334
|
-
"export const GET = handlers.GET;",
|
|
335
|
-
"export const POST = handlers.POST;",
|
|
336
|
-
"",
|
|
337
|
-
].join('\n'));
|
|
338
|
-
|
|
339
|
-
// app/login/page.ts
|
|
340
|
-
await mkdir(join(appDir, 'app', 'login'), { recursive: true });
|
|
341
|
-
await writeFile(join(appDir, 'app', 'login', 'page.ts'), [
|
|
342
|
-
"import { html } from '@webjsdev/core';",
|
|
343
|
-
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts';",
|
|
344
|
-
"import { buttonClass } from '#components/ui/button.ts';",
|
|
345
|
-
"import { inputClass } from '#components/ui/input.ts';",
|
|
346
|
-
"import { labelClass } from '#components/ui/label.ts';",
|
|
347
|
-
"",
|
|
348
|
-
"export const metadata = { title: 'Login' };",
|
|
349
|
-
"",
|
|
350
|
-
"// A failed sign-in 302s back here with ?error=... (createAuth is configured",
|
|
351
|
-
"// with pages.error: '/login' in lib/auth.server.ts). Map the code to a plain",
|
|
352
|
-
"// message so a bad password gets visible feedback instead of a silent bounce.",
|
|
353
|
-
"function errorMessage(code: string | undefined): string | null {",
|
|
354
|
-
" if (!code) return null;",
|
|
355
|
-
" if (code === 'CredentialsSignin') return 'Invalid email or password.';",
|
|
356
|
-
" return 'Could not sign you in. Please try again.';",
|
|
357
|
-
"}",
|
|
358
|
-
"",
|
|
359
|
-
"export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {",
|
|
360
|
-
" const error = errorMessage(searchParams.error);",
|
|
361
|
-
" return html`",
|
|
362
|
-
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
363
|
-
" <div class=${cardClass()}>",
|
|
364
|
-
" <div class=${cardHeaderClass()}>",
|
|
365
|
-
" <h1 class=${cardTitleClass()}>Sign in</h1>",
|
|
366
|
-
" <p class=${cardDescriptionClass()}>Welcome back: log in to continue.</p>",
|
|
367
|
-
" </div>",
|
|
368
|
-
" <div class=${cardContentClass()}>",
|
|
369
|
-
" ${error ? html`<p role=\"alert\" class=\"mb-4 text-sm text-destructive\">${error}</p>` : ''}",
|
|
370
|
-
" <form method=\"POST\" action=\"/api/auth/signin/credentials\" class=\"flex flex-col gap-4\">",
|
|
371
|
-
" <!-- createAuth reads redirectTo from the posted form and 302s there after a successful signin. -->",
|
|
372
|
-
" <input type=\"hidden\" name=\"redirectTo\" value=\"/dashboard\">",
|
|
373
|
-
" <div class=\"flex flex-col gap-1.5\">",
|
|
374
|
-
" <label class=${labelClass()} for=\"email\">Email</label>",
|
|
375
|
-
" <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
|
|
376
|
-
" </div>",
|
|
377
|
-
" <div class=\"flex flex-col gap-1.5\">",
|
|
378
|
-
" <label class=${labelClass()} for=\"password\">Password</label>",
|
|
379
|
-
" <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
|
|
380
|
-
" </div>",
|
|
381
|
-
" <button class=${buttonClass()} type=\"submit\">Sign in</button>",
|
|
382
|
-
" </form>",
|
|
383
|
-
" </div>",
|
|
384
|
-
" <div class=${cardFooterClass()}>",
|
|
385
|
-
" <p class=\"text-sm text-muted-foreground\">Don't have an account? <a href=\"/signup\" class=\"underline\">Sign up</a></p>",
|
|
386
|
-
" </div>",
|
|
387
|
-
" </div>",
|
|
388
|
-
" </div>",
|
|
389
|
-
" `;",
|
|
390
|
-
"}",
|
|
391
|
-
"",
|
|
392
|
-
].join('\n'));
|
|
393
|
-
|
|
394
|
-
// app/signup/page.ts
|
|
395
|
-
await mkdir(join(appDir, 'app', 'signup'), { recursive: true });
|
|
396
|
-
await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [
|
|
397
|
-
"import { html } from '@webjsdev/core';",
|
|
398
|
-
"import { signup } from '#modules/auth/actions/signup.server.ts';",
|
|
399
|
-
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts';",
|
|
400
|
-
"import { buttonClass } from '#components/ui/button.ts';",
|
|
401
|
-
"import { inputClass } from '#components/ui/input.ts';",
|
|
402
|
-
"import { labelClass } from '#components/ui/label.ts';",
|
|
403
|
-
"",
|
|
404
|
-
"export const metadata = { title: 'Sign up' };",
|
|
405
|
-
"",
|
|
406
|
-
"// Page server action: handles the POST from the form below. With JS",
|
|
407
|
-
"// disabled this is a plain <form> round-trip; with JS the client router",
|
|
408
|
-
"// swaps the 422 re-render (errors) or follows the 303 (success) in place.",
|
|
409
|
-
"// A validation failure returns fieldErrors + values so the page re-renders",
|
|
410
|
-
"// with messages and the user's typed input preserved (#244).",
|
|
411
|
-
"export async function action({ formData }: { formData: FormData }) {",
|
|
412
|
-
" const name = String(formData.get('name') || '').trim();",
|
|
413
|
-
" const email = String(formData.get('email') || '').trim();",
|
|
414
|
-
" const password = String(formData.get('password') || '');",
|
|
415
|
-
" const values = { name, email };",
|
|
416
|
-
" const fieldErrors: Record<string, string> = {};",
|
|
417
|
-
" if (!name) fieldErrors.name = 'Name is required';",
|
|
418
|
-
" if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';",
|
|
419
|
-
" if (password.length < 8) fieldErrors.password = 'At least 8 characters';",
|
|
420
|
-
" if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };",
|
|
421
|
-
" const result = await signup({ name, email, password });",
|
|
422
|
-
" // On success signup returns signIn's 302 Response (auto-login -> /dashboard);",
|
|
423
|
-
" // a page action may return a Response, so pass it straight through.",
|
|
424
|
-
" if (result instanceof Response) return result;",
|
|
425
|
-
" return { success: false, fieldErrors: { email: result.error }, values, status: result.status };",
|
|
426
|
-
"}",
|
|
427
|
-
"",
|
|
428
|
-
"export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {",
|
|
429
|
-
" const errors = actionData?.fieldErrors || {};",
|
|
430
|
-
" const values = actionData?.values || {};",
|
|
431
|
-
" return html`",
|
|
432
|
-
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
433
|
-
" <div class=${cardClass()}>",
|
|
434
|
-
" <div class=${cardHeaderClass()}>",
|
|
435
|
-
" <h1 class=${cardTitleClass()}>Create an account</h1>",
|
|
436
|
-
" <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
|
|
437
|
-
" </div>",
|
|
438
|
-
" <div class=${cardContentClass()}>",
|
|
439
|
-
" <form method=\"POST\" class=\"flex flex-col gap-4\">",
|
|
440
|
-
" <div class=\"flex flex-col gap-1.5\">",
|
|
441
|
-
" <label class=${labelClass()} for=\"name\">Name</label>",
|
|
442
|
-
" <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" value=${values.name || ''} required>",
|
|
443
|
-
" ${errors.name ? html`<p class=\"text-sm text-destructive\">${errors.name}</p>` : ''}",
|
|
444
|
-
" </div>",
|
|
445
|
-
" <div class=\"flex flex-col gap-1.5\">",
|
|
446
|
-
" <label class=${labelClass()} for=\"email\">Email</label>",
|
|
447
|
-
" <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" value=${values.email || ''} required>",
|
|
448
|
-
" ${errors.email ? html`<p class=\"text-sm text-destructive\">${errors.email}</p>` : ''}",
|
|
449
|
-
" </div>",
|
|
450
|
-
" <div class=\"flex flex-col gap-1.5\">",
|
|
451
|
-
" <label class=${labelClass()} for=\"password\">Password</label>",
|
|
452
|
-
" <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" minlength=\"8\" required>",
|
|
453
|
-
" ${errors.password ? html`<p class=\"text-sm text-destructive\">${errors.password}</p>` : ''}",
|
|
454
|
-
" </div>",
|
|
455
|
-
" <button class=${buttonClass()} type=\"submit\">Create account</button>",
|
|
456
|
-
" </form>",
|
|
457
|
-
" </div>",
|
|
458
|
-
" <div class=${cardFooterClass()}>",
|
|
459
|
-
" <p class=\"text-sm text-muted-foreground\">Already have an account? <a href=\"/login\" class=\"underline\">Log in</a></p>",
|
|
460
|
-
" </div>",
|
|
461
|
-
" </div>",
|
|
462
|
-
" </div>",
|
|
463
|
-
" `;",
|
|
464
|
-
"}",
|
|
465
|
-
"",
|
|
466
|
-
].join('\n'));
|
|
467
|
-
|
|
468
|
-
// app/dashboard/middleware.ts
|
|
469
|
-
await mkdir(join(appDir, 'app', 'dashboard', 'settings'), { recursive: true });
|
|
470
|
-
await writeFile(join(appDir, 'app', 'dashboard', 'middleware.ts'), [
|
|
471
|
-
"import { auth } from '#lib/auth.server.ts';",
|
|
472
|
-
"",
|
|
473
|
-
"export default async function requireAuth(req: Request, next: () => Promise<Response>) {",
|
|
474
|
-
" const session = await auth();",
|
|
475
|
-
" if (!session?.user) {",
|
|
476
|
-
" return new Response(null, { status: 302, headers: { location: '/login' } });",
|
|
477
|
-
" }",
|
|
478
|
-
" return next();",
|
|
479
|
-
"}",
|
|
480
|
-
"",
|
|
481
|
-
].join('\n'));
|
|
482
|
-
|
|
483
|
-
// app/dashboard/layout.ts: a thin sub-nav shared by every /dashboard page
|
|
484
|
-
// (page.ts and settings/page.ts). It carries the logout control so a signed-in
|
|
485
|
-
// user can end the session from anywhere under /dashboard.
|
|
486
|
-
await writeFile(join(appDir, 'app', 'dashboard', 'layout.ts'), [
|
|
487
|
-
"import { html } from '@webjsdev/core';",
|
|
488
|
-
"import { buttonClass } from '#components/ui/button.ts';",
|
|
489
|
-
"",
|
|
490
|
-
"// Nested layout for the protected /dashboard subtree. Logout is a plain",
|
|
491
|
-
"// <form method=\"POST\"> posting to the createAuth signout route: it clears the",
|
|
492
|
-
"// session cookie and 302s home, and works with JS off (progressive-enhancement",
|
|
493
|
-
"// default). signOut is server-only (lib/auth.server.ts), so we POST to its route",
|
|
494
|
-
"// rather than import it into a browser-shipping page. After signout the dashboard",
|
|
495
|
-
"// middleware bounces any later /dashboard visit to /login.",
|
|
496
|
-
"export default function DashboardLayout({ children }: { children: unknown }) {",
|
|
497
|
-
" return html`",
|
|
498
|
-
" <nav class=\"flex items-center gap-4 mb-6 pb-4 border-b border-border\">",
|
|
499
|
-
" <a href=\"/dashboard\" class=\"text-sm font-medium hover:underline\">Dashboard</a>",
|
|
500
|
-
" <a href=\"/dashboard/settings\" class=\"text-sm font-medium hover:underline\">Settings</a>",
|
|
501
|
-
" <form method=\"POST\" action=\"/api/auth/signout\" class=\"ml-auto\">",
|
|
502
|
-
" <button class=${buttonClass({ variant: 'outline', size: 'sm' })} type=\"submit\">Log out</button>",
|
|
503
|
-
" </form>",
|
|
504
|
-
" </nav>",
|
|
505
|
-
" ${children}",
|
|
506
|
-
" `;",
|
|
507
|
-
"}",
|
|
508
|
-
"",
|
|
509
|
-
].join('\n'));
|
|
510
|
-
|
|
511
|
-
// app/dashboard/page.ts
|
|
512
|
-
await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [
|
|
513
|
-
"import { html } from '@webjsdev/core';",
|
|
514
|
-
"import { currentUser } from '#modules/auth/queries/current-user.server.ts';",
|
|
515
|
-
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass } from '#components/ui/card.ts';",
|
|
516
|
-
"import { badgeClass } from '#components/ui/badge.ts';",
|
|
517
|
-
"",
|
|
518
|
-
"export const metadata = { title: 'Dashboard' };",
|
|
519
|
-
"",
|
|
520
|
-
"export default async function Dashboard() {",
|
|
521
|
-
" const user = await currentUser();",
|
|
522
|
-
" return html`",
|
|
523
|
-
" <div class=\"flex items-center justify-between mb-6\">",
|
|
524
|
-
" <h1 class=\"text-2xl font-semibold\">Dashboard</h1>",
|
|
525
|
-
" <span class=${badgeClass({ variant: 'secondary' })}>Signed in</span>",
|
|
526
|
-
" </div>",
|
|
527
|
-
" <div class=${cardClass()}>",
|
|
528
|
-
" <div class=${cardHeaderClass()}>",
|
|
529
|
-
" <h2 class=${cardTitleClass()}>Welcome, ${user?.name || user?.email}!</h2>",
|
|
530
|
-
" <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
|
|
531
|
-
" </div>",
|
|
532
|
-
" </div>",
|
|
533
|
-
" `;",
|
|
534
|
-
"}",
|
|
535
|
-
"",
|
|
536
|
-
].join('\n'));
|
|
537
|
-
|
|
538
|
-
// app/dashboard/settings/page.ts
|
|
539
|
-
await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [
|
|
540
|
-
"import { html } from '@webjsdev/core';",
|
|
541
|
-
"import { currentUser } from '#modules/auth/queries/current-user.server.ts';",
|
|
542
|
-
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '#components/ui/card.ts';",
|
|
543
|
-
"",
|
|
544
|
-
"export const metadata = { title: 'Settings' };",
|
|
545
|
-
"",
|
|
546
|
-
"export default async function Settings() {",
|
|
547
|
-
" const user = await currentUser();",
|
|
548
|
-
" return html`",
|
|
549
|
-
" <h1 class=\"text-2xl font-semibold mb-6\">Settings</h1>",
|
|
550
|
-
" <div class=${cardClass()}>",
|
|
551
|
-
" <div class=${cardHeaderClass()}>",
|
|
552
|
-
" <h2 class=${cardTitleClass()}>Account</h2>",
|
|
553
|
-
" <p class=${cardDescriptionClass()}>Your basic profile information.</p>",
|
|
554
|
-
" </div>",
|
|
555
|
-
" <div class=${cardContentClass()}>",
|
|
556
|
-
" <dl class=\"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm\">",
|
|
557
|
-
" <dt class=\"text-muted-foreground\">Email</dt>",
|
|
558
|
-
" <dd>${user?.email}</dd>",
|
|
559
|
-
" <dt class=\"text-muted-foreground\">Name</dt>",
|
|
560
|
-
" <dd>${user?.name || 'Not set'}</dd>",
|
|
561
|
-
" </dl>",
|
|
562
|
-
" </div>",
|
|
563
|
-
" </div>",
|
|
564
|
-
" `;",
|
|
565
|
-
"}",
|
|
566
|
-
"",
|
|
567
|
-
].join('\n'));
|
|
568
|
-
}
|