@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,128 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { createFileRoute, Link } from "@tanstack/react-router";
3
+ import { useState } from "react";
4
+ import {
5
+ buttonClass,
6
+ DashboardShell,
7
+ linkClass,
8
+ mutedTextClass,
9
+ PageAlert,
10
+ } from "@/components/shells";
11
+ import * as m from "@/paraglide/messages";
12
+ import {
13
+ getDemoCheckoutMethods,
14
+ startDemoPurchase,
15
+ } from "@/server/demo-checkout";
16
+ import { cn } from "@/utils/cn";
17
+
18
+ const methodsQuery = {
19
+ queryKey: ["demo-checkout-methods"],
20
+ queryFn: () => getDemoCheckoutMethods(),
21
+ };
22
+
23
+ export const Route = createFileRoute("/dashboard/")({
24
+ component: DashboardOverview,
25
+ // The checkout panel renders this query during SSR. Await it so streamed
26
+ // query data cannot change its loading text before the client hydrates.
27
+ loader: ({ context }) => context.queryClient.ensureQueryData(methodsQuery),
28
+ });
29
+
30
+ function DashboardOverview() {
31
+ const { session } = Route.useRouteContext();
32
+ const u = session.user;
33
+ const [checkoutError, setCheckoutError] = useState<string | null>(null);
34
+
35
+ const methods = useQuery(methodsQuery);
36
+
37
+ const start = useMutation({
38
+ mutationFn: (provider: "stripe" | "creem" | "nowpayments") =>
39
+ startDemoPurchase({ data: { provider } }),
40
+ onSuccess: (r) => {
41
+ if (r.ok) {
42
+ window.location.href = r.url;
43
+ return;
44
+ }
45
+ setCheckoutError(
46
+ r.code === "disabled"
47
+ ? "That payment method is disabled."
48
+ : "That payment method is not configured.",
49
+ );
50
+ },
51
+ onError: () => setCheckoutError("Could not start checkout."),
52
+ });
53
+
54
+ const rails = methods.data
55
+ ? (["stripe", "creem", "nowpayments"] as const).filter(
56
+ (k) => methods.data[k],
57
+ )
58
+ : [];
59
+
60
+ return (
61
+ <DashboardShell title={m.dashboard_overview_title()}>
62
+ <section className="space-y-3 text-sm">
63
+ <p>
64
+ <span className="text-muted-foreground">
65
+ {m.dashboard_signed_in_as()}
66
+ </span>{" "}
67
+ <strong>{u.name}</strong> ({u.email})
68
+ </p>
69
+ <ul className="list-inside list-disc space-y-1 text-foreground">
70
+ <li>
71
+ {m.dashboard_email_verified()}:{" "}
72
+ {u.emailVerified ? m.yes() : m.dashboard_email_unverified()}
73
+ </li>
74
+ <li>
75
+ {m.dashboard_two_factor()}:{" "}
76
+ {u.twoFactorEnabled ? m.enabled() : m.off()}
77
+ </li>
78
+ <li>
79
+ {m.dashboard_role()}: {u.role ?? "user"}
80
+ </li>
81
+ </ul>
82
+ <p className="pt-4">
83
+ {m.dashboard_manage_account()}{" "}
84
+ <Link to="/dashboard/account" className={linkClass}>
85
+ {m.nav_account()}
86
+ </Link>
87
+ .
88
+ </p>
89
+ {u.role === "admin" || u.role === "superadmin" ? (
90
+ <p>
91
+ <Link to="/admin/users" className={linkClass}>
92
+ {m.dashboard_open_admin()}
93
+ </Link>
94
+ </p>
95
+ ) : null}
96
+ </section>
97
+
98
+ <section className="mt-10 space-y-3 border-t border-border pt-8 text-sm">
99
+ <h2 className="text-lg font-medium">{m.dashboard_demo_checkout()}</h2>
100
+ <p className={mutedTextClass}>{m.dashboard_demo_checkout_blurb()}</p>
101
+ <p className={mutedTextClass}>{m.dashboard_demo_amount()}</p>
102
+ {checkoutError ? <PageAlert>{checkoutError}</PageAlert> : null}
103
+ {methods.isLoading ? (
104
+ <p className={mutedTextClass}>…</p>
105
+ ) : rails.length === 0 ? (
106
+ <p className={mutedTextClass}>{m.dashboard_no_rails()}</p>
107
+ ) : (
108
+ <div className="flex flex-wrap gap-2">
109
+ {rails.map((provider) => (
110
+ <button
111
+ key={provider}
112
+ type="button"
113
+ className={cn(buttonClass, "max-w-xs")}
114
+ disabled={start.isPending}
115
+ onClick={() => {
116
+ setCheckoutError(null);
117
+ start.mutate(provider);
118
+ }}
119
+ >
120
+ {m.dashboard_pay_with({ provider })}
121
+ </button>
122
+ ))}
123
+ </div>
124
+ )}
125
+ </section>
126
+ </DashboardShell>
127
+ );
128
+ }
@@ -0,0 +1,17 @@
1
+ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
2
+
3
+ export const Route = createFileRoute("/dashboard")({
4
+ // `session` comes from the root route's beforeLoad (one read per request);
5
+ // re-returning it only narrows it to signed-in for the subtree below.
6
+ beforeLoad: ({ context: { session } }) => {
7
+ if (!session?.user) {
8
+ throw redirect({ to: "/sign-in" });
9
+ }
10
+ return { session };
11
+ },
12
+ // Private area — keep the whole dashboard subtree out of search indexes.
13
+ head: () => ({
14
+ meta: [{ name: "robots", content: "noindex, nofollow" }],
15
+ }),
16
+ component: () => <Outlet />,
17
+ });
@@ -0,0 +1,65 @@
1
+ import { createFileRoute, Link } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import {
4
+ AuthShell,
5
+ buttonClass,
6
+ Field,
7
+ inputClass,
8
+ linkClass,
9
+ PageAlert,
10
+ } from "@/components/shells";
11
+ import { authClient } from "@/lib/auth-client";
12
+ import { NOINDEX_META, pageTitle } from "@/lib/site";
13
+ import * as m from "@/paraglide/messages";
14
+
15
+ export const Route = createFileRoute("/forgot-password")({
16
+ head: () => ({
17
+ meta: [...NOINDEX_META, { title: pageTitle(m.auth_forgot_title()) }],
18
+ }),
19
+ component: ForgotPasswordPage,
20
+ });
21
+
22
+ function ForgotPasswordPage() {
23
+ const [email, setEmail] = useState("");
24
+ const [message, setMessage] = useState<string | null>(null);
25
+ const [pending, setPending] = useState(false);
26
+
27
+ async function onSubmit(e: React.FormEvent) {
28
+ e.preventDefault();
29
+ setPending(true);
30
+ setMessage(null);
31
+ await authClient.requestPasswordReset({
32
+ email,
33
+ redirectTo: `${window.location.origin}/reset-password`,
34
+ });
35
+ setPending(false);
36
+ // Same copy whether or not the address exists.
37
+ setMessage(m.auth_reset_link_sent());
38
+ }
39
+
40
+ return (
41
+ <AuthShell title={m.auth_forgot_title()}>
42
+ <form onSubmit={onSubmit}>
43
+ <Field label={m.field_email()}>
44
+ <input
45
+ className={inputClass}
46
+ type="email"
47
+ autoComplete="email"
48
+ required
49
+ value={email}
50
+ onChange={(ev) => setEmail(ev.target.value)}
51
+ />
52
+ </Field>
53
+ {message ? <PageAlert tone="info">{message}</PageAlert> : null}
54
+ <button type="submit" className={buttonClass} disabled={pending}>
55
+ {pending ? m.auth_sending() : m.auth_send_reset_link()}
56
+ </button>
57
+ </form>
58
+ <p className="mt-4 text-center text-sm">
59
+ <Link to="/sign-in" className={linkClass}>
60
+ {m.auth_back_to_sign_in()}
61
+ </Link>
62
+ </p>
63
+ </AuthShell>
64
+ );
65
+ }
@@ -0,0 +1,85 @@
1
+ import { createFileRoute, Link } from "@tanstack/react-router";
2
+ import { LocaleSwitcher } from "@/components/LocaleSwitcher";
3
+ import { ThemeToggle } from "@/components/ThemeToggle";
4
+ import {
5
+ buttonPrimaryClass,
6
+ buttonSecondaryClass,
7
+ linkClass,
8
+ mutedTextClass,
9
+ } from "@/components/ui/styles";
10
+ import { DEFAULT_DESCRIPTION, DEFAULT_TITLE, SITE_NAME } from "@/lib/site";
11
+ import * as m from "@/paraglide/messages";
12
+ import { cn } from "@/utils/cn";
13
+
14
+ export const Route = createFileRoute("/")({
15
+ head: () => ({
16
+ meta: [
17
+ { title: DEFAULT_TITLE },
18
+ { name: "description", content: DEFAULT_DESCRIPTION },
19
+ { property: "og:title", content: DEFAULT_TITLE },
20
+ { property: "og:description", content: DEFAULT_DESCRIPTION },
21
+ { name: "twitter:title", content: DEFAULT_TITLE },
22
+ { name: "twitter:description", content: DEFAULT_DESCRIPTION },
23
+ { name: "application-name", content: SITE_NAME },
24
+ ],
25
+ }),
26
+ component: Home,
27
+ });
28
+
29
+ function Home() {
30
+ return (
31
+ <div className="page" data-testid="home-page">
32
+ <header className="mb-10 flex flex-wrap items-center justify-between gap-3">
33
+ <p className="text-sm text-muted-foreground" data-testid="home-eyebrow">
34
+ {m.app_name()}
35
+ </p>
36
+ <div className="flex flex-wrap items-center gap-3">
37
+ <LocaleSwitcher />
38
+ <ThemeToggle />
39
+ </div>
40
+ </header>
41
+
42
+ <section data-testid="home-hero">
43
+ <h1
44
+ className="text-2xl font-medium tracking-tight text-foreground sm:text-3xl"
45
+ data-testid="home-title"
46
+ >
47
+ {m.home_tagline()}
48
+ </h1>
49
+ <p
50
+ className={cn(mutedTextClass, "mt-3 max-w-prose leading-relaxed")}
51
+ data-testid="home-blurb"
52
+ >
53
+ {m.home_blurb()}
54
+ </p>
55
+
56
+ <nav
57
+ className="mt-8 flex flex-wrap items-center gap-3"
58
+ aria-label={m.nav_primary()}
59
+ data-testid="home-nav"
60
+ >
61
+ <Link
62
+ to="/sign-in"
63
+ className={buttonPrimaryClass}
64
+ data-testid="nav-sign-in"
65
+ >
66
+ {m.nav_sign_in()}
67
+ </Link>
68
+ <Link
69
+ to="/sign-up"
70
+ className={buttonSecondaryClass}
71
+ data-testid="nav-sign-up"
72
+ >
73
+ {m.nav_sign_up()}
74
+ </Link>
75
+ <Link to="/dashboard" className={linkClass}>
76
+ {m.nav_dashboard()}
77
+ </Link>
78
+ <Link to="/admin/users" className={linkClass}>
79
+ {m.nav_admin()}
80
+ </Link>
81
+ </nav>
82
+ </section>
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,77 @@
1
+ import { createFileRoute, Link } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import {
4
+ AuthShell,
5
+ buttonClass,
6
+ buttonSecondaryClass,
7
+ linkClass,
8
+ mutedTextClass,
9
+ PageAlert,
10
+ } from "@/components/shells";
11
+ import { authClient } from "@/lib/auth-client";
12
+ import { NOINDEX_META, pageTitle } from "@/lib/site";
13
+ import * as m from "@/paraglide/messages";
14
+ import { cn } from "@/utils/cn";
15
+
16
+ export const Route = createFileRoute("/oauth/consent")({
17
+ // Keep the signed OAuth query intact for consent.
18
+ validateSearch: (search: Record<string, unknown>) => search,
19
+ head: () => ({
20
+ meta: [...NOINDEX_META, { title: pageTitle(m.auth_consent_title()) }],
21
+ }),
22
+ component: ConsentPage,
23
+ });
24
+
25
+ function ConsentPage() {
26
+ const [error, setError] = useState<string | null>(null);
27
+ const [pending, setPending] = useState(false);
28
+
29
+ async function decide(accept: boolean) {
30
+ setPending(true);
31
+ setError(null);
32
+ // Better Auth requires the signed query as oauth_query.
33
+ const oauth_query = window.location.search.replace(/^\?/, "");
34
+ try {
35
+ // Plugin requires the signed query forwarded as oauth_query.
36
+ await (
37
+ authClient.oauth2.consent as (body: {
38
+ accept: boolean;
39
+ oauth_query: string;
40
+ }) => Promise<unknown>
41
+ )({ accept, oauth_query });
42
+ } catch (e) {
43
+ setError(e instanceof Error ? e.message : m.auth_consent_failed());
44
+ setPending(false);
45
+ }
46
+ }
47
+
48
+ return (
49
+ <AuthShell title={m.auth_consent_title()}>
50
+ <p className={cn(mutedTextClass, "mb-4")}>{m.auth_consent_blurb()}</p>
51
+ {error ? <PageAlert>{error}</PageAlert> : null}
52
+ <div className="flex flex-col gap-2">
53
+ <button
54
+ type="button"
55
+ className={buttonClass}
56
+ disabled={pending}
57
+ onClick={() => void decide(true)}
58
+ >
59
+ {m.auth_consent_approve()}
60
+ </button>
61
+ <button
62
+ type="button"
63
+ className={cn(buttonSecondaryClass, "w-full")}
64
+ disabled={pending}
65
+ onClick={() => void decide(false)}
66
+ >
67
+ {m.auth_consent_deny()}
68
+ </button>
69
+ </div>
70
+ <p className="mt-4 text-center text-sm">
71
+ <Link to="/dashboard" className={linkClass}>
72
+ {m.auth_back_to_dashboard()}
73
+ </Link>
74
+ </p>
75
+ </AuthShell>
76
+ );
77
+ }
@@ -0,0 +1,78 @@
1
+ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import {
4
+ AuthShell,
5
+ buttonClass,
6
+ Field,
7
+ inputClass,
8
+ linkClass,
9
+ PageAlert,
10
+ } from "@/components/shells";
11
+ import { authClient } from "@/lib/auth-client";
12
+ import { NOINDEX_META, pageTitle } from "@/lib/site";
13
+ import * as m from "@/paraglide/messages";
14
+
15
+ export const Route = createFileRoute("/reset-password")({
16
+ validateSearch: (search: Record<string, unknown>) => ({
17
+ token: typeof search.token === "string" ? search.token : "",
18
+ }),
19
+ head: () => ({
20
+ meta: [...NOINDEX_META, { title: pageTitle(m.auth_reset_title()) }],
21
+ }),
22
+ component: ResetPasswordPage,
23
+ });
24
+
25
+ function ResetPasswordPage() {
26
+ const { token } = Route.useSearch();
27
+ const navigate = useNavigate();
28
+ const [password, setPassword] = useState("");
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [pending, setPending] = useState(false);
31
+
32
+ async function onSubmit(e: React.FormEvent) {
33
+ e.preventDefault();
34
+ if (!token) {
35
+ setError(m.auth_missing_reset_token());
36
+ return;
37
+ }
38
+ setPending(true);
39
+ setError(null);
40
+ const { error: err } = await authClient.resetPassword({
41
+ newPassword: password,
42
+ token,
43
+ });
44
+ setPending(false);
45
+ if (err) {
46
+ setError(err.message ?? m.auth_reset_failed());
47
+ return;
48
+ }
49
+ void navigate({ to: "/sign-in" });
50
+ }
51
+
52
+ return (
53
+ <AuthShell title={m.auth_reset_title()}>
54
+ <form onSubmit={onSubmit}>
55
+ <Field label={m.field_new_password()} hint={m.auth_password_hint()}>
56
+ <input
57
+ className={inputClass}
58
+ type="password"
59
+ autoComplete="new-password"
60
+ required
61
+ minLength={8}
62
+ value={password}
63
+ onChange={(ev) => setPassword(ev.target.value)}
64
+ />
65
+ </Field>
66
+ {error ? <PageAlert>{error}</PageAlert> : null}
67
+ <button type="submit" className={buttonClass} disabled={pending}>
68
+ {pending ? m.auth_saving() : m.auth_update_password()}
69
+ </button>
70
+ </form>
71
+ <p className="mt-4 text-center text-sm">
72
+ <Link to="/sign-in" className={linkClass}>
73
+ {m.auth_back_to_sign_in()}
74
+ </Link>
75
+ </p>
76
+ </AuthShell>
77
+ );
78
+ }
@@ -0,0 +1,174 @@
1
+ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import {
4
+ AuthDivider,
5
+ GithubButton,
6
+ PasskeyButton,
7
+ } from "@/components/auth/social";
8
+ import { Turnstile, type TurnstileHandle } from "@/components/auth/turnstile";
9
+ import {
10
+ AuthShell,
11
+ buttonClass,
12
+ Field,
13
+ inputClass,
14
+ linkClass,
15
+ mutedTextClass,
16
+ PageAlert,
17
+ } from "@/components/shells";
18
+ import { authClient } from "@/lib/auth-client";
19
+ import { CAPTCHA_ENABLED } from "@/lib/captcha";
20
+ import { GITHUB_OAUTH_ENABLED } from "@/lib/github";
21
+ import { NOINDEX_META, pageTitle } from "@/lib/site";
22
+ import * as m from "@/paraglide/messages";
23
+ import { afterAuthTarget, localizedAuthPath } from "@/utils/auth-redirect";
24
+
25
+ export const Route = createFileRoute("/sign-in")({
26
+ // Pass-through search so signed OAuth queries survive login.
27
+ validateSearch: (search: Record<string, unknown>) => search,
28
+ beforeLoad: ({ context: { session } }) => {
29
+ if (session?.user) {
30
+ throw redirect({ to: "/dashboard" });
31
+ }
32
+ },
33
+ head: () => ({
34
+ meta: [...NOINDEX_META, { title: pageTitle(m.auth_sign_in_title()) }],
35
+ }),
36
+ component: SignInPage,
37
+ });
38
+
39
+ function afterAuth(): void {
40
+ if (typeof window === "undefined") return;
41
+ window.location.href = afterAuthTarget();
42
+ }
43
+
44
+ function SignInPage() {
45
+ const [email, setEmail] = useState("");
46
+ const [password, setPassword] = useState("");
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [pending, setPending] = useState(false);
49
+ const [captchaToken, setCaptchaToken] = useState<string | null>(null);
50
+ const turnstile = useRef<TurnstileHandle>(null);
51
+
52
+ // Passkey conditional UI: browser may offer a saved passkey from autofill.
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ const check =
56
+ window.PublicKeyCredential?.isConditionalMediationAvailable?.();
57
+ if (!check) return;
58
+ void check.then((available) => {
59
+ if (!available || cancelled) return;
60
+ void authClient.signIn.passkey({ autoFill: true }).then((result) => {
61
+ if (!cancelled && !result.error) afterAuth();
62
+ });
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, []);
68
+
69
+ async function onSubmit(e: React.FormEvent) {
70
+ e.preventDefault();
71
+ setPending(true);
72
+ setError(null);
73
+ if (CAPTCHA_ENABLED && !captchaToken) {
74
+ setError(m.auth_captcha_required());
75
+ setPending(false);
76
+ return;
77
+ }
78
+ const { error: err } = await authClient.signIn.email({
79
+ email,
80
+ password,
81
+ // Locale-aware so verification / error returns stay on the same language URL.
82
+ callbackURL: localizedAuthPath("/sign-in"),
83
+ fetchOptions: {
84
+ headers: { "x-captcha-response": captchaToken ?? "" },
85
+ },
86
+ });
87
+ turnstile.current?.reset();
88
+ setPending(false);
89
+ if (err) {
90
+ setError(err.message ?? m.auth_sign_in_failed());
91
+ return;
92
+ }
93
+ afterAuth();
94
+ }
95
+
96
+ return (
97
+ <AuthShell title={m.auth_sign_in_title()}>
98
+ <form onSubmit={onSubmit}>
99
+ <Field label={m.field_email()}>
100
+ <input
101
+ className={inputClass}
102
+ type="email"
103
+ autoComplete="username webauthn"
104
+ required
105
+ value={email}
106
+ onChange={(ev) => setEmail(ev.target.value)}
107
+ />
108
+ </Field>
109
+ <Field label={m.field_password()}>
110
+ <input
111
+ className={inputClass}
112
+ type="password"
113
+ autoComplete="current-password"
114
+ required
115
+ value={password}
116
+ onChange={(ev) => setPassword(ev.target.value)}
117
+ />
118
+ </Field>
119
+ <Turnstile ref={turnstile} onToken={setCaptchaToken} />
120
+ {error ? <PageAlert>{error}</PageAlert> : null}
121
+ <button
122
+ type="submit"
123
+ className={buttonClass}
124
+ disabled={pending || (CAPTCHA_ENABLED && !captchaToken)}
125
+ >
126
+ {pending ? m.auth_signing_in() : m.nav_sign_in()}
127
+ </button>
128
+ </form>
129
+
130
+ <AuthDivider />
131
+ <div className="mt-4 flex flex-col gap-2">
132
+ {GITHUB_OAUTH_ENABLED ? (
133
+ <GithubButton label={m.auth_continue_github()} />
134
+ ) : null}
135
+ <PasskeyButton
136
+ label={m.auth_use_passkey()}
137
+ disabled={pending}
138
+ onClick={() => {
139
+ setPending(true);
140
+ setError(null);
141
+ void authClient.signIn
142
+ .passkey()
143
+ .then(({ error: err }) => {
144
+ if (err) {
145
+ setError(err.message ?? m.auth_passkey_failed());
146
+ return;
147
+ }
148
+ afterAuth();
149
+ })
150
+ .catch((err) => {
151
+ setError(
152
+ err instanceof Error ? err.message : m.auth_passkey_failed(),
153
+ );
154
+ })
155
+ .finally(() => {
156
+ setPending(false);
157
+ });
158
+ }}
159
+ />
160
+ </div>
161
+
162
+ <p className={`mt-4 text-center ${mutedTextClass}`}>
163
+ {m.auth_no_account()}{" "}
164
+ <Link to="/sign-up" className={linkClass}>
165
+ {m.nav_sign_up()}
166
+ </Link>
167
+ {" · "}
168
+ <Link to="/forgot-password" className={linkClass}>
169
+ {m.auth_forgot_title()}
170
+ </Link>
171
+ </p>
172
+ </AuthShell>
173
+ );
174
+ }