elm-ssr 1.0.0 → 1.0.3
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/lib/scaffold.mjs +697 -268
- package/package.json +1 -1
- package/src/debugger.ts +34 -25
package/lib/scaffold.mjs
CHANGED
|
@@ -48,10 +48,10 @@ const elmJsonTemplate = () => ({
|
|
|
48
48
|
}
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
const sharedTemplate = (namespace) => `module ${namespace}.View.Shared exposing (head,
|
|
51
|
+
const sharedTemplate = (namespace, auth) => `module ${namespace}.View.Shared exposing (head, layout)
|
|
52
52
|
|
|
53
|
-
import ElmSsr.Html exposing (Node, div,
|
|
54
|
-
import ElmSsr.Html.Attributes exposing (class)
|
|
53
|
+
import ElmSsr.Html exposing (Node, a, div, header, main_, nav, span, text)
|
|
54
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
55
55
|
import ElmSsr.Page as Page
|
|
56
56
|
|
|
57
57
|
|
|
@@ -63,19 +63,33 @@ head =
|
|
|
63
63
|
]
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
div [ class "
|
|
66
|
+
layout : String -> List (Node msg) -> Node msg
|
|
67
|
+
layout pageTitle body =
|
|
68
|
+
div [ class "page" ]
|
|
69
|
+
[ header [ class "header" ]
|
|
70
|
+
[ div [ class "header-inner" ]
|
|
71
|
+
[ a [ class "brand", href "/" ]
|
|
72
|
+
[ span [ class "brand-icon" ] [ text "◆" ]
|
|
73
|
+
, text "elm-ssr"
|
|
74
|
+
]
|
|
75
|
+
, nav [ class "nav" ]
|
|
76
|
+
[ a [ class "nav-link", href "/" ] [ text "Home" ]
|
|
77
|
+
, a [ class "nav-link", href "/counter" ] [ text "Counter" ]${auth ? `
|
|
78
|
+
, a [ class "nav-link", href "/login" ] [ text "Sign in" ]` : ""}
|
|
79
|
+
]
|
|
80
|
+
]
|
|
81
|
+
]
|
|
82
|
+
, main_ [ class "main" ]
|
|
83
|
+
[ div [ class "container" ] body
|
|
84
|
+
]
|
|
85
|
+
]
|
|
69
86
|
`;
|
|
70
87
|
|
|
71
|
-
const indexRouteTemplate = (namespace) => `module ${namespace}.Routes.Index exposing (page, action)
|
|
72
|
-
|
|
73
|
-
-- File-based routing: this module maps to GET /.
|
|
74
|
-
-- A page is stateless (no Model/Msg) and ships no client JavaScript.
|
|
88
|
+
const indexRouteTemplate = (namespace, auth) => `module ${namespace}.Routes.Index exposing (page, action)
|
|
75
89
|
|
|
76
90
|
import ElmSsr.Action as Action exposing (Action)
|
|
77
91
|
import ElmSsr.Document exposing (Document)
|
|
78
|
-
import ElmSsr.Html exposing (a, p, text)
|
|
92
|
+
import ElmSsr.Html exposing (a, div, h1, h2, p, section, span, text)
|
|
79
93
|
import ElmSsr.Html.Attributes exposing (class, href)
|
|
80
94
|
import ElmSsr.Loader as Loader exposing (Loader)
|
|
81
95
|
import ElmSsr.Page as Page
|
|
@@ -85,10 +99,7 @@ import ${namespace}.View.Shared as Shared
|
|
|
85
99
|
|
|
86
100
|
page : Request -> Loader (Document Never)
|
|
87
101
|
page _ =
|
|
88
|
-
Loader.
|
|
89
|
-
|> Loader.map (\\maybeGreeting ->
|
|
90
|
-
view (Maybe.withDefault "Hello from default env!" maybeGreeting)
|
|
91
|
-
)
|
|
102
|
+
Loader.succeed view
|
|
92
103
|
|
|
93
104
|
|
|
94
105
|
action : Request -> Action (Document Never)
|
|
@@ -96,30 +107,47 @@ action _ =
|
|
|
96
107
|
Action.fail 405 "Method not allowed"
|
|
97
108
|
|
|
98
109
|
|
|
99
|
-
view :
|
|
100
|
-
view
|
|
110
|
+
view : Document Never
|
|
111
|
+
view =
|
|
101
112
|
Page.page
|
|
102
|
-
{ title = "
|
|
113
|
+
{ title = "elm-ssr"
|
|
103
114
|
, head = Shared.head
|
|
104
115
|
, body =
|
|
105
|
-
[ Shared.
|
|
106
|
-
[
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
[ Shared.layout "Home"
|
|
117
|
+
[ section [ class "hero" ]
|
|
118
|
+
[ h1 [ class "hero-title" ] [ text "Ship fast." ]
|
|
119
|
+
, p [ class "hero-subtitle" ]
|
|
120
|
+
[ text "Type-safe server-side rendering with interactive islands. Runs on Cloudflare Workers and Bun." ]
|
|
121
|
+
, div [ class "hero-actions" ]
|
|
122
|
+
[ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ]${auth ? `
|
|
123
|
+
, a [ class "btn btn-secondary", href "/login" ] [ text "Sign in" ]` : ""}
|
|
124
|
+
]
|
|
125
|
+
]
|
|
126
|
+
, section [ class "features" ]
|
|
127
|
+
[ featureCard "⚡" "Edge-first" "Renders in milliseconds at the edge. No cold starts."
|
|
128
|
+
, featureCard "🦺" "Fully typed" "End-to-end Elm types from DB to HTML. No runtime surprises."
|
|
129
|
+
, featureCard "🏝️" "Islands" "Add interactivity exactly where you need it. Zero JS elsewhere."
|
|
130
|
+
]
|
|
109
131
|
]
|
|
110
132
|
]
|
|
111
133
|
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
featureCard : String -> String -> String -> ElmSsr.Html.Node msg
|
|
137
|
+
featureCard icon title_ body =
|
|
138
|
+
div [ class "feature-card" ]
|
|
139
|
+
[ span [ class "feature-icon" ] [ text icon ]
|
|
140
|
+
, h2 [ class "feature-title" ] [ text title_ ]
|
|
141
|
+
, p [ class "feature-body" ] [ text body ]
|
|
142
|
+
]
|
|
112
143
|
`;
|
|
113
144
|
|
|
114
145
|
const counterRouteTemplate = (namespace) => `module ${namespace}.Routes.Counter exposing (page, action)
|
|
115
146
|
|
|
116
|
-
-- File-based routing: GET /counter. A static page that embeds an interactive
|
|
117
|
-
-- island. The page ships no client runtime; the browser mounts the island
|
|
118
|
-
-- separately, and only that root updates.
|
|
119
|
-
|
|
120
147
|
import ElmSsr.Action as Action exposing (Action)
|
|
121
148
|
import ElmSsr.Document exposing (Document)
|
|
122
|
-
import ElmSsr.Html exposing (p, text)
|
|
149
|
+
import ElmSsr.Html exposing (div, h1, p, text)
|
|
150
|
+
import ElmSsr.Html.Attributes exposing (class)
|
|
123
151
|
import ElmSsr.Loader as Loader exposing (Loader)
|
|
124
152
|
import ElmSsr.Page as Page
|
|
125
153
|
import ElmSsr.Route exposing (Request)
|
|
@@ -143,9 +171,14 @@ view =
|
|
|
143
171
|
{ title = "Counter | elm-ssr"
|
|
144
172
|
, head = Shared.head
|
|
145
173
|
, body =
|
|
146
|
-
[ Shared.
|
|
147
|
-
[
|
|
148
|
-
|
|
174
|
+
[ Shared.layout "Counter"
|
|
175
|
+
[ div [ class "page-header" ]
|
|
176
|
+
[ h1 [] [ text "Interactive Counter" ]
|
|
177
|
+
, p [ class "page-subtitle" ]
|
|
178
|
+
[ text "This page is server-rendered. Only the counter widget below is a client-side island — zero JS elsewhere." ]
|
|
179
|
+
]
|
|
180
|
+
, div [ class "card" ]
|
|
181
|
+
[ Counter.embed { start = 0 } ]
|
|
149
182
|
]
|
|
150
183
|
]
|
|
151
184
|
}
|
|
@@ -238,19 +271,18 @@ subscriptions _ =
|
|
|
238
271
|
view : Model -> Html Msg
|
|
239
272
|
view model =
|
|
240
273
|
div [ class "counter" ]
|
|
241
|
-
[ button [ class "
|
|
242
|
-
, span [ class "value" ] [ text (String.fromInt model.count) ]
|
|
243
|
-
, button [ class "
|
|
274
|
+
[ button [ class "btn btn-secondary btn-square", type_ "button", onClick Decrement ] [ text "−" ]
|
|
275
|
+
, span [ class "counter-value" ] [ text (String.fromInt model.count) ]
|
|
276
|
+
, button [ class "btn btn-primary btn-square", type_ "button", onClick Increment ] [ text "+" ]
|
|
244
277
|
]
|
|
245
278
|
`;
|
|
246
279
|
|
|
247
280
|
const notFoundRouteTemplate = (namespace) => `module ${namespace}.Routes.NotFound exposing (page, action)
|
|
248
281
|
|
|
249
|
-
-- File-based routing: NotFound is the fallback when no other route matches.
|
|
250
|
-
|
|
251
282
|
import ElmSsr.Action as Action exposing (Action)
|
|
252
283
|
import ElmSsr.Document exposing (Document)
|
|
253
|
-
import ElmSsr.Html exposing (p, text)
|
|
284
|
+
import ElmSsr.Html exposing (a, div, h1, p, text)
|
|
285
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
254
286
|
import ElmSsr.Loader as Loader exposing (Loader)
|
|
255
287
|
import ElmSsr.Page as Page
|
|
256
288
|
import ElmSsr.Route exposing (Request)
|
|
@@ -272,44 +304,124 @@ view =
|
|
|
272
304
|
Page.notFound
|
|
273
305
|
{ title = "Not Found | elm-ssr"
|
|
274
306
|
, head = Shared.head
|
|
275
|
-
, body =
|
|
307
|
+
, body =
|
|
308
|
+
[ Shared.layout "Not Found"
|
|
309
|
+
[ div [ class "error-page" ]
|
|
310
|
+
[ h1 [ class "error-code" ] [ text "404" ]
|
|
311
|
+
, p [ class "error-message" ] [ text "This page doesn't exist." ]
|
|
312
|
+
, a [ class "btn btn-primary", href "/" ] [ text "Go home" ]
|
|
313
|
+
]
|
|
314
|
+
]
|
|
315
|
+
]
|
|
276
316
|
}
|
|
277
317
|
`;
|
|
278
318
|
|
|
319
|
+
const betterAuthMigrationTemplate = () => `-- BetterAuth requires these 4 tables. Do not rename or remove columns.
|
|
320
|
+
-- Generated by elm-ssr. See https://www.better-auth.com/docs/concepts/database
|
|
321
|
+
CREATE TABLE IF NOT EXISTS "user" (
|
|
322
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
323
|
+
name TEXT NOT NULL,
|
|
324
|
+
email TEXT NOT NULL UNIQUE,
|
|
325
|
+
"emailVerified" INTEGER NOT NULL DEFAULT 0,
|
|
326
|
+
image TEXT,
|
|
327
|
+
"createdAt" INTEGER NOT NULL,
|
|
328
|
+
"updatedAt" INTEGER NOT NULL
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
CREATE TABLE IF NOT EXISTS session (
|
|
332
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
333
|
+
"expiresAt" INTEGER NOT NULL,
|
|
334
|
+
token TEXT NOT NULL UNIQUE,
|
|
335
|
+
"createdAt" INTEGER NOT NULL,
|
|
336
|
+
"updatedAt" INTEGER NOT NULL,
|
|
337
|
+
"ipAddress" TEXT,
|
|
338
|
+
"userAgent" TEXT,
|
|
339
|
+
"userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
CREATE TABLE IF NOT EXISTS account (
|
|
343
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
344
|
+
"accountId" TEXT NOT NULL,
|
|
345
|
+
"providerId" TEXT NOT NULL,
|
|
346
|
+
"userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
347
|
+
"accessToken" TEXT,
|
|
348
|
+
"refreshToken" TEXT,
|
|
349
|
+
"idToken" TEXT,
|
|
350
|
+
"accessTokenExpiresAt" INTEGER,
|
|
351
|
+
"refreshTokenExpiresAt" INTEGER,
|
|
352
|
+
scope TEXT,
|
|
353
|
+
password TEXT,
|
|
354
|
+
"createdAt" INTEGER NOT NULL,
|
|
355
|
+
"updatedAt" INTEGER NOT NULL
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
CREATE TABLE IF NOT EXISTS verification (
|
|
359
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
360
|
+
identifier TEXT NOT NULL,
|
|
361
|
+
value TEXT NOT NULL,
|
|
362
|
+
"expiresAt" INTEGER NOT NULL,
|
|
363
|
+
"createdAt" INTEGER,
|
|
364
|
+
"updatedAt" INTEGER
|
|
365
|
+
);
|
|
366
|
+
`;
|
|
367
|
+
|
|
368
|
+
const auth0MigrationTemplate = () => `-- Local user cache for Auth0 authenticated users.
|
|
369
|
+
-- Auth0 is the canonical identity source; this table stores profile data after first login.
|
|
370
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
371
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
372
|
+
email TEXT NOT NULL UNIQUE,
|
|
373
|
+
name TEXT,
|
|
374
|
+
picture TEXT,
|
|
375
|
+
"createdAt" INTEGER NOT NULL,
|
|
376
|
+
"updatedAt" INTEGER NOT NULL
|
|
377
|
+
);
|
|
378
|
+
`;
|
|
379
|
+
|
|
380
|
+
const authDisplayName = (authProvider) =>
|
|
381
|
+
authProvider === "auth0" ? "Auth0" : "BetterAuth";
|
|
382
|
+
|
|
279
383
|
const loginRouteTemplate = (namespace, authProvider) => `module ${namespace}.Routes.Login exposing (page, action)
|
|
280
384
|
|
|
281
385
|
import ElmSsr.Action as Action exposing (Action)
|
|
282
386
|
import ElmSsr.Document exposing (Document)
|
|
283
|
-
import ElmSsr.Html exposing (a, div, p, text)
|
|
284
|
-
import ElmSsr.Html.Attributes
|
|
387
|
+
import ElmSsr.Html exposing (a, div, h1, p, span, text)
|
|
388
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
285
389
|
import ElmSsr.Loader as Loader exposing (Loader)
|
|
286
390
|
import ElmSsr.Page as Page
|
|
287
391
|
import ElmSsr.Route exposing (Request)
|
|
288
392
|
import ${namespace}.View.Shared as Shared
|
|
289
393
|
|
|
394
|
+
|
|
290
395
|
page : Request -> Loader (Document Never)
|
|
291
396
|
page _ =
|
|
292
397
|
Loader.succeed view
|
|
293
398
|
|
|
399
|
+
|
|
294
400
|
action : Request -> Action (Document Never)
|
|
295
401
|
action _ =
|
|
296
402
|
Action.fail 405 "Method not allowed"
|
|
297
403
|
|
|
404
|
+
|
|
298
405
|
view : Document Never
|
|
299
406
|
view =
|
|
300
407
|
Page.page
|
|
301
|
-
{ title = "
|
|
408
|
+
{ title = "Sign in | elm-ssr"
|
|
302
409
|
, head = Shared.head
|
|
303
410
|
, body =
|
|
304
|
-
[ Shared.
|
|
305
|
-
[ div [
|
|
306
|
-
[
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
[
|
|
310
|
-
,
|
|
311
|
-
]
|
|
312
|
-
|
|
411
|
+
[ Shared.layout "Sign in"
|
|
412
|
+
[ div [ class "auth-page" ]
|
|
413
|
+
[ div [ class "auth-card" ]
|
|
414
|
+
[ div [ class "auth-header" ]
|
|
415
|
+
[ span [ class "auth-logo" ] [ text "◆" ]
|
|
416
|
+
, h1 [ class "auth-title" ] [ text "Welcome back" ]
|
|
417
|
+
, p [ class "auth-subtitle" ] [ text "Sign in to your account to continue." ]
|
|
418
|
+
]
|
|
419
|
+
, div [ class "auth-body" ]
|
|
420
|
+
[ a [ class "btn btn-primary btn-full", href "/api/auth/login" ]
|
|
421
|
+
[ text "Continue with ${authDisplayName(authProvider)}" ]
|
|
422
|
+
]
|
|
423
|
+
, p [ class "auth-footer" ]
|
|
424
|
+
[ text "Powered by ${authDisplayName(authProvider)} via elm-ssr" ]
|
|
313
425
|
]
|
|
314
426
|
]
|
|
315
427
|
]
|
|
@@ -321,54 +433,144 @@ const profileRouteTemplate = (namespace) => `module ${namespace}.Routes.Profile
|
|
|
321
433
|
|
|
322
434
|
import ElmSsr.Action as Action exposing (Action)
|
|
323
435
|
import ElmSsr.Document exposing (Document)
|
|
324
|
-
import ElmSsr.Html exposing (a, div,
|
|
325
|
-
import ElmSsr.Html.Attributes
|
|
436
|
+
import ElmSsr.Html exposing (a, div, h1, p, span, text)
|
|
437
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
326
438
|
import ElmSsr.Loader as Loader exposing (Loader)
|
|
327
439
|
import ElmSsr.Page as Page
|
|
328
|
-
import ElmSsr.Route
|
|
440
|
+
import ElmSsr.Route exposing (Request)
|
|
329
441
|
import ${namespace}.View.Shared as Shared
|
|
330
442
|
import Json.Decode as Decode
|
|
331
443
|
|
|
444
|
+
|
|
332
445
|
type alias UserProfile =
|
|
333
446
|
{ email : String
|
|
334
447
|
, name : Maybe String
|
|
335
448
|
}
|
|
336
449
|
|
|
450
|
+
|
|
337
451
|
userDecoder : Decode.Decoder UserProfile
|
|
338
452
|
userDecoder =
|
|
339
453
|
Decode.map2 UserProfile
|
|
340
454
|
(Decode.field "email" Decode.string)
|
|
341
455
|
(Decode.maybe (Decode.field "name" Decode.string))
|
|
342
456
|
|
|
457
|
+
|
|
343
458
|
page : Request -> Loader (Document Never)
|
|
344
459
|
page _ =
|
|
345
460
|
Loader.requireUser userDecoder "/login" (\\user ->
|
|
346
461
|
Loader.succeed (view user)
|
|
347
462
|
)
|
|
348
463
|
|
|
464
|
+
|
|
349
465
|
action : Request -> Action (Document Never)
|
|
350
466
|
action _ =
|
|
351
467
|
Action.fail 405 "Method not allowed"
|
|
352
468
|
|
|
469
|
+
|
|
353
470
|
view : UserProfile -> Document Never
|
|
354
471
|
view user =
|
|
355
472
|
Page.page
|
|
356
473
|
{ title = "Profile | elm-ssr"
|
|
357
474
|
, head = Shared.head
|
|
358
475
|
, body =
|
|
359
|
-
[ Shared.
|
|
360
|
-
[ div []
|
|
361
|
-
[
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
476
|
+
[ Shared.layout "Profile"
|
|
477
|
+
[ div [ class "auth-page" ]
|
|
478
|
+
[ div [ class "auth-card" ]
|
|
479
|
+
[ div [ class "auth-header" ]
|
|
480
|
+
[ span [ class "avatar" ]
|
|
481
|
+
[ text (String.left 1 (Maybe.withDefault user.email user.name) |> String.toUpper) ]
|
|
482
|
+
, h1 [ class "auth-title" ]
|
|
483
|
+
[ text (Maybe.withDefault "Your account" user.name) ]
|
|
484
|
+
, p [ class "auth-subtitle" ] [ text user.email ]
|
|
485
|
+
]
|
|
486
|
+
, div [ class "auth-body" ]
|
|
487
|
+
[ a [ class "btn btn-secondary btn-full", href "/api/auth/logout" ]
|
|
488
|
+
[ text "Sign out" ]
|
|
489
|
+
]
|
|
490
|
+
]
|
|
365
491
|
]
|
|
366
492
|
]
|
|
367
493
|
]
|
|
368
494
|
}
|
|
369
495
|
`;
|
|
370
496
|
|
|
371
|
-
const
|
|
497
|
+
const betterAuthEndpointTemplate = () => `import { betterAuth } from "better-auth";
|
|
498
|
+
import type { Middleware } from "elm-ssr/http";
|
|
499
|
+
import type { RequestSession } from "elm-ssr/sessions";
|
|
500
|
+
|
|
501
|
+
// BetterAuth is initialised per-request to pick up the right DB binding.
|
|
502
|
+
// Production (Cloudflare Workers): env.DB is the D1 binding.
|
|
503
|
+
// Local dev (Bun): falls back to bun:sqlite using app.db.
|
|
504
|
+
const createAuth = (env: any) => {
|
|
505
|
+
const db = env?.DB ?? (() => {
|
|
506
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
507
|
+
const { Database } = require("bun:sqlite");
|
|
508
|
+
return new Database("app.db");
|
|
509
|
+
})();
|
|
510
|
+
|
|
511
|
+
return betterAuth({
|
|
512
|
+
baseURL: (env?.BETTER_AUTH_URL as string) ?? "http://localhost:8787",
|
|
513
|
+
secret: (env?.BETTER_AUTH_SECRET as string) ?? "change-me-in-production",
|
|
514
|
+
database: { type: "sqlite", db },
|
|
515
|
+
emailAndPassword: { enabled: true },
|
|
516
|
+
// Uncomment to add social providers (add matching env vars in .dev.vars):
|
|
517
|
+
// socialProviders: {
|
|
518
|
+
// github: {
|
|
519
|
+
// clientId: env?.GITHUB_CLIENT_ID as string,
|
|
520
|
+
// clientSecret: env?.GITHUB_CLIENT_SECRET as string,
|
|
521
|
+
// },
|
|
522
|
+
// },
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
export const handleAuth = (request: Request, env: any): Promise<Response> =>
|
|
527
|
+
createAuth(env).handler(request);
|
|
528
|
+
|
|
529
|
+
// Reads the BetterAuth session from the request and returns the user.
|
|
530
|
+
const getSessionUser = async (
|
|
531
|
+
request: Request,
|
|
532
|
+
env: any
|
|
533
|
+
): Promise<{ email: string; name: string } | null> => {
|
|
534
|
+
try {
|
|
535
|
+
const session = await createAuth(env).api.getSession({ headers: request.headers });
|
|
536
|
+
if (!session?.user) return null;
|
|
537
|
+
return { email: session.user.email, name: session.user.name ?? session.user.email };
|
|
538
|
+
} catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// elm-ssr middleware: reads BetterAuth's session and injects the user into
|
|
544
|
+
// context.session so Loader.session / Loader.requireUser work on every request.
|
|
545
|
+
export const betterAuthMiddleware: Middleware = async (context, next) => {
|
|
546
|
+
const user = await getSessionUser(context.request, context.env);
|
|
547
|
+
context.session = {
|
|
548
|
+
id: user ? \`ba:\${user.email}\` : "",
|
|
549
|
+
data: user,
|
|
550
|
+
csrf: "",
|
|
551
|
+
dirty: false,
|
|
552
|
+
destroyed: false,
|
|
553
|
+
isNew: false,
|
|
554
|
+
} as RequestSession;
|
|
555
|
+
return next(context);
|
|
556
|
+
};
|
|
557
|
+
`;
|
|
558
|
+
|
|
559
|
+
const auth0EndpointTemplate = () => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
|
|
560
|
+
|
|
561
|
+
interface Auth0Config {
|
|
562
|
+
domain: string;
|
|
563
|
+
clientId: string;
|
|
564
|
+
clientSecret: string;
|
|
565
|
+
callbackUrl: string;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const getConfig = (env: any): Auth0Config => ({
|
|
569
|
+
domain: (env?.AUTH0_DOMAIN as string) ?? "",
|
|
570
|
+
clientId: (env?.AUTH0_CLIENT_ID as string) ?? "",
|
|
571
|
+
clientSecret: (env?.AUTH0_CLIENT_SECRET as string) ?? "",
|
|
572
|
+
callbackUrl: (env?.AUTH0_CALLBACK_URL as string) ?? "http://localhost:8787/api/auth/callback",
|
|
573
|
+
});
|
|
372
574
|
|
|
373
575
|
export const handleAuth = async (
|
|
374
576
|
request: Request,
|
|
@@ -376,54 +578,88 @@ export const handleAuth = async (
|
|
|
376
578
|
options?: { store?: any; secret?: string }
|
|
377
579
|
): Promise<Response> => {
|
|
378
580
|
const url = new URL(request.url);
|
|
581
|
+
const config = getConfig(env);
|
|
379
582
|
|
|
380
583
|
if (url.pathname === "/api/auth/login") {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
584
|
+
if (!config.domain || !config.clientId) {
|
|
585
|
+
return new Response(
|
|
586
|
+
"Auth0 not configured — set AUTH0_DOMAIN and AUTH0_CLIENT_ID in .dev.vars",
|
|
587
|
+
{ status: 500 }
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const params = new URLSearchParams({
|
|
591
|
+
response_type: "code",
|
|
592
|
+
client_id: config.clientId,
|
|
593
|
+
redirect_uri: config.callbackUrl,
|
|
594
|
+
scope: "openid profile email",
|
|
595
|
+
});
|
|
596
|
+
return Response.redirect(\`https://\${config.domain}/authorize?\${params}\`, 302);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (url.pathname === "/api/auth/callback") {
|
|
600
|
+
const code = url.searchParams.get("code");
|
|
601
|
+
if (!code) return new Response("Missing code", { status: 400 });
|
|
602
|
+
|
|
603
|
+
const tokenRes = await fetch(\`https://\${config.domain}/oauth/token\`, {
|
|
604
|
+
method: "POST",
|
|
605
|
+
headers: { "content-type": "application/json" },
|
|
606
|
+
body: JSON.stringify({
|
|
607
|
+
grant_type: "authorization_code",
|
|
608
|
+
client_id: config.clientId,
|
|
609
|
+
client_secret: config.clientSecret,
|
|
610
|
+
code,
|
|
611
|
+
redirect_uri: config.callbackUrl,
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
if (!tokenRes.ok) {
|
|
616
|
+
return new Response("Token exchange with Auth0 failed", { status: 502 });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const tokens = await tokenRes.json() as { id_token: string };
|
|
620
|
+
const [, payloadB64] = tokens.id_token.split(".");
|
|
621
|
+
const user = JSON.parse(atob(payloadB64)) as { email: string; name?: string; sub: string };
|
|
385
622
|
|
|
386
623
|
if (options?.store && options?.secret) {
|
|
387
624
|
const sessionId = generateSessionId();
|
|
388
625
|
await options.store.set(sessionId, {
|
|
389
|
-
data: { email:
|
|
390
|
-
csrf: generateCsrfToken()
|
|
626
|
+
data: { email: user.email, name: user.name ?? user.email },
|
|
627
|
+
csrf: generateCsrfToken(),
|
|
391
628
|
});
|
|
392
629
|
const signed = await signValue(options.secret, sessionId);
|
|
393
|
-
|
|
630
|
+
return new Response(null, {
|
|
631
|
+
status: 302,
|
|
632
|
+
headers: {
|
|
633
|
+
Location: "/profile",
|
|
634
|
+
"Set-Cookie": \`session=\${signed}; Path=/; HttpOnly; SameSite=Lax\`,
|
|
635
|
+
},
|
|
636
|
+
});
|
|
394
637
|
}
|
|
395
|
-
|
|
396
|
-
return new Response(null, {
|
|
397
|
-
status: 302,
|
|
398
|
-
headers: {
|
|
399
|
-
"Location": "/profile",
|
|
400
|
-
"Set-Cookie": cookieVal
|
|
401
|
-
}
|
|
402
|
-
});
|
|
638
|
+
return new Response(null, { status: 302, headers: { Location: "/profile" } });
|
|
403
639
|
}
|
|
404
640
|
|
|
405
641
|
if (url.pathname === "/api/auth/logout") {
|
|
406
|
-
let cookieVal = "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly";
|
|
407
642
|
if (options?.store && options?.secret) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (sessionId) {
|
|
413
|
-
await options.store.delete(sessionId);
|
|
414
|
-
}
|
|
643
|
+
const incoming = request.headers.get("cookie");
|
|
644
|
+
if (incoming) {
|
|
645
|
+
const sessionId = await readSignedCookie(incoming, "session", options.secret);
|
|
646
|
+
if (sessionId) await options.store.delete(sessionId);
|
|
415
647
|
}
|
|
416
648
|
}
|
|
649
|
+
const params = new URLSearchParams({
|
|
650
|
+
client_id: config.clientId,
|
|
651
|
+
returnTo: new URL(request.url).origin,
|
|
652
|
+
});
|
|
417
653
|
return new Response(null, {
|
|
418
654
|
status: 302,
|
|
419
655
|
headers: {
|
|
420
|
-
"
|
|
421
|
-
|
|
422
|
-
}
|
|
656
|
+
"Set-Cookie": "session=; Path=/; Max-Age=0; HttpOnly",
|
|
657
|
+
Location: \`https://\${config.domain}/oidc/logout?\${params}\`,
|
|
658
|
+
},
|
|
423
659
|
});
|
|
424
660
|
}
|
|
425
661
|
|
|
426
|
-
return new Response("
|
|
662
|
+
return new Response("Not found", { status: 404 });
|
|
427
663
|
};
|
|
428
664
|
`;
|
|
429
665
|
|
|
@@ -434,6 +670,9 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
|
|
|
434
670
|
const upToRoot = appRoot === "." ? "." : appRoot.split("/").map(() => "..").join("/");
|
|
435
671
|
const generatedPrefix = `${upToRoot}/generated/${appRoot === "." ? "" : appRoot}`.replace(/\/+$/, "");
|
|
436
672
|
|
|
673
|
+
const isBetterAuth = auth === "better-auth";
|
|
674
|
+
const isAuth0 = auth === "auth0";
|
|
675
|
+
|
|
437
676
|
const imports = [
|
|
438
677
|
`import { createWorkerApp } from "elm-ssr";`,
|
|
439
678
|
`import { renderApp, type CompiledElmModule } from "elm-ssr/render";`,
|
|
@@ -441,13 +680,16 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
|
|
|
441
680
|
`import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";`,
|
|
442
681
|
`import { stylesheet } from "./styles";`,
|
|
443
682
|
`// @ts-expect-error Generated at build time.`,
|
|
444
|
-
`import ElmRuntime from "${generatedPrefix}/app.mjs"
|
|
683
|
+
`import ElmRuntime from "${generatedPrefix}/app.mjs";`,
|
|
684
|
+
`import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`
|
|
445
685
|
];
|
|
446
686
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
|
|
687
|
+
if (isBetterAuth) {
|
|
688
|
+
imports.push(`import { sessionEffects } from "elm-ssr/sessions";`);
|
|
689
|
+
imports.push(`import { handleAuth, betterAuthMiddleware } from "./src/Endpoints/Auth";`);
|
|
690
|
+
} else if (isAuth0) {
|
|
450
691
|
imports.push(`import { memorySessionStore } from "elm-ssr/sessions";`);
|
|
692
|
+
imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
|
|
451
693
|
}
|
|
452
694
|
|
|
453
695
|
let dbInit = '';
|
|
@@ -492,8 +734,9 @@ if (typeof Bun !== "undefined") {
|
|
|
492
734
|
},`;
|
|
493
735
|
}
|
|
494
736
|
|
|
495
|
-
|
|
496
|
-
|
|
737
|
+
// BetterAuth: manually wrap effects with sessionEffects (no elm-ssr session cookie).
|
|
738
|
+
// Auth0 / plain: use the effects callback directly.
|
|
739
|
+
const baseEffectsBody = `(effect, context) => {
|
|
497
740
|
if (context.env) {
|
|
498
741
|
return cloudflareEffects(${db ? '{ dbBinding: "DB" }' : ''})(effect, context);
|
|
499
742
|
}
|
|
@@ -502,9 +745,14 @@ if (typeof Bun !== "undefined") {
|
|
|
502
745
|
})(effect, context);
|
|
503
746
|
}`;
|
|
504
747
|
|
|
748
|
+
const effectsConfig = isBetterAuth
|
|
749
|
+
? `,\n effects: sessionEffects(${baseEffectsBody}),\n middlewares: [betterAuthMiddleware]`
|
|
750
|
+
: `,\n effects: ${baseEffectsBody}`;
|
|
751
|
+
|
|
752
|
+
// Auth0 keeps elm-ssr session middleware; BetterAuth manages sessions itself.
|
|
505
753
|
let sessionsConfig = '';
|
|
506
754
|
let authInit = '';
|
|
507
|
-
if (
|
|
755
|
+
if (isAuth0) {
|
|
508
756
|
authInit = `\nexport const sessionStore = memorySessionStore();\n`;
|
|
509
757
|
sessionsConfig = `,
|
|
510
758
|
sessions: {
|
|
@@ -516,9 +764,21 @@ if (typeof Bun !== "undefined") {
|
|
|
516
764
|
}
|
|
517
765
|
|
|
518
766
|
let authIntercept = '';
|
|
519
|
-
if (
|
|
767
|
+
if (isBetterAuth) {
|
|
768
|
+
authIntercept = `
|
|
769
|
+
// Delegate all /api/auth/* requests to BetterAuth.
|
|
770
|
+
const baseWorkerFetch = worker.fetch;
|
|
771
|
+
worker.fetch = async (request, env, ctx) => {
|
|
772
|
+
const url = new URL(request.url);
|
|
773
|
+
if (url.pathname.startsWith("/api/auth/")) {
|
|
774
|
+
return handleAuth(request, env);
|
|
775
|
+
}
|
|
776
|
+
return baseWorkerFetch(request, env, ctx);
|
|
777
|
+
};
|
|
778
|
+
`;
|
|
779
|
+
} else if (isAuth0) {
|
|
520
780
|
authIntercept = `
|
|
521
|
-
//
|
|
781
|
+
// Delegate all /api/auth/* requests to the Auth0 handler.
|
|
522
782
|
const baseWorkerFetch = worker.fetch;
|
|
523
783
|
worker.fetch = async (request, env, ctx) => {
|
|
524
784
|
const url = new URL(request.url);
|
|
@@ -635,175 +895,337 @@ export default worker;
|
|
|
635
895
|
const stylesTemplate = () => `export const stylesheet = \`
|
|
636
896
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
|
637
897
|
|
|
638
|
-
*, *::before, *::after {
|
|
639
|
-
box-sizing: border-box;
|
|
640
|
-
}
|
|
898
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
641
899
|
|
|
642
900
|
:root {
|
|
643
|
-
|
|
901
|
+
--bg: #f6f5f3;
|
|
902
|
+
--surface: #ffffff;
|
|
903
|
+
--border: #e5e2dd;
|
|
904
|
+
--text: #1a1a1a;
|
|
905
|
+
--text-muted: #6b6b6b;
|
|
906
|
+
--accent: #1a1a1a;
|
|
907
|
+
--accent-hover: #333;
|
|
908
|
+
--radius: 12px;
|
|
909
|
+
--header-h: 60px;
|
|
644
910
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
645
911
|
font-size: 16px;
|
|
646
|
-
color:
|
|
912
|
+
color: var(--text);
|
|
913
|
+
background: var(--bg);
|
|
647
914
|
}
|
|
648
915
|
|
|
649
|
-
body {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
916
|
+
body { min-height: 100vh; }
|
|
917
|
+
|
|
918
|
+
/* ── Layout ────────────────────────────────────────── */
|
|
919
|
+
.page { display: flex; flex-direction: column; min-height: 100vh; }
|
|
920
|
+
|
|
921
|
+
.header {
|
|
922
|
+
height: var(--header-h);
|
|
923
|
+
border-bottom: 1px solid var(--border);
|
|
924
|
+
background: rgba(246, 245, 243, 0.85);
|
|
925
|
+
backdrop-filter: blur(12px);
|
|
926
|
+
position: sticky;
|
|
927
|
+
top: 0;
|
|
928
|
+
z-index: 10;
|
|
655
929
|
}
|
|
656
930
|
|
|
657
|
-
.
|
|
658
|
-
max-width:
|
|
931
|
+
.header-inner {
|
|
932
|
+
max-width: 1100px;
|
|
659
933
|
margin: 0 auto;
|
|
660
|
-
padding:
|
|
934
|
+
padding: 0 1.5rem;
|
|
935
|
+
height: 100%;
|
|
936
|
+
display: flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
justify-content: space-between;
|
|
661
939
|
}
|
|
662
940
|
|
|
663
|
-
|
|
664
|
-
|
|
941
|
+
.brand {
|
|
942
|
+
display: flex;
|
|
943
|
+
align-items: center;
|
|
944
|
+
gap: 0.5rem;
|
|
665
945
|
font-weight: 700;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
h2 {
|
|
672
|
-
font-size: 1.4rem;
|
|
673
|
-
font-weight: 600;
|
|
674
|
-
margin: 0 0 0.75rem;
|
|
675
|
-
color: #18222f;
|
|
946
|
+
font-size: 1rem;
|
|
947
|
+
color: var(--text);
|
|
948
|
+
text-decoration: none;
|
|
949
|
+
letter-spacing: -0.01em;
|
|
676
950
|
}
|
|
677
951
|
|
|
678
|
-
|
|
679
|
-
line-height: 1.6;
|
|
680
|
-
margin: 0 0 1rem;
|
|
681
|
-
color: #4a5568;
|
|
682
|
-
}
|
|
952
|
+
.brand-icon { font-size: 0.85em; opacity: 0.6; }
|
|
683
953
|
|
|
684
|
-
|
|
685
|
-
color: #18222f;
|
|
686
|
-
text-decoration: underline;
|
|
687
|
-
text-underline-offset: 2px;
|
|
688
|
-
}
|
|
954
|
+
.nav { display: flex; align-items: center; gap: 0.25rem; }
|
|
689
955
|
|
|
690
|
-
|
|
956
|
+
.nav-link {
|
|
957
|
+
padding: 0.4rem 0.75rem;
|
|
958
|
+
border-radius: 6px;
|
|
959
|
+
font-size: 0.875rem;
|
|
960
|
+
font-weight: 500;
|
|
961
|
+
color: var(--text-muted);
|
|
691
962
|
text-decoration: none;
|
|
963
|
+
transition: background 0.12s, color 0.12s;
|
|
692
964
|
}
|
|
965
|
+
.nav-link:hover { background: var(--border); color: var(--text); }
|
|
966
|
+
|
|
967
|
+
.main { flex: 1; padding: 3rem 1.5rem; }
|
|
968
|
+
|
|
969
|
+
.container { max-width: 1100px; margin: 0 auto; }
|
|
693
970
|
|
|
694
|
-
|
|
971
|
+
/* ── Typography ────────────────────────────────────── */
|
|
972
|
+
h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2; }
|
|
973
|
+
h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.01em; }
|
|
974
|
+
p { line-height: 1.65; color: var(--text-muted); }
|
|
975
|
+
|
|
976
|
+
/* ── Buttons ────────────────────────────────────────── */
|
|
977
|
+
.btn {
|
|
695
978
|
display: inline-flex;
|
|
696
979
|
align-items: center;
|
|
980
|
+
justify-content: center;
|
|
697
981
|
gap: 0.5rem;
|
|
698
|
-
|
|
699
|
-
border:
|
|
700
|
-
|
|
982
|
+
padding: 0.55rem 1.1rem;
|
|
983
|
+
border-radius: 8px;
|
|
984
|
+
border: 1.5px solid transparent;
|
|
701
985
|
font: inherit;
|
|
702
|
-
font-size: 0.
|
|
986
|
+
font-size: 0.875rem;
|
|
703
987
|
font-weight: 500;
|
|
704
|
-
background: white;
|
|
705
|
-
color: #18222f;
|
|
706
988
|
cursor: pointer;
|
|
707
989
|
text-decoration: none;
|
|
708
|
-
transition: background 0.
|
|
990
|
+
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
|
991
|
+
white-space: nowrap;
|
|
709
992
|
}
|
|
710
993
|
|
|
711
|
-
.
|
|
712
|
-
background:
|
|
994
|
+
.btn-primary {
|
|
995
|
+
background: var(--accent);
|
|
996
|
+
color: white;
|
|
997
|
+
border-color: var(--accent);
|
|
713
998
|
}
|
|
999
|
+
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
|
714
1000
|
|
|
715
|
-
.
|
|
716
|
-
background:
|
|
717
|
-
color:
|
|
718
|
-
border-color:
|
|
1001
|
+
.btn-secondary {
|
|
1002
|
+
background: var(--surface);
|
|
1003
|
+
color: var(--text);
|
|
1004
|
+
border-color: var(--border);
|
|
1005
|
+
}
|
|
1006
|
+
.btn-secondary:hover { background: var(--bg); }
|
|
1007
|
+
|
|
1008
|
+
.btn-square { width: 2.5rem; height: 2.5rem; padding: 0; font-size: 1.1rem; }
|
|
1009
|
+
|
|
1010
|
+
.btn-full { width: 100%; }
|
|
1011
|
+
|
|
1012
|
+
/* ── Cards ──────────────────────────────────────────── */
|
|
1013
|
+
.card {
|
|
1014
|
+
background: var(--surface);
|
|
1015
|
+
border: 1px solid var(--border);
|
|
1016
|
+
border-radius: var(--radius);
|
|
1017
|
+
padding: 1.5rem;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/* ── Hero ────────────────────────────────────────────── */
|
|
1021
|
+
.hero {
|
|
1022
|
+
text-align: center;
|
|
1023
|
+
padding: 5rem 0 4rem;
|
|
1024
|
+
max-width: 640px;
|
|
1025
|
+
margin: 0 auto;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.hero-title {
|
|
1029
|
+
font-size: clamp(2.5rem, 6vw, 4rem);
|
|
1030
|
+
font-weight: 800;
|
|
1031
|
+
letter-spacing: -0.04em;
|
|
1032
|
+
margin-bottom: 1rem;
|
|
719
1033
|
}
|
|
720
1034
|
|
|
721
|
-
.
|
|
722
|
-
|
|
1035
|
+
.hero-subtitle {
|
|
1036
|
+
font-size: 1.15rem;
|
|
1037
|
+
margin-bottom: 2rem;
|
|
1038
|
+
max-width: 480px;
|
|
1039
|
+
margin-left: auto;
|
|
1040
|
+
margin-right: auto;
|
|
723
1041
|
}
|
|
724
1042
|
|
|
1043
|
+
.hero-actions {
|
|
1044
|
+
display: flex;
|
|
1045
|
+
gap: 0.75rem;
|
|
1046
|
+
justify-content: center;
|
|
1047
|
+
flex-wrap: wrap;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/* ── Features ────────────────────────────────────────── */
|
|
1051
|
+
.features {
|
|
1052
|
+
display: grid;
|
|
1053
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
1054
|
+
gap: 1rem;
|
|
1055
|
+
margin-top: 4rem;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.feature-card {
|
|
1059
|
+
background: var(--surface);
|
|
1060
|
+
border: 1px solid var(--border);
|
|
1061
|
+
border-radius: var(--radius);
|
|
1062
|
+
padding: 1.5rem;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.feature-icon { font-size: 1.5rem; display: block; margin-bottom: 0.75rem; }
|
|
1066
|
+
|
|
1067
|
+
.feature-title {
|
|
1068
|
+
font-size: 1rem;
|
|
1069
|
+
font-weight: 600;
|
|
1070
|
+
margin-bottom: 0.5rem;
|
|
1071
|
+
color: var(--text);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.feature-body { font-size: 0.9rem; }
|
|
1075
|
+
|
|
1076
|
+
/* ── Page header ─────────────────────────────────────── */
|
|
1077
|
+
.page-header { margin-bottom: 2rem; }
|
|
1078
|
+
.page-header h1 { margin-bottom: 0.5rem; }
|
|
1079
|
+
.page-subtitle { font-size: 0.95rem; }
|
|
1080
|
+
|
|
1081
|
+
/* ── Counter island ──────────────────────────────────── */
|
|
725
1082
|
.counter {
|
|
726
1083
|
display: grid;
|
|
727
1084
|
grid-template-columns: auto 1fr auto;
|
|
728
|
-
gap: 0.75rem;
|
|
729
1085
|
align-items: center;
|
|
730
|
-
|
|
1086
|
+
gap: 1rem;
|
|
731
1087
|
}
|
|
732
1088
|
|
|
733
|
-
.value {
|
|
1089
|
+
.counter-value {
|
|
734
1090
|
text-align: center;
|
|
735
|
-
font-size:
|
|
1091
|
+
font-size: 3rem;
|
|
736
1092
|
font-weight: 700;
|
|
737
|
-
|
|
1093
|
+
letter-spacing: -0.03em;
|
|
738
1094
|
}
|
|
739
1095
|
|
|
740
|
-
|
|
1096
|
+
/* ── Auth pages ──────────────────────────────────────── */
|
|
1097
|
+
.auth-page {
|
|
741
1098
|
display: flex;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1099
|
+
align-items: flex-start;
|
|
1100
|
+
justify-content: center;
|
|
1101
|
+
padding-top: 3rem;
|
|
745
1102
|
}
|
|
746
1103
|
|
|
747
|
-
.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1104
|
+
.auth-card {
|
|
1105
|
+
background: var(--surface);
|
|
1106
|
+
border: 1px solid var(--border);
|
|
1107
|
+
border-radius: 16px;
|
|
1108
|
+
padding: 2.5rem;
|
|
1109
|
+
width: 100%;
|
|
1110
|
+
max-width: 380px;
|
|
751
1111
|
}
|
|
752
1112
|
|
|
753
|
-
.
|
|
754
|
-
.field span {
|
|
755
|
-
font-size: 0.875rem;
|
|
756
|
-
font-weight: 500;
|
|
757
|
-
color: #4a5568;
|
|
758
|
-
}
|
|
1113
|
+
.auth-header { text-align: center; margin-bottom: 2rem; }
|
|
759
1114
|
|
|
760
|
-
.
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
padding: 0.65rem 0.9rem;
|
|
765
|
-
font: inherit;
|
|
766
|
-
font-size: 0.95rem;
|
|
767
|
-
background: white;
|
|
768
|
-
color: #18222f;
|
|
769
|
-
transition: border-color 0.15s;
|
|
1115
|
+
.auth-logo {
|
|
1116
|
+
font-size: 1.5rem;
|
|
1117
|
+
display: block;
|
|
1118
|
+
margin-bottom: 1rem;
|
|
770
1119
|
}
|
|
771
1120
|
|
|
772
|
-
.
|
|
773
|
-
|
|
774
|
-
border-color: #18222f;
|
|
775
|
-
}
|
|
1121
|
+
.auth-title { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
1122
|
+
.auth-subtitle { font-size: 0.9rem; }
|
|
776
1123
|
|
|
777
|
-
.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1124
|
+
.auth-body { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
1125
|
+
|
|
1126
|
+
.auth-footer {
|
|
1127
|
+
text-align: center;
|
|
1128
|
+
font-size: 0.75rem;
|
|
1129
|
+
color: var(--text-muted);
|
|
1130
|
+
margin-top: 1.5rem;
|
|
784
1131
|
}
|
|
785
1132
|
|
|
786
|
-
.
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1133
|
+
.avatar {
|
|
1134
|
+
display: inline-flex;
|
|
1135
|
+
align-items: center;
|
|
1136
|
+
justify-content: center;
|
|
1137
|
+
width: 3.5rem;
|
|
1138
|
+
height: 3.5rem;
|
|
1139
|
+
border-radius: 50%;
|
|
1140
|
+
background: var(--accent);
|
|
1141
|
+
color: white;
|
|
1142
|
+
font-size: 1.5rem;
|
|
1143
|
+
font-weight: 700;
|
|
791
1144
|
margin-bottom: 1rem;
|
|
792
1145
|
}
|
|
793
1146
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1147
|
+
/* ── Error page ──────────────────────────────────────── */
|
|
1148
|
+
.error-page {
|
|
1149
|
+
text-align: center;
|
|
1150
|
+
padding: 5rem 0;
|
|
797
1151
|
}
|
|
798
1152
|
|
|
799
|
-
.error {
|
|
800
|
-
|
|
801
|
-
font-
|
|
802
|
-
|
|
1153
|
+
.error-code {
|
|
1154
|
+
font-size: 6rem;
|
|
1155
|
+
font-weight: 800;
|
|
1156
|
+
letter-spacing: -0.05em;
|
|
1157
|
+
color: var(--border);
|
|
1158
|
+
margin-bottom: 0.5rem;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
.error-message { margin-bottom: 2rem; }
|
|
1162
|
+
|
|
1163
|
+
/* ── Forms ───────────────────────────────────────────── */
|
|
1164
|
+
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
1165
|
+
.field label, .field span { font-size: 0.875rem; font-weight: 500; }
|
|
1166
|
+
|
|
1167
|
+
.input {
|
|
1168
|
+
width: 100%;
|
|
1169
|
+
border-radius: 8px;
|
|
1170
|
+
border: 1.5px solid var(--border);
|
|
1171
|
+
padding: 0.6rem 0.9rem;
|
|
1172
|
+
font: inherit;
|
|
1173
|
+
font-size: 0.9rem;
|
|
1174
|
+
background: white;
|
|
1175
|
+
color: var(--text);
|
|
1176
|
+
transition: border-color 0.12s;
|
|
803
1177
|
}
|
|
1178
|
+
.input:focus { outline: none; border-color: var(--accent); }
|
|
1179
|
+
|
|
1180
|
+
.error-hint { color: #c53030; font-size: 0.8rem; margin-top: 0.25rem; }
|
|
804
1181
|
\`;
|
|
805
1182
|
`;
|
|
806
1183
|
|
|
1184
|
+
const devVarsContent = (auth) => {
|
|
1185
|
+
if (auth === "better-auth") {
|
|
1186
|
+
return `GREETING="Hello from your local .dev.vars file!"
|
|
1187
|
+
BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1188
|
+
BETTER_AUTH_URL="http://localhost:8787"
|
|
1189
|
+
# Uncomment to enable social providers in src/Endpoints/Auth.ts:
|
|
1190
|
+
# GITHUB_CLIENT_ID="your-github-client-id"
|
|
1191
|
+
# GITHUB_CLIENT_SECRET="your-github-client-secret"
|
|
1192
|
+
`;
|
|
1193
|
+
}
|
|
1194
|
+
if (auth === "auth0") {
|
|
1195
|
+
return `GREETING="Hello from your local .dev.vars file!"
|
|
1196
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1197
|
+
AUTH0_DOMAIN="your-tenant.auth0.com"
|
|
1198
|
+
AUTH0_CLIENT_ID="your-client-id"
|
|
1199
|
+
AUTH0_CLIENT_SECRET="your-client-secret"
|
|
1200
|
+
AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
|
|
1201
|
+
`;
|
|
1202
|
+
}
|
|
1203
|
+
return `GREETING="Hello from your local .dev.vars file!"
|
|
1204
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1205
|
+
`;
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
const rootEnvContent = (auth) => {
|
|
1209
|
+
if (auth === "better-auth") {
|
|
1210
|
+
return `GREETING="Hello from your local .env file!"
|
|
1211
|
+
BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1212
|
+
BETTER_AUTH_URL="http://localhost:8787"
|
|
1213
|
+
`;
|
|
1214
|
+
}
|
|
1215
|
+
if (auth === "auth0") {
|
|
1216
|
+
return `GREETING="Hello from your local .env file!"
|
|
1217
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1218
|
+
AUTH0_DOMAIN="your-tenant.auth0.com"
|
|
1219
|
+
AUTH0_CLIENT_ID="your-client-id"
|
|
1220
|
+
AUTH0_CLIENT_SECRET="your-client-secret"
|
|
1221
|
+
AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
|
|
1222
|
+
`;
|
|
1223
|
+
}
|
|
1224
|
+
return `GREETING="Hello from your local .env file!"
|
|
1225
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1226
|
+
`;
|
|
1227
|
+
};
|
|
1228
|
+
|
|
807
1229
|
const filesForApp = (name, appRoot, options = {}) => {
|
|
808
1230
|
const namespace = toPascalCase(name);
|
|
809
1231
|
const db = options.db || !!options.auth;
|
|
@@ -815,29 +1237,34 @@ const filesForApp = (name, appRoot, options = {}) => {
|
|
|
815
1237
|
{ path: `${appRoot}/runtime.ts`, content: runtimeTemplate(appRoot, db, auth) },
|
|
816
1238
|
{ path: `${appRoot}/worker.ts`, content: workerTemplate() },
|
|
817
1239
|
{ path: `${appRoot}/styles.ts`, content: stylesTemplate() },
|
|
818
|
-
{ path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
|
|
819
|
-
{ path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
|
|
1240
|
+
{ path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace, auth) },
|
|
1241
|
+
{ path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace, auth) },
|
|
820
1242
|
{ path: `${appRoot}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
|
|
821
1243
|
{ path: `${appRoot}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
|
|
822
1244
|
{ path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) },
|
|
823
1245
|
{
|
|
824
1246
|
path: `${appRoot}/.dev.vars`,
|
|
825
|
-
content:
|
|
826
|
-
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
827
|
-
`
|
|
1247
|
+
content: devVarsContent(auth)
|
|
828
1248
|
}
|
|
829
1249
|
];
|
|
830
1250
|
|
|
831
1251
|
if (db) {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1252
|
+
let migrationContent;
|
|
1253
|
+
if (auth === "better-auth") {
|
|
1254
|
+
migrationContent = betterAuthMigrationTemplate();
|
|
1255
|
+
} else if (auth === "auth0") {
|
|
1256
|
+
migrationContent = auth0MigrationTemplate();
|
|
1257
|
+
} else {
|
|
1258
|
+
migrationContent = `CREATE TABLE IF NOT EXISTS users (
|
|
835
1259
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
836
1260
|
email TEXT NOT NULL UNIQUE,
|
|
837
1261
|
name TEXT,
|
|
838
1262
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
839
|
-
)
|
|
840
|
-
|
|
1263
|
+
);\n`;
|
|
1264
|
+
}
|
|
1265
|
+
files.push({
|
|
1266
|
+
path: `${appRoot}/migrations/0001_init.sql`,
|
|
1267
|
+
content: migrationContent
|
|
841
1268
|
});
|
|
842
1269
|
}
|
|
843
1270
|
|
|
@@ -852,7 +1279,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
|
|
|
852
1279
|
});
|
|
853
1280
|
files.push({
|
|
854
1281
|
path: `${appRoot}/src/Endpoints/Auth.ts`,
|
|
855
|
-
content:
|
|
1282
|
+
content: auth === "better-auth" ? betterAuthEndpointTemplate() : auth0EndpointTemplate()
|
|
856
1283
|
});
|
|
857
1284
|
}
|
|
858
1285
|
|
|
@@ -872,58 +1299,56 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
|
|
|
872
1299
|
}
|
|
873
1300
|
|
|
874
1301
|
@layer components {
|
|
875
|
-
.
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
.
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
.
|
|
886
|
-
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
.
|
|
895
|
-
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
.
|
|
899
|
-
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
.
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
.
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
.
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
.
|
|
917
|
-
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
.
|
|
921
|
-
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
.error {
|
|
925
|
-
@apply text-red-600 text-sm mt-1;
|
|
926
|
-
}
|
|
1302
|
+
.page { @apply flex flex-col min-h-screen; }
|
|
1303
|
+
.header { @apply sticky top-0 z-10 border-b border-gray-200 bg-white/80 backdrop-blur-md; }
|
|
1304
|
+
.header-inner { @apply max-w-6xl mx-auto px-6 h-16 flex items-center justify-between; }
|
|
1305
|
+
.brand { @apply flex items-center gap-2 font-bold text-gray-900 no-underline; }
|
|
1306
|
+
.nav { @apply flex items-center gap-1; }
|
|
1307
|
+
.nav-link { @apply px-3 py-1.5 rounded-md text-sm font-medium text-gray-500 no-underline hover:bg-gray-100 hover:text-gray-900 transition-colors; }
|
|
1308
|
+
.main { @apply flex-1 px-6 py-12; }
|
|
1309
|
+
.container { @apply max-w-6xl mx-auto; }
|
|
1310
|
+
.card { @apply bg-white border border-gray-200 rounded-xl p-6; }
|
|
1311
|
+
|
|
1312
|
+
.btn { @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium no-underline cursor-pointer transition-colors whitespace-nowrap; }
|
|
1313
|
+
.btn-primary { @apply bg-gray-900 text-white border-gray-900 hover:bg-gray-700; }
|
|
1314
|
+
.btn-secondary { @apply bg-white text-gray-900 border-gray-200 hover:bg-gray-50; }
|
|
1315
|
+
.btn-square { @apply w-10 h-10 p-0 text-lg; }
|
|
1316
|
+
.btn-full { @apply w-full; }
|
|
1317
|
+
|
|
1318
|
+
.hero { @apply text-center py-20 max-w-2xl mx-auto; }
|
|
1319
|
+
.hero-title { @apply text-6xl font-extrabold tracking-tight mb-4; }
|
|
1320
|
+
.hero-subtitle { @apply text-lg text-gray-500 mb-8 max-w-lg mx-auto; }
|
|
1321
|
+
.hero-actions { @apply flex gap-3 justify-center flex-wrap; }
|
|
1322
|
+
|
|
1323
|
+
.features { @apply grid gap-4 mt-16; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
|
1324
|
+
.feature-card { @apply bg-white border border-gray-200 rounded-xl p-6; }
|
|
1325
|
+
.feature-icon { @apply text-2xl block mb-3; }
|
|
1326
|
+
.feature-title { @apply text-base font-semibold mb-2; }
|
|
1327
|
+
.feature-body { @apply text-sm text-gray-500; }
|
|
1328
|
+
|
|
1329
|
+
.page-header { @apply mb-8; }
|
|
1330
|
+
.page-subtitle { @apply text-gray-500 mt-2; }
|
|
1331
|
+
|
|
1332
|
+
.counter { @apply grid items-center gap-4; grid-template-columns: auto 1fr auto; }
|
|
1333
|
+
.counter-value { @apply text-center text-5xl font-bold tracking-tight; }
|
|
1334
|
+
|
|
1335
|
+
.auth-page { @apply flex justify-center pt-12; }
|
|
1336
|
+
.auth-card { @apply bg-white border border-gray-200 rounded-2xl p-10 w-full max-w-sm; }
|
|
1337
|
+
.auth-header { @apply text-center mb-8; }
|
|
1338
|
+
.auth-logo { @apply text-2xl block mb-4; }
|
|
1339
|
+
.auth-title { @apply text-2xl font-bold tracking-tight mb-2; }
|
|
1340
|
+
.auth-subtitle { @apply text-sm text-gray-500; }
|
|
1341
|
+
.auth-body { @apply flex flex-col gap-3; }
|
|
1342
|
+
.auth-footer { @apply text-center text-xs text-gray-400 mt-6; }
|
|
1343
|
+
.avatar { @apply inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-900 text-white text-2xl font-bold mb-4; }
|
|
1344
|
+
|
|
1345
|
+
.error-page { @apply text-center py-20; }
|
|
1346
|
+
.error-code { @apply text-8xl font-extrabold text-gray-200 mb-2; }
|
|
1347
|
+
.error-message { @apply text-gray-500 mb-8; }
|
|
1348
|
+
|
|
1349
|
+
.field { @apply flex flex-col gap-1.5; }
|
|
1350
|
+
.input { @apply w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm bg-white focus:outline-none focus:border-gray-900 transition-colors; }
|
|
1351
|
+
.error-hint { @apply text-red-600 text-xs mt-1; }
|
|
927
1352
|
}
|
|
928
1353
|
`
|
|
929
1354
|
});
|
|
@@ -955,7 +1380,7 @@ const normalizeAppRoot = (rawRoot, name) => {
|
|
|
955
1380
|
return candidate;
|
|
956
1381
|
};
|
|
957
1382
|
|
|
958
|
-
const ensurePackageJson = async (rootPath, appName) => {
|
|
1383
|
+
const ensurePackageJson = async (rootPath, appName, options = {}) => {
|
|
959
1384
|
const packageJsonPath = resolve(rootPath, "package.json");
|
|
960
1385
|
let packageJson = {};
|
|
961
1386
|
try {
|
|
@@ -978,9 +1403,15 @@ const ensurePackageJson = async (rootPath, appName) => {
|
|
|
978
1403
|
...packageJson.scripts
|
|
979
1404
|
};
|
|
980
1405
|
|
|
1406
|
+
const extraDeps = {};
|
|
1407
|
+
if (options.auth === "better-auth") {
|
|
1408
|
+
extraDeps["better-auth"] = "latest";
|
|
1409
|
+
}
|
|
1410
|
+
|
|
981
1411
|
packageJson.devDependencies = {
|
|
982
1412
|
"elm-ssr": "latest",
|
|
983
1413
|
wrangler: "^4.0.0",
|
|
1414
|
+
...extraDeps,
|
|
984
1415
|
...packageJson.devDependencies
|
|
985
1416
|
};
|
|
986
1417
|
|
|
@@ -1026,9 +1457,7 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
|
|
|
1026
1457
|
} catch {}
|
|
1027
1458
|
|
|
1028
1459
|
if (!envExists) {
|
|
1029
|
-
await writeFile(envPath,
|
|
1030
|
-
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1031
|
-
`, "utf8");
|
|
1460
|
+
await writeFile(envPath, rootEnvContent(options.auth), "utf8");
|
|
1032
1461
|
}
|
|
1033
1462
|
|
|
1034
1463
|
await writeWorkspaceConfig(rootPath, {
|
|
@@ -1036,7 +1465,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
|
|
|
1036
1465
|
apps: [...config.apps, configEntry]
|
|
1037
1466
|
});
|
|
1038
1467
|
|
|
1039
|
-
await ensurePackageJson(rootPath, name);
|
|
1468
|
+
await ensurePackageJson(rootPath, name, options);
|
|
1040
1469
|
|
|
1041
1470
|
return configEntry;
|
|
1042
1471
|
};
|