@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,69 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { withRlsService } from "@/db/helper";
3
+ import { env } from "@/env";
4
+ import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
5
+ import { databaseMiddleware } from "@/middlewares/database";
6
+ import { handleCreemWebhook } from "@/services/creem";
7
+ import { APIError, methodNotAllowed } from "@/utils/api-error";
8
+
9
+ // Creem checkout webhook: a thin shell over the framework-agnostic handler.
10
+ // Fulfillment runs under service RLS context. HTTP contract: a valid signature
11
+ // answers 200 for every mapped outcome so Creem stops retrying a delivered
12
+ // event; a bad or missing signature answers 400 with no effect; a genuine
13
+ // processing failure throws and answers 5xx so Creem retries — that retry is
14
+ // what makes the purchase-paid hook exactly-once.
15
+ export const Route = createFileRoute("/api/webhooks/creem")({
16
+ server: {
17
+ handlers: ({ createHandlers }) =>
18
+ createHandlers({
19
+ // Answers a browser, which would otherwise get the SPA shell.
20
+ GET: { handler: () => methodNotAllowed("POST") },
21
+ POST: {
22
+ // Public and unauthenticated: the body is capped before anything
23
+ // reads or hashes it.
24
+ middleware: [databaseMiddleware, webhookBodyLimitMiddleware],
25
+ handler: async ({ request, context }) => {
26
+ const secret = env.CREEM_WEBHOOK_SECRET;
27
+ if (!secret) {
28
+ return APIError({
29
+ status: 503,
30
+ error: "not_configured",
31
+ message: "Creem is not configured.",
32
+ });
33
+ }
34
+
35
+ const result = await withRlsService(context.db, (tx) =>
36
+ handleCreemWebhook(tx, {
37
+ rawBody: context.rawBody,
38
+ signature:
39
+ request.headers.get("creem-signature") ??
40
+ request.headers.get("x-creem-signature"),
41
+ secret,
42
+ }),
43
+ );
44
+
45
+ if (result === "invalid_signature") {
46
+ return APIError({
47
+ status: 400,
48
+ error: "invalid_signature",
49
+ message: "Signature verification failed.",
50
+ });
51
+ }
52
+ if (result === "invalid_payload") {
53
+ return APIError({
54
+ status: 400,
55
+ error: "invalid_payload",
56
+ message: "Request body is not valid JSON.",
57
+ });
58
+ }
59
+ if (result === "unknown_order") {
60
+ console.error(
61
+ "[creem] event references an unknown purchase — not fulfilled",
62
+ );
63
+ }
64
+ return Response.json({ result }, { status: 200 });
65
+ },
66
+ },
67
+ }),
68
+ },
69
+ });
@@ -0,0 +1,62 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { withRlsService } from "@/db/helper";
3
+ import { env } from "@/env";
4
+ import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
5
+ import { databaseMiddleware } from "@/middlewares/database";
6
+ import { handleNowPaymentsWebhook } from "@/services/nowpayments";
7
+ import { APIError, methodNotAllowed } from "@/utils/api-error";
8
+
9
+ // NowPayments IPN callback: a thin shell over the framework-agnostic handler.
10
+ // Fulfillment runs under service RLS context. HTTP contract: a valid signature
11
+ // answers 200 for every mapped outcome so NowPayments stops retrying a
12
+ // delivered notification; a bad or missing signature answers 400 with no
13
+ // effect; a genuine processing failure throws and answers 5xx so NowPayments
14
+ // retries — that retry is what makes the purchase-paid hook exactly-once.
15
+ export const Route = createFileRoute("/api/webhooks/nowpayments")({
16
+ server: {
17
+ handlers: ({ createHandlers }) =>
18
+ createHandlers({
19
+ // Answers a browser, which would otherwise get the SPA shell.
20
+ GET: { handler: () => methodNotAllowed("POST") },
21
+ POST: {
22
+ // Public and unauthenticated: the body is capped before anything
23
+ // reads or hashes it.
24
+ middleware: [databaseMiddleware, webhookBodyLimitMiddleware],
25
+ handler: async ({ request, context }) => {
26
+ const ipnKey = env.NOW_PAYMENTS_IPN_KEY;
27
+ if (!ipnKey) {
28
+ return APIError({
29
+ status: 503,
30
+ error: "not_configured",
31
+ message: "NowPayments is not configured.",
32
+ });
33
+ }
34
+
35
+ const result = await withRlsService(context.db, (tx) =>
36
+ handleNowPaymentsWebhook(tx, {
37
+ rawBody: context.rawBody,
38
+ signature: request.headers.get("x-nowpayments-sig"),
39
+ ipnKey,
40
+ }),
41
+ );
42
+
43
+ if (result === "invalid_signature") {
44
+ return APIError({
45
+ status: 400,
46
+ error: "invalid_signature",
47
+ message: "Signature verification failed.",
48
+ });
49
+ }
50
+ if (result === "unknown_order") {
51
+ // Signed, but joins no known Purchase — nothing to do. Still 200
52
+ // so NowPayments stops retrying a callback we cannot act on.
53
+ console.error(
54
+ "[nowpayments] IPN references an unknown purchase — not fulfilled",
55
+ );
56
+ }
57
+ return Response.json({ result }, { status: 200 });
58
+ },
59
+ },
60
+ }),
61
+ },
62
+ });
@@ -0,0 +1,74 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { withRlsService } from "@/db/helper";
3
+ import { env } from "@/env";
4
+ import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
5
+ import { databaseMiddleware } from "@/middlewares/database";
6
+ import { handleStripeWebhook } from "@/services/stripe";
7
+ import { APIError, methodNotAllowed } from "@/utils/api-error";
8
+
9
+ // Stripe Checkout webhook: a thin shell over the framework-agnostic handler.
10
+ // Fulfillment runs under service RLS context. HTTP contract: a valid signature
11
+ // answers 200 for every mapped outcome so Stripe stops retrying a delivered
12
+ // event; a bad or missing signature answers 400 with no effect; a genuine
13
+ // processing failure throws and answers 5xx so Stripe retries — that retry is
14
+ // what makes the purchase-paid hook exactly-once.
15
+ export const Route = createFileRoute("/api/webhooks/stripe")({
16
+ server: {
17
+ handlers: ({ createHandlers }) =>
18
+ createHandlers({
19
+ // Answers a browser, which would otherwise get the SPA shell.
20
+ GET: { handler: () => methodNotAllowed("POST") },
21
+ POST: {
22
+ // Public and unauthenticated: the body is capped before anything
23
+ // reads or hashes it.
24
+ middleware: [databaseMiddleware, webhookBodyLimitMiddleware],
25
+ handler: async ({ request, context }) => {
26
+ const secret = env.STRIPE_WEBHOOK_SECRET;
27
+ if (!secret) {
28
+ return APIError({
29
+ status: 503,
30
+ error: "not_configured",
31
+ message: "Stripe is not configured.",
32
+ });
33
+ }
34
+
35
+ const result = await withRlsService(context.db, (tx) =>
36
+ handleStripeWebhook(tx, {
37
+ rawBody: context.rawBody,
38
+ signature: request.headers.get("stripe-signature"),
39
+ secret,
40
+ }),
41
+ );
42
+
43
+ if (result === "invalid_signature") {
44
+ return APIError({
45
+ status: 400,
46
+ error: "invalid_signature",
47
+ message: "Signature verification failed.",
48
+ });
49
+ }
50
+ if (result === "invalid_payload") {
51
+ return APIError({
52
+ status: 400,
53
+ error: "invalid_payload",
54
+ message: "Request body is not valid JSON.",
55
+ });
56
+ }
57
+ if (result === "amount_mismatch") {
58
+ // The one branch designed to fail closed. It answers 200 so
59
+ // Stripe stops retrying, but it must not pass unnoticed.
60
+ console.error(
61
+ "[stripe] reported amount does not match the Purchase row — not fulfilled",
62
+ );
63
+ }
64
+ if (result === "unknown_order") {
65
+ console.error(
66
+ "[stripe] event references an unknown purchase — not fulfilled",
67
+ );
68
+ }
69
+ return Response.json({ result }, { status: 200 });
70
+ },
71
+ },
72
+ }),
73
+ },
74
+ });
@@ -0,0 +1,430 @@
1
+ import { Select } from "@base-ui/react/select";
2
+ import { Tabs } from "@base-ui/react/tabs";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { createFileRoute, useRouter } from "@tanstack/react-router";
5
+ import { useEffect, useState } from "react";
6
+ import {
7
+ buttonClass,
8
+ buttonSecondaryClass,
9
+ DashboardShell,
10
+ Field,
11
+ inputClass,
12
+ mutedTextClass,
13
+ } from "@/components/shells";
14
+ import { sessionQueryOptions } from "@/integrations/better-auth/session-query";
15
+ import { authClient } from "@/lib/auth-client";
16
+ import * as m from "@/paraglide/messages";
17
+ import {
18
+ beginTwoFactorEnable,
19
+ confirmPasswordChange,
20
+ disableTwoFactor,
21
+ getSecurityOverview,
22
+ removePasskey,
23
+ requestPasswordOtp,
24
+ requestReauthCode,
25
+ revokeOtherSessions,
26
+ } from "@/server/account-security";
27
+ import { accountSecurityErrorMessage } from "@/utils/auth-errors";
28
+
29
+ /** One key for the page: profile, security, passkeys and sessions load together. */
30
+ const SECURITY_OVERVIEW_KEY = ["security-overview"] as const;
31
+
32
+ const overviewQuery = {
33
+ queryKey: SECURITY_OVERVIEW_KEY,
34
+ queryFn: () => getSecurityOverview(),
35
+ };
36
+
37
+ export const Route = createFileRoute("/dashboard/account")({
38
+ component: AccountPage,
39
+ // Every panel on the page reads this one payload, so there is nothing to
40
+ // render before it resolves.
41
+ loader: ({ context }) => context.queryClient.ensureQueryData(overviewQuery),
42
+ });
43
+
44
+ function AccountPage() {
45
+ // `/dashboard` narrowed `session` to signed-in for this whole subtree.
46
+ const { session } = Route.useRouteContext();
47
+ const qc = useQueryClient();
48
+ const router = useRouter();
49
+ const overview = useQuery(overviewQuery);
50
+
51
+ const sessionLocale =
52
+ (session.user as { locale?: string | null }).locale ?? "en";
53
+ const [name, setName] = useState(session.user.name);
54
+ const [locale, setLocale] = useState(sessionLocale);
55
+ const [message, setMessage] = useState<string | null>(null);
56
+ const [otp, setOtp] = useState("");
57
+ const [newPassword, setNewPassword] = useState("");
58
+ const [totpUri, setTotpUri] = useState<string | null>(null);
59
+ const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
60
+ const [factorCode, setFactorCode] = useState("");
61
+
62
+ const invalidate = () => {
63
+ void qc.invalidateQueries({ queryKey: SECURITY_OVERVIEW_KEY });
64
+ void qc
65
+ .invalidateQueries({ queryKey: sessionQueryOptions.queryKey })
66
+ .then(() => router.invalidate());
67
+ };
68
+
69
+ async function saveProfile(e: React.FormEvent) {
70
+ e.preventDefault();
71
+ setMessage(null);
72
+ const { error } = await authClient.updateUser({ name, locale });
73
+ setMessage(
74
+ error
75
+ ? (error.message ?? m.account_update_failed())
76
+ : m.account_profile_saved(),
77
+ );
78
+ invalidate();
79
+ }
80
+
81
+ const requestOtp = useMutation({
82
+ mutationFn: () => requestPasswordOtp(),
83
+ onSuccess: (r) => {
84
+ setMessage(
85
+ r.ok
86
+ ? m.account_otp_sent({ seconds: r.retryAfterSeconds })
87
+ : accountSecurityErrorMessage(r.code),
88
+ );
89
+ },
90
+ });
91
+
92
+ const changePw = useMutation({
93
+ mutationFn: () => confirmPasswordChange({ data: { otp, newPassword } }),
94
+ onSuccess: (r) => {
95
+ setMessage(
96
+ r.ok
97
+ ? m.account_password_updated()
98
+ : accountSecurityErrorMessage(r.code),
99
+ );
100
+ if (r.ok) {
101
+ setOtp("");
102
+ setNewPassword("");
103
+ }
104
+ invalidate();
105
+ },
106
+ });
107
+
108
+ const reauthFor2fa = useMutation({
109
+ mutationFn: () => requestReauthCode(),
110
+ onSuccess: (r) => {
111
+ setMessage(
112
+ r.ok ? m.account_reauth_emailed() : accountSecurityErrorMessage(r.code),
113
+ );
114
+ },
115
+ });
116
+
117
+ const enable2fa = useMutation({
118
+ mutationFn: () => beginTwoFactorEnable({ data: { otp } }),
119
+ onSuccess: (r) => {
120
+ if (r.ok) {
121
+ setTotpUri(r.totpURI);
122
+ setBackupCodes(r.backupCodes);
123
+ setMessage(m.account_2fa_enroll_hint());
124
+ } else setMessage(accountSecurityErrorMessage(r.code));
125
+ invalidate();
126
+ },
127
+ });
128
+
129
+ const disable2fa = useMutation({
130
+ mutationFn: () =>
131
+ disableTwoFactor({ data: { code: factorCode, backupCode: false } }),
132
+ onSuccess: (r) => {
133
+ setMessage(
134
+ r.ok ? m.account_2fa_disabled() : accountSecurityErrorMessage(r.code),
135
+ );
136
+ invalidate();
137
+ },
138
+ });
139
+
140
+ const revokeOthers = useMutation({
141
+ mutationFn: () => revokeOtherSessions(),
142
+ onSuccess: (r) => {
143
+ setMessage(
144
+ r.ok ? m.account_sessions_revoked() : m.account_err_unauthorized(),
145
+ );
146
+ invalidate();
147
+ },
148
+ });
149
+
150
+ const dropPasskey = useMutation({
151
+ mutationFn: (passkeyId: string) => removePasskey({ data: { passkeyId } }),
152
+ onSuccess: (r) => {
153
+ setMessage(
154
+ r.ok
155
+ ? m.account_passkey_removed()
156
+ : accountSecurityErrorMessage(r.code),
157
+ );
158
+ invalidate();
159
+ },
160
+ });
161
+
162
+ const o = overview.data;
163
+
164
+ useEffect(() => {
165
+ if (o?.locale) setLocale(o.locale);
166
+ }, [o?.locale]);
167
+
168
+ return (
169
+ <DashboardShell title={m.account_title()}>
170
+ {message ? (
171
+ <p className="mb-4 text-sm text-foreground">{message}</p>
172
+ ) : null}
173
+
174
+ <Tabs.Root defaultValue="profile">
175
+ <Tabs.List className="ui-tabs-list">
176
+ <Tabs.Tab className="ui-tabs-tab" value="profile">
177
+ {m.account_tab_profile()}
178
+ </Tabs.Tab>
179
+ <Tabs.Tab className="ui-tabs-tab" value="security">
180
+ {m.account_tab_security()}
181
+ </Tabs.Tab>
182
+ <Tabs.Tab className="ui-tabs-tab" value="sessions">
183
+ {m.account_tab_sessions()}
184
+ </Tabs.Tab>
185
+ </Tabs.List>
186
+
187
+ <Tabs.Panel className="ui-tabs-panel" value="profile">
188
+ <form onSubmit={saveProfile} className="max-w-md">
189
+ <Field label={m.field_display_name()}>
190
+ <input
191
+ className={inputClass}
192
+ value={name}
193
+ onChange={(ev) => setName(ev.target.value)}
194
+ />
195
+ </Field>
196
+ <Field label={m.field_locale()}>
197
+ <Select.Root
198
+ value={locale}
199
+ onValueChange={(v) => {
200
+ if (typeof v === "string") setLocale(v);
201
+ }}
202
+ >
203
+ <Select.Trigger className="ui-select-trigger">
204
+ <Select.Value>
205
+ {(value: string | null) =>
206
+ value === "de" ? m.locale_de() : m.locale_en()
207
+ }
208
+ </Select.Value>
209
+ <Select.Icon className="ui-select-icon">
210
+ <span aria-hidden>▾</span>
211
+ </Select.Icon>
212
+ </Select.Trigger>
213
+ <Select.Portal>
214
+ <Select.Positioner sideOffset={4} className="ui-positioner">
215
+ <Select.Popup className="ui-select-popup">
216
+ <Select.Item className="ui-select-item" value="en">
217
+ <Select.ItemText>{m.locale_en()}</Select.ItemText>
218
+ </Select.Item>
219
+ <Select.Item className="ui-select-item" value="de">
220
+ <Select.ItemText>{m.locale_de()}</Select.ItemText>
221
+ </Select.Item>
222
+ </Select.Popup>
223
+ </Select.Positioner>
224
+ </Select.Portal>
225
+ </Select.Root>
226
+ </Field>
227
+ <button type="submit" className={`${buttonClass} max-w-xs`}>
228
+ {m.account_save_profile()}
229
+ </button>
230
+ </form>
231
+ </Tabs.Panel>
232
+
233
+ <Tabs.Panel className="ui-tabs-panel" value="security">
234
+ <section className="mb-8">
235
+ {overview.isLoading ? (
236
+ <p className={mutedTextClass}>{m.common_loading()}</p>
237
+ ) : o ? (
238
+ <ul className="list-inside list-disc text-sm text-foreground">
239
+ <li>
240
+ {m.account_email_label()}: {o.email} (
241
+ {o.emailVerified
242
+ ? m.account_verified()
243
+ : m.account_unverified()}
244
+ )
245
+ </li>
246
+ <li>
247
+ {m.account_password_label()}:{" "}
248
+ {o.hasPassword
249
+ ? m.account_password_set()
250
+ : m.account_password_none()}
251
+ </li>
252
+ <li>
253
+ {m.account_github_label()}:{" "}
254
+ {o.githubLinked
255
+ ? m.account_github_linked()
256
+ : m.account_github_not_linked()}
257
+ </li>
258
+ <li>
259
+ {m.account_2fa_label()}:{" "}
260
+ {o.twoFactorEnabled ? m.account_on() : m.account_off()}
261
+ </li>
262
+ <li>
263
+ {m.account_passkeys_label()}: {o.passkeys.length}
264
+ </li>
265
+ </ul>
266
+ ) : null}
267
+ </section>
268
+
269
+ <section className="mb-8 max-w-md">
270
+ <h2 className="mb-3 text-lg font-medium">
271
+ {m.account_change_password_title()}
272
+ </h2>
273
+ <button
274
+ type="button"
275
+ className={`${buttonClass} mb-3 max-w-xs`}
276
+ disabled={requestOtp.isPending}
277
+ onClick={() => requestOtp.mutate()}
278
+ >
279
+ {m.account_send_otp()}
280
+ </button>
281
+ <form
282
+ onSubmit={(e) => {
283
+ e.preventDefault();
284
+ changePw.mutate();
285
+ }}
286
+ >
287
+ <Field label={m.account_field_otp()}>
288
+ <input
289
+ className={inputClass}
290
+ value={otp}
291
+ onChange={(e) => setOtp(e.target.value)}
292
+ />
293
+ </Field>
294
+ <Field label={m.account_field_new_password()}>
295
+ <input
296
+ className={inputClass}
297
+ type="password"
298
+ value={newPassword}
299
+ onChange={(e) => setNewPassword(e.target.value)}
300
+ />
301
+ </Field>
302
+ <button type="submit" className={`${buttonClass} max-w-xs`}>
303
+ {m.account_update_password()}
304
+ </button>
305
+ </form>
306
+ </section>
307
+
308
+ <section className="mb-8 max-w-md">
309
+ <h2 className="mb-3 text-lg font-medium">
310
+ {m.account_section_two_factor()}
311
+ </h2>
312
+ {!o?.twoFactorEnabled ? (
313
+ <>
314
+ <button
315
+ type="button"
316
+ className={`${buttonClass} mb-2 max-w-xs`}
317
+ onClick={() => reauthFor2fa.mutate()}
318
+ >
319
+ {m.account_email_enrollment_code()}
320
+ </button>
321
+ <form
322
+ onSubmit={(e) => {
323
+ e.preventDefault();
324
+ enable2fa.mutate();
325
+ }}
326
+ >
327
+ <Field label={m.account_field_enrollment_otp()}>
328
+ <input
329
+ className={inputClass}
330
+ value={otp}
331
+ onChange={(e) => setOtp(e.target.value)}
332
+ />
333
+ </Field>
334
+ <button type="submit" className={`${buttonClass} max-w-xs`}>
335
+ {m.account_begin_2fa()}
336
+ </button>
337
+ </form>
338
+ {totpUri ? (
339
+ <p className="mt-2 break-all font-mono text-xs">{totpUri}</p>
340
+ ) : null}
341
+ {backupCodes ? (
342
+ <pre className="mt-2 text-xs">{backupCodes.join("\n")}</pre>
343
+ ) : null}
344
+ </>
345
+ ) : (
346
+ <form
347
+ onSubmit={(e) => {
348
+ e.preventDefault();
349
+ disable2fa.mutate();
350
+ }}
351
+ >
352
+ <Field label={m.account_field_totp_disable()}>
353
+ <input
354
+ className={inputClass}
355
+ value={factorCode}
356
+ onChange={(e) => setFactorCode(e.target.value)}
357
+ />
358
+ </Field>
359
+ <button type="submit" className={`${buttonClass} max-w-xs`}>
360
+ {m.account_disable_2fa()}
361
+ </button>
362
+ </form>
363
+ )}
364
+ </section>
365
+
366
+ <section className="mb-8">
367
+ <h2 className="mb-3 text-lg font-medium">
368
+ {m.account_section_passkeys()}
369
+ </h2>
370
+ {o?.passkeys.length ? (
371
+ <ul className="space-y-2 text-sm">
372
+ {o.passkeys.map((pk) => (
373
+ <li key={pk.id} className="flex items-center gap-3">
374
+ <span>
375
+ {pk.name ?? pk.id}
376
+ {pk.createdAt ? ` · ${pk.createdAt}` : ""}
377
+ </span>
378
+ <button
379
+ type="button"
380
+ className={`${buttonClass} w-auto px-2 py-1 text-xs`}
381
+ onClick={() => dropPasskey.mutate(pk.id)}
382
+ >
383
+ {m.account_remove()}
384
+ </button>
385
+ </li>
386
+ ))}
387
+ </ul>
388
+ ) : (
389
+ <p className={mutedTextClass}>{m.account_no_passkeys()}</p>
390
+ )}
391
+ <p className="mt-2 text-xs text-muted-foreground">
392
+ {m.account_passkey_hint()}
393
+ </p>
394
+ </section>
395
+ </Tabs.Panel>
396
+
397
+ <Tabs.Panel className="ui-tabs-panel" value="sessions">
398
+ <button
399
+ type="button"
400
+ className={`${buttonClass} mb-3 max-w-xs`}
401
+ onClick={() => revokeOthers.mutate()}
402
+ >
403
+ {m.account_revoke_other_sessions()}
404
+ </button>
405
+ {overview.data?.sessions ? (
406
+ <ul className="space-y-1 text-xs text-muted-foreground">
407
+ {overview.data.sessions.map((s) => (
408
+ <li key={s.id}>
409
+ {s.isCurrent ? `${m.account_session_current()} · ` : ""}
410
+ {s.createdAt}
411
+ {s.ipAddress ? ` · ${s.ipAddress}` : ""}
412
+ </li>
413
+ ))}
414
+ </ul>
415
+ ) : null}
416
+ <button
417
+ type="button"
418
+ className={`${buttonSecondaryClass} mt-6 w-full max-w-xs`}
419
+ onClick={async () => {
420
+ await authClient.signOut();
421
+ window.location.href = "/sign-in";
422
+ }}
423
+ >
424
+ {m.action_sign_out()}
425
+ </button>
426
+ </Tabs.Panel>
427
+ </Tabs.Root>
428
+ </DashboardShell>
429
+ );
430
+ }