@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,15 @@
1
+ import { drizzle } from "drizzle-orm/node-postgres";
2
+ import { Pool } from "pg";
3
+ import * as schema from "@/db/schema";
4
+
5
+ export function createDB(connection: Hyperdrive) {
6
+ const client = new Pool({
7
+ connectionString: connection.connectionString,
8
+ });
9
+
10
+ return drizzle(client, { schema });
11
+ }
12
+
13
+ export type Database = ReturnType<typeof createDB>;
14
+ export type DatabaseTxCallback = Parameters<Database["transaction"]>[0];
15
+ export type DatabaseTx = Parameters<DatabaseTxCallback>[0];
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Platform tables owned by the Starter (not Better Auth generated).
3
+ * Purchase, audit log, system_config — product domain tables stay out.
4
+ */
5
+ import { sql } from "drizzle-orm";
6
+ import {
7
+ index,
8
+ jsonb,
9
+ pgPolicy,
10
+ pgRole,
11
+ pgTable,
12
+ text,
13
+ timestamp,
14
+ uniqueIndex,
15
+ } from "drizzle-orm/pg-core";
16
+ import { user } from "./auth.schema";
17
+
18
+ /** App role used by Hyperdrive connections (RLS policies target this). */
19
+ export const appRole = pgRole("starter_app", { createRole: false }).existing();
20
+
21
+ export type JsonValue =
22
+ | string
23
+ | number
24
+ | boolean
25
+ | null
26
+ | { [key: string]: JsonValue }
27
+ | JsonValue[];
28
+
29
+ export const systemConfig = pgTable(
30
+ "system_config",
31
+ {
32
+ key: text("key").primaryKey(),
33
+ value: jsonb("value").$type<JsonValue>().notNull(),
34
+ updatedAt: timestamp("updated_at", { withTimezone: true })
35
+ .defaultNow()
36
+ .$onUpdate(() => new Date())
37
+ .notNull(),
38
+ },
39
+ () => [
40
+ pgPolicy("rls_policy", {
41
+ as: "permissive",
42
+ to: appRole,
43
+ for: "all",
44
+ using: sql`app_private.is_admin()`,
45
+ withCheck: sql`app_private.is_admin()`,
46
+ }),
47
+ ],
48
+ );
49
+
50
+ export const adminAction = pgTable(
51
+ "admin_action",
52
+ {
53
+ id: text("id").primaryKey(),
54
+ actorId: text("actor_id").references(() => user.id, {
55
+ onDelete: "set null",
56
+ }),
57
+ action: text("action").notNull(),
58
+ targetType: text("target_type"),
59
+ targetId: text("target_id"),
60
+ detail: jsonb("detail").$type<JsonValue>(),
61
+ createdAt: timestamp("created_at", { withTimezone: true })
62
+ .defaultNow()
63
+ .notNull(),
64
+ },
65
+ (t) => [
66
+ pgPolicy("rls_policy", {
67
+ as: "permissive",
68
+ to: appRole,
69
+ for: "all",
70
+ using: sql`app_private.is_admin()`,
71
+ withCheck: sql`app_private.is_admin()`,
72
+ }),
73
+ index("admin_action_created_idx").on(t.createdAt.desc()),
74
+ ],
75
+ );
76
+
77
+ export const purchase = pgTable(
78
+ "purchase",
79
+ {
80
+ id: text("id").primaryKey(),
81
+ userId: text("user_id")
82
+ .notNull()
83
+ .references(() => user.id, { onDelete: "cascade" }),
84
+ provider: text("provider").notNull(), // stripe | creem | nowpayments
85
+ externalId: text("external_id").notNull(),
86
+ // Minor units as decimal text (e.g. "1999" cents). Avoid float JSON;
87
+ // adapters parse with parseInt, not Number(), for safe integers.
88
+ amount: text("amount").notNull(),
89
+ currency: text("currency").notNull().default("usd"),
90
+ status: text("status").notNull().default("pending"), // pending | paid | failed
91
+ metadata: jsonb("metadata").$type<JsonValue>(),
92
+ paidAt: timestamp("paid_at", { withTimezone: true }),
93
+ createdAt: timestamp("created_at", { withTimezone: true })
94
+ .defaultNow()
95
+ .notNull(),
96
+ updatedAt: timestamp("updated_at", { withTimezone: true })
97
+ .defaultNow()
98
+ .$onUpdate(() => new Date())
99
+ .notNull(),
100
+ },
101
+ (t) => [
102
+ uniqueIndex("purchase_provider_external_uq").on(t.provider, t.externalId),
103
+ index("purchase_userId_idx").on(t.userId),
104
+ pgPolicy("rls_user_read", {
105
+ as: "permissive",
106
+ to: appRole,
107
+ for: "select",
108
+ using: sql`app_private.is_admin() OR user_id = app_private.current_user_id()`,
109
+ }),
110
+ pgPolicy("rls_service_write", {
111
+ as: "permissive",
112
+ to: appRole,
113
+ for: "all",
114
+ using: sql`app_private.is_admin()`,
115
+ withCheck: sql`app_private.is_admin()`,
116
+ }),
117
+ ],
118
+ );
119
+
120
+ export type SystemConfigRow = typeof systemConfig.$inferSelect;
121
+ export type AdminActionRow = typeof adminAction.$inferSelect;
122
+ export type PurchaseRow = typeof purchase.$inferSelect;
@@ -0,0 +1,2 @@
1
+ export * from "./auth.schema";
2
+ export * from "./platform.schema";
@@ -0,0 +1,65 @@
1
+ import { createEnv } from "@t3-oss/env-core";
2
+ import { z } from "zod";
3
+
4
+ function createAppEnv() {
5
+ return createEnv({
6
+ server: {
7
+ APP_ORIGIN: z.url(),
8
+ SERVER_URL: z.url().optional(),
9
+ BETTER_AUTH_URL: z.url(),
10
+ BETTER_AUTH_SECRET: z.string().min(1),
11
+ // Comma-separated OAuth resource audiences; empty defaults to APP_ORIGIN.
12
+ BETTER_AUTH_AUDIENCES: z.string().optional(),
13
+ TURNSTILE_SECRET_KEY: z.string().optional(),
14
+ EMAIL_FROM: z.string().min(1),
15
+ /** Inbox From display name — clone-time brand string. */
16
+ EMAIL_FROM_NAME: z.string().min(1).default("Starter"),
17
+
18
+ GITHUB_CLIENT_ID: z.string().optional(),
19
+ GITHUB_CLIENT_SECRET: z.string().optional(),
20
+
21
+ // Optional payment adapters — absence means inactive.
22
+ CREEM_API_KEY: z.string().optional(),
23
+ CREEM_WEBHOOK_SECRET: z.string().optional(),
24
+ CREEM_PRODUCT_ID: z.string().optional(),
25
+ CREEM_TEST_MODE: z.stringbool().default(false),
26
+
27
+ STRIPE_SECRET_KEY: z.string().optional(),
28
+ STRIPE_WEBHOOK_SECRET: z.string().optional(),
29
+
30
+ NOW_PAYMENTS_API_KEY: z.string().optional(),
31
+ NOW_PAYMENTS_IPN_KEY: z.string().optional(),
32
+ NOW_PAYMENTS_TEST_MODE: z.stringbool().default(false),
33
+ },
34
+
35
+ clientPrefix: "VITE_",
36
+
37
+ client: {
38
+ VITE_APP_TITLE: z.string().min(1).optional(),
39
+ VITE_APP_ORIGIN: z.url().optional(),
40
+ VITE_TURNSTILE_SITE_KEY: z.string().min(1).optional(),
41
+ // Public GitHub OAuth client id (same value as GITHUB_CLIENT_ID).
42
+ // When set with GITHUB_CLIENT_SECRET, the provider is enabled end-to-end.
43
+ VITE_GITHUB_CLIENT_ID: z.string().min(1).optional(),
44
+ },
45
+
46
+ runtimeEnv: process.env,
47
+ emptyStringAsUndefined: true,
48
+ });
49
+ }
50
+
51
+ type AppEnv = ReturnType<typeof createAppEnv>;
52
+
53
+ let appEnv: AppEnv | undefined;
54
+
55
+ export function getEnv() {
56
+ if (!appEnv) appEnv = createAppEnv();
57
+ return appEnv;
58
+ }
59
+
60
+ /** Lazy proxy so importing modules does not force env validation at load time in tests. */
61
+ export const env = new Proxy({} as AppEnv, {
62
+ get(_target, prop, receiver) {
63
+ return Reflect.get(getEnv(), prop, receiver);
64
+ },
65
+ });
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ applyTheme,
4
+ readThemePreference,
5
+ type ThemePreference,
6
+ } from "@/lib/theme";
7
+
8
+ /** Theme behavior shared by the supplied control and custom presentations. */
9
+ export function useTheme() {
10
+ const [preference, setPreference] = useState<ThemePreference>("system");
11
+ const [mounted, setMounted] = useState(false);
12
+
13
+ useEffect(() => {
14
+ setPreference(readThemePreference());
15
+ setMounted(true);
16
+ }, []);
17
+
18
+ useEffect(() => {
19
+ if (!mounted || preference !== "system") return;
20
+ const query = window.matchMedia("(prefers-color-scheme: dark)");
21
+ const onChange = () => applyTheme("system");
22
+ query.addEventListener("change", onChange);
23
+ return () => query.removeEventListener("change", onChange);
24
+ }, [mounted, preference]);
25
+
26
+ function setTheme(next: ThemePreference) {
27
+ setPreference(next);
28
+ applyTheme(next);
29
+ }
30
+
31
+ return { preference, setTheme, mounted };
32
+ }
@@ -0,0 +1,36 @@
1
+ import { authClient } from "@/lib/auth-client";
2
+
3
+ export default function BetterAuthHeader() {
4
+ const { data: session, isPending } = authClient.useSession();
5
+
6
+ if (isPending) {
7
+ return <div className="h-8 w-8 bg-muted animate-pulse" />;
8
+ }
9
+
10
+ if (session?.user) {
11
+ return (
12
+ <div className="flex items-center gap-2">
13
+ {session.user.image ? (
14
+ <img src={session.user.image} alt="" className="h-8 w-8" />
15
+ ) : (
16
+ <div className="h-8 w-8 bg-muted flex items-center justify-center">
17
+ <span className="text-xs font-medium text-muted-foreground">
18
+ {session.user.name?.charAt(0).toUpperCase() || "U"}
19
+ </span>
20
+ </div>
21
+ )}
22
+ <button
23
+ type="button"
24
+ onClick={() => {
25
+ void authClient.signOut();
26
+ }}
27
+ className="flex-1 h-9 px-4 text-sm font-medium bg-background text-muted-foreground border border-border hover:bg-muted dark:hover:bg-muted transition-colors"
28
+ >
29
+ Sign out
30
+ </button>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return null;
36
+ }
@@ -0,0 +1,11 @@
1
+ import { queryOptions } from "@tanstack/react-query";
2
+ import { getSession } from "@/server/auth";
3
+
4
+ /** Shared by root guards; fetchQuery revalidates stale or invalidated sessions. */
5
+ export const sessionQueryOptions = queryOptions({
6
+ queryKey: ["auth", "session"],
7
+ queryFn: () => getSession(),
8
+ staleTime: 30_000,
9
+ // Keep a failed session check from holding navigation through retry backoff.
10
+ retry: false,
11
+ });
@@ -0,0 +1,62 @@
1
+ import {
2
+ Body,
3
+ Container,
4
+ Head,
5
+ Heading,
6
+ Hr,
7
+ Html,
8
+ Preview,
9
+ Text,
10
+ } from "@react-email/components";
11
+
12
+ import * as m from "@/paraglide/messages";
13
+ import * as s from "./theme.ts";
14
+
15
+ /** See `VerifyEmail` for why `locale` is a prop rather than ambient state. */
16
+ export function PasswordOtpEmail({
17
+ otp,
18
+ brandName,
19
+ purpose,
20
+ locale,
21
+ }: {
22
+ otp: string;
23
+ brandName: string;
24
+ purpose: "forget-password" | "sign-in";
25
+ locale?: "en" | "de";
26
+ }) {
27
+ const opts = { locale };
28
+ const reset = purpose === "forget-password";
29
+ const preview = reset
30
+ ? m.email_otp_preview_reset({ brand: brandName }, opts)
31
+ : m.email_otp_preview_signin({ brand: brandName }, opts);
32
+ const heading = reset
33
+ ? m.email_otp_heading_reset({}, opts)
34
+ : m.email_otp_heading_signin({}, opts);
35
+
36
+ return (
37
+ <Html lang={locale ?? "en"}>
38
+ <Head />
39
+ <Preview>{preview}</Preview>
40
+ <Body style={s.main}>
41
+ <Container style={s.container}>
42
+ <Text style={s.wordmark}>{brandName}</Text>
43
+ <Heading style={s.heading}>{heading}</Heading>
44
+ <Text style={s.text}>{m.email_otp_body({}, opts)}</Text>
45
+ <Text style={s.otpCode}>{otp}</Text>
46
+ <Hr style={s.hr} />
47
+ <Text style={s.footer}>
48
+ {m.email_otp_footer({ brand: brandName }, opts)}
49
+ </Text>
50
+ </Container>
51
+ </Body>
52
+ </Html>
53
+ );
54
+ }
55
+
56
+ PasswordOtpEmail.PreviewProps = {
57
+ otp: "123456",
58
+ brandName: "Starter",
59
+ purpose: "sign-in" as const,
60
+ };
61
+
62
+ export default PasswordOtpEmail;
@@ -0,0 +1,64 @@
1
+ import {
2
+ Body,
3
+ Button,
4
+ Container,
5
+ Head,
6
+ Heading,
7
+ Hr,
8
+ Html,
9
+ Preview,
10
+ Section,
11
+ Text,
12
+ } from "@react-email/components";
13
+
14
+ import * as m from "@/paraglide/messages";
15
+ import * as s from "./theme.ts";
16
+
17
+ /** See `VerifyEmail` for why `locale` is a prop rather than ambient state. */
18
+ export function ResetPasswordEmail({
19
+ url,
20
+ name,
21
+ brandName,
22
+ locale,
23
+ }: {
24
+ url: string;
25
+ name?: string;
26
+ brandName: string;
27
+ locale?: "en" | "de";
28
+ }) {
29
+ const opts = { locale };
30
+ return (
31
+ <Html lang={locale ?? "en"}>
32
+ <Head />
33
+ <Preview>{m.email_reset_preview({ brand: brandName }, opts)}</Preview>
34
+ <Body style={s.main}>
35
+ <Container style={s.container}>
36
+ <Text style={s.wordmark}>{brandName}</Text>
37
+ <Heading style={s.heading}>{m.email_reset_heading({}, opts)}</Heading>
38
+ <Text style={s.text}>
39
+ {name
40
+ ? m.email_reset_body_named({ name, brand: brandName }, opts)
41
+ : m.email_reset_body({ brand: brandName }, opts)}
42
+ </Text>
43
+ <Section style={{ textAlign: "center", margin: "32px 0 4px" }}>
44
+ <Button href={url} style={s.button}>
45
+ {m.email_reset_cta({}, opts)}
46
+ </Button>
47
+ </Section>
48
+ <Text style={s.text}>{m.email_link_fallback({}, opts)}</Text>
49
+ <Text style={s.linkText}>{url}</Text>
50
+ <Hr style={s.hr} />
51
+ <Text style={s.footer}>{m.email_reset_footer({}, opts)}</Text>
52
+ </Container>
53
+ </Body>
54
+ </Html>
55
+ );
56
+ }
57
+
58
+ ResetPasswordEmail.PreviewProps = {
59
+ url: "https://example.com/reset-password?token=preview-token",
60
+ name: "Ada",
61
+ brandName: "Starter",
62
+ };
63
+
64
+ export default ResetPasswordEmail;
@@ -0,0 +1,72 @@
1
+ import {
2
+ Body,
3
+ Button,
4
+ Container,
5
+ Head,
6
+ Heading,
7
+ Hr,
8
+ Html,
9
+ Preview,
10
+ Section,
11
+ Text,
12
+ } from "@react-email/components";
13
+
14
+ import * as m from "@/paraglide/messages";
15
+ import * as s from "./theme.ts";
16
+
17
+ /**
18
+ * `locale` is passed explicitly rather than read from ambient state: an email
19
+ * is rendered inside a request whose own locale is the sender's, not the
20
+ * recipient's. It is the account's stored `user.locale` (ADR 0005).
21
+ */
22
+ export function VerifyEmail({
23
+ url,
24
+ name,
25
+ brandName,
26
+ locale,
27
+ }: {
28
+ url: string;
29
+ name?: string;
30
+ brandName: string;
31
+ locale?: "en" | "de";
32
+ }) {
33
+ const opts = { locale };
34
+ return (
35
+ <Html lang={locale ?? "en"}>
36
+ <Head />
37
+ <Preview>{m.email_verify_preview({ brand: brandName }, opts)}</Preview>
38
+ <Body style={s.main}>
39
+ <Container style={s.container}>
40
+ <Text style={s.wordmark}>{brandName}</Text>
41
+ <Heading style={s.heading}>
42
+ {m.email_verify_heading({}, opts)}
43
+ </Heading>
44
+ <Text style={s.text}>
45
+ {name
46
+ ? m.email_verify_body_named({ name }, opts)
47
+ : m.email_verify_body({}, opts)}
48
+ </Text>
49
+ <Section style={{ textAlign: "center", margin: "32px 0 4px" }}>
50
+ <Button href={url} style={s.button}>
51
+ {m.email_verify_cta({}, opts)}
52
+ </Button>
53
+ </Section>
54
+ <Text style={s.text}>{m.email_link_fallback({}, opts)}</Text>
55
+ <Text style={s.linkText}>{url}</Text>
56
+ <Hr style={s.hr} />
57
+ <Text style={s.footer}>
58
+ {m.email_verify_footer({ brand: brandName }, opts)}
59
+ </Text>
60
+ </Container>
61
+ </Body>
62
+ </Html>
63
+ );
64
+ }
65
+
66
+ VerifyEmail.PreviewProps = {
67
+ url: "https://example.com/api/auth/verify-email?token=preview-token",
68
+ name: "Ada",
69
+ brandName: "Starter",
70
+ };
71
+
72
+ export default VerifyEmail;
@@ -0,0 +1,75 @@
1
+ // Shared email styles. Email clients need inline styles and web-safe fonts.
2
+ // Neutral structural palette only — clones rebrand for product identity.
3
+ export const serif = "Georgia, 'Times New Roman', Times, serif";
4
+
5
+ export const main = {
6
+ backgroundColor: "#f4f4f5",
7
+ margin: 0,
8
+ // Horizontal padding keeps the card off the screen edges on small devices.
9
+ padding: "32px 16px",
10
+ fontFamily: serif,
11
+ };
12
+ export const container = {
13
+ maxWidth: "480px",
14
+ margin: "0 auto",
15
+ backgroundColor: "#ffffff",
16
+ border: "1px solid #e4e4e7",
17
+ borderRadius: "4px",
18
+ padding: "40px 40px 32px",
19
+ };
20
+ export const wordmark = {
21
+ margin: 0,
22
+ fontFamily: serif,
23
+ fontSize: "22px",
24
+ fontWeight: 600,
25
+ letterSpacing: "0.01em",
26
+ color: "#18181b",
27
+ };
28
+ export const heading = {
29
+ margin: "24px 0 0",
30
+ fontFamily: serif,
31
+ fontSize: "26px",
32
+ fontWeight: 600,
33
+ color: "#18181b",
34
+ };
35
+ export const text = {
36
+ margin: "12px 0 0",
37
+ fontSize: "16px",
38
+ lineHeight: "1.6",
39
+ color: "#52525b",
40
+ };
41
+ export const button = {
42
+ backgroundColor: "#18181b",
43
+ color: "#fafafa",
44
+ fontFamily: serif,
45
+ fontSize: "16px",
46
+ fontWeight: 600,
47
+ textDecoration: "none",
48
+ padding: "12px 28px",
49
+ borderRadius: "3px",
50
+ display: "inline-block",
51
+ };
52
+ export const linkText = {
53
+ margin: "8px 0 0",
54
+ fontSize: "13px",
55
+ lineHeight: "1.5",
56
+ color: "#3f3f46",
57
+ wordBreak: "break-all" as const,
58
+ };
59
+ export const hr = { borderColor: "#e4e4e7", margin: "28px 0 0" };
60
+ export const footer = {
61
+ margin: "16px 0 0",
62
+ fontSize: "13px",
63
+ lineHeight: "1.6",
64
+ color: "#71717a",
65
+ };
66
+ export const otpCode = {
67
+ margin: "20px 0 0",
68
+ fontFamily:
69
+ "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
70
+ fontSize: "28px",
71
+ fontWeight: 600,
72
+ letterSpacing: "0.2em",
73
+ color: "#18181b",
74
+ textAlign: "center" as const,
75
+ };
@@ -0,0 +1,6 @@
1
+ import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
2
+
3
+ export default {
4
+ name: "Tanstack Query",
5
+ render: <ReactQueryDevtoolsPanel />,
6
+ };
@@ -0,0 +1,60 @@
1
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
2
+ import type { RootSession } from "@/server/auth";
3
+ import { HttpError } from "@/utils/api-error";
4
+ import { goToSignIn } from "@/utils/auth-redirect";
5
+
6
+ export interface MyRouterContext {
7
+ queryClient: QueryClient;
8
+ /** Filled by root `beforeLoad` — one session read per request. */
9
+ session?: RootSession | null;
10
+ }
11
+
12
+ /**
13
+ * The one place an expired session is handled.
14
+ *
15
+ * Every guarded server function throws a 401 the same way, so the remedy
16
+ * belongs here rather than in each page that consumes one — a component
17
+ * whose query 401s has no better answer than the sign-in page.
18
+ */
19
+ function handleClientDataError(error: unknown) {
20
+ if (typeof window === "undefined") return;
21
+
22
+ if (error instanceof HttpError && error.status === 401) {
23
+ goToSignIn(`${window.location.pathname}${window.location.search}`);
24
+ }
25
+ }
26
+
27
+ export function getContext(): MyRouterContext {
28
+ const queryClient = new QueryClient({
29
+ defaultOptions: {
30
+ queries: {
31
+ // Reuse SSR/loader/preload results on mount rather than immediately
32
+ // repeating the same server function. Pages can override freshness.
33
+ staleTime: 30_000,
34
+ // A 4xx is a refusal, not a flake: asking again gets the same
35
+ // answer, and for a 401 the default three retries held the user
36
+ // on a dead page through ~7s of backoff before the redirect
37
+ // above could fire. Everything else — network faults, 5xx —
38
+ // keeps the default three attempts.
39
+ retry: (failureCount, error) =>
40
+ !(
41
+ error instanceof HttpError &&
42
+ error.status >= 400 &&
43
+ error.status < 500
44
+ ) && failureCount < 3,
45
+ },
46
+ },
47
+ queryCache: new QueryCache({
48
+ onError: (error) => handleClientDataError(error),
49
+ }),
50
+ mutationCache: new MutationCache({
51
+ onError: (error) => handleClientDataError(error),
52
+ }),
53
+ });
54
+
55
+ return {
56
+ queryClient,
57
+ };
58
+ }
59
+
60
+ export default function TanstackQueryProvider() {}
@@ -0,0 +1,27 @@
1
+ import { oauthProviderClient } from "@better-auth/oauth-provider/client";
2
+ import { passkeyClient } from "@better-auth/passkey/client";
3
+ import {
4
+ adminClient,
5
+ inferAdditionalFields,
6
+ twoFactorClient,
7
+ } from "better-auth/client/plugins";
8
+ import { createAuthClient } from "better-auth/react";
9
+ // Type-only import: a value import would drag server-only code into the client.
10
+ import type { auth } from "@/lib/auth";
11
+ import { localizedAuthPath } from "@/utils/auth-redirect";
12
+ import { ac, admin, superadmin, user } from "./permissions";
13
+
14
+ export const authClient = createAuthClient({
15
+ plugins: [
16
+ adminClient({ ac, roles: { admin, superadmin, user } }),
17
+ oauthProviderClient(),
18
+ passkeyClient(),
19
+ twoFactorClient({
20
+ onTwoFactorRedirect() {
21
+ // Locale-prefixed challenge page so full-app URL i18n is preserved.
22
+ window.location.href = `${localizedAuthPath("/two-factor")}${window.location.search}`;
23
+ },
24
+ }),
25
+ inferAdditionalFields<typeof auth>(),
26
+ ],
27
+ });