@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,175 @@
1
+ import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
2
+ import { NavigationProgress } from "#/components/root/navigation-progress.tsx";
3
+ import { NotFound } from "#/components/root/not-found.tsx";
4
+ import { RootDocument } from "#/components/root/root-document.tsx";
5
+ import { RootError } from "#/components/root/root-error.tsx";
6
+ import type { MyRouterContext } from "#/integrations/tanstack-query/root-provider.tsx";
7
+ import {
8
+ abs,
9
+ DEFAULT_DESCRIPTION,
10
+ DEFAULT_TITLE,
11
+ SITE_NAME,
12
+ SITE_ORIGIN,
13
+ THEME_COLOR_DARK,
14
+ THEME_COLOR_LIGHT,
15
+ } from "#/lib/site.ts";
16
+ import { THEME_INIT_SCRIPT } from "#/lib/theme.ts";
17
+ import { sessionQueryOptions } from "@/integrations/better-auth/session-query";
18
+ import { getLocale } from "@/paraglide/runtime";
19
+ import appCss from "../styles.css?url";
20
+
21
+ const OG_IMAGE = abs("/logo512.png");
22
+
23
+ export const Route = createRootRouteWithContext<MyRouterContext>()({
24
+ // Each SSR request has its own QueryClient. In the browser, navigation and
25
+ // intent preloads share the session query instead of serializing an RPC
26
+ // ahead of every page loader. fetchQuery still awaits stale-session checks.
27
+ beforeLoad: async ({ context }) => ({
28
+ session: await context.queryClient.fetchQuery(sessionQueryOptions),
29
+ }),
30
+ head: () => {
31
+ const locale = getLocale();
32
+ return {
33
+ meta: [
34
+ { charSet: "utf-8" },
35
+ {
36
+ name: "viewport",
37
+ content: "width=device-width, initial-scale=1, viewport-fit=cover",
38
+ },
39
+ // Sensible defaults; content pages (e.g. `/`) may override title/description.
40
+ { title: DEFAULT_TITLE },
41
+ { name: "description", content: DEFAULT_DESCRIPTION },
42
+ { name: "application-name", content: SITE_NAME },
43
+ { name: "apple-mobile-web-app-title", content: SITE_NAME },
44
+ // Standard tag for Chrome; iOS Safari still reads only the apple- one.
45
+ { name: "mobile-web-app-capable", content: "yes" },
46
+ { name: "apple-mobile-web-app-capable", content: "yes" },
47
+ {
48
+ name: "apple-mobile-web-app-status-bar-style",
49
+ content: "black-translucent",
50
+ },
51
+ { name: "format-detection", content: "telephone=no" },
52
+ // Public pages are indexable by default; gated/transactional routes set
53
+ // their own `noindex` (dashboard, reset-password, two-factor, …).
54
+ { name: "robots", content: "index, follow" },
55
+ // Browser UI tint per theme.
56
+ {
57
+ name: "theme-color",
58
+ content: THEME_COLOR_LIGHT,
59
+ media: "(prefers-color-scheme: light)",
60
+ },
61
+ {
62
+ name: "theme-color",
63
+ content: THEME_COLOR_DARK,
64
+ media: "(prefers-color-scheme: dark)",
65
+ },
66
+ // Open Graph
67
+ { property: "og:type", content: "website" },
68
+ { property: "og:site_name", content: SITE_NAME },
69
+ { property: "og:title", content: DEFAULT_TITLE },
70
+ { property: "og:description", content: DEFAULT_DESCRIPTION },
71
+ { property: "og:image", content: OG_IMAGE },
72
+ { property: "og:locale", content: locale === "de" ? "de_DE" : "en_US" },
73
+ // Twitter
74
+ { name: "twitter:card", content: "summary_large_image" },
75
+ { name: "twitter:title", content: DEFAULT_TITLE },
76
+ { name: "twitter:description", content: DEFAULT_DESCRIPTION },
77
+ { name: "twitter:image", content: OG_IMAGE },
78
+ ],
79
+ links: [
80
+ { rel: "stylesheet", href: appCss },
81
+ // Icons — last equally-appropriate entry wins in modern browsers.
82
+ {
83
+ rel: "icon",
84
+ type: "image/x-icon",
85
+ href: "/favicon.ico",
86
+ },
87
+ {
88
+ rel: "shortcut icon",
89
+ type: "image/x-icon",
90
+ href: "/favicon.ico",
91
+ },
92
+ {
93
+ rel: "icon",
94
+ type: "image/png",
95
+ sizes: "192x192",
96
+ href: "/logo192.png",
97
+ },
98
+ {
99
+ rel: "icon",
100
+ type: "image/png",
101
+ sizes: "512x512",
102
+ href: "/logo512.png",
103
+ },
104
+ {
105
+ rel: "apple-touch-icon",
106
+ href: "/logo192.png",
107
+ sizes: "192x192",
108
+ },
109
+ { rel: "manifest", href: "/manifest.json" },
110
+ ],
111
+ scripts: [
112
+ {
113
+ // Set the theme class before first paint: stored choice wins,
114
+ // system preference otherwise. See src/lib/theme.ts.
115
+ children: THEME_INIT_SCRIPT,
116
+ },
117
+ // Structured data — only with an absolute origin (schema.org needs absolute URLs).
118
+ ...(SITE_ORIGIN
119
+ ? [
120
+ {
121
+ type: "application/ld+json",
122
+ children: JSON.stringify({
123
+ "@context": "https://schema.org",
124
+ "@graph": [
125
+ {
126
+ "@type": "Organization",
127
+ "@id": `${SITE_ORIGIN}/#organization`,
128
+ name: SITE_NAME,
129
+ url: SITE_ORIGIN,
130
+ logo: abs("/logo512.png"),
131
+ },
132
+ {
133
+ "@type": "WebSite",
134
+ "@id": `${SITE_ORIGIN}/#website`,
135
+ name: SITE_NAME,
136
+ url: SITE_ORIGIN,
137
+ description: DEFAULT_DESCRIPTION,
138
+ inLanguage: locale,
139
+ publisher: { "@id": `${SITE_ORIGIN}/#organization` },
140
+ },
141
+ {
142
+ "@type": "SoftwareApplication",
143
+ "@id": `${SITE_ORIGIN}/#app`,
144
+ name: SITE_NAME,
145
+ url: SITE_ORIGIN,
146
+ description: DEFAULT_DESCRIPTION,
147
+ inLanguage: locale,
148
+ applicationCategory: "DeveloperApplication",
149
+ operatingSystem: "Web",
150
+ publisher: { "@id": `${SITE_ORIGIN}/#organization` },
151
+ },
152
+ ],
153
+ }),
154
+ },
155
+ ]
156
+ : []),
157
+ ],
158
+ };
159
+ },
160
+ errorComponent: ({ error, reset }) => (
161
+ <RootError error={error} reset={reset} />
162
+ ),
163
+ notFoundComponent: NotFound,
164
+ component: RootContent,
165
+ shellComponent: RootDocument,
166
+ });
167
+
168
+ function RootContent() {
169
+ return (
170
+ <>
171
+ <NavigationProgress />
172
+ <Outlet />
173
+ </>
174
+ );
175
+ }
@@ -0,0 +1,74 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+ import { AdminShell } from "@/components/shells";
4
+ import * as m from "@/paraglide/messages";
5
+ import { listAuditEvents } from "@/server/admin-audit";
6
+
7
+ const auditQuery = {
8
+ queryKey: ["admin-audit"],
9
+ queryFn: () => listAuditEvents({ data: { limit: 100 } }),
10
+ };
11
+
12
+ export const Route = createFileRoute("/admin/audit")({
13
+ component: AdminAuditPage,
14
+ // The trail is the whole page: nothing renders without it.
15
+ loader: ({ context }) => context.queryClient.ensureQueryData(auditQuery),
16
+ });
17
+
18
+ function AdminAuditPage() {
19
+ const { data, isLoading, error } = useQuery(auditQuery);
20
+
21
+ return (
22
+ <AdminShell title={m.admin_audit_title()}>
23
+ {isLoading ? (
24
+ <p className="text-sm text-muted-foreground">{m.common_loading()}</p>
25
+ ) : null}
26
+ {error ? (
27
+ <p className="text-sm text-red-600" role="alert">
28
+ {(error as Error).message}
29
+ </p>
30
+ ) : null}
31
+ {data && data.length === 0 ? (
32
+ <p className="text-sm text-muted-foreground">{m.admin_audit_empty()}</p>
33
+ ) : null}
34
+ {data && data.length > 0 ? (
35
+ <div className="overflow-x-auto">
36
+ <table className="w-full min-w-[40rem] border-collapse text-left text-sm">
37
+ <thead>
38
+ <tr className="border-b border-border">
39
+ <th className="py-2 pr-3 font-medium">{m.admin_col_when()}</th>
40
+ <th className="py-2 pr-3 font-medium">
41
+ {m.admin_col_action()}
42
+ </th>
43
+ <th className="py-2 pr-3 font-medium">{m.admin_col_actor()}</th>
44
+ <th className="py-2 pr-3 font-medium">
45
+ {m.admin_col_target()}
46
+ </th>
47
+ <th className="py-2 font-medium">{m.admin_col_detail()}</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody>
51
+ {data.map((row) => (
52
+ <tr key={row.id} className="border-b border-border">
53
+ <td className="py-2 pr-3 whitespace-nowrap text-xs">
54
+ {row.createdAt}
55
+ </td>
56
+ <td className="py-2 pr-3 font-mono text-xs">{row.action}</td>
57
+ <td className="py-2 pr-3 font-mono text-xs">
58
+ {row.actorId ?? "—"}
59
+ </td>
60
+ <td className="py-2 pr-3 font-mono text-xs">
61
+ {row.targetType}:{row.targetId ?? "—"}
62
+ </td>
63
+ <td className="py-2 font-mono text-xs text-muted-foreground">
64
+ {row.detail ? JSON.stringify(row.detail) : "—"}
65
+ </td>
66
+ </tr>
67
+ ))}
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+ ) : null}
72
+ </AdminShell>
73
+ );
74
+ }
@@ -0,0 +1,7 @@
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
2
+
3
+ export const Route = createFileRoute("/admin/")({
4
+ beforeLoad: () => {
5
+ throw redirect({ to: "/admin/users" });
6
+ },
7
+ });
@@ -0,0 +1,164 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+ import { useState } from "react";
4
+ import {
5
+ AdminShell,
6
+ buttonClass,
7
+ Field,
8
+ inputClass,
9
+ } from "@/components/shells";
10
+ import * as m from "@/paraglide/messages";
11
+ import {
12
+ createOAuthClient,
13
+ deleteOAuthClient,
14
+ getOAuthClients,
15
+ } from "@/server/oauth-clients";
16
+
17
+ const clientsQuery = {
18
+ queryKey: ["oauth-clients"],
19
+ queryFn: () => getOAuthClients(),
20
+ };
21
+
22
+ export const Route = createFileRoute("/admin/oauth-apps")({
23
+ component: AdminOAuthAppsPage,
24
+ // The client list is the whole page: nothing renders without it.
25
+ loader: ({ context }) => context.queryClient.ensureQueryData(clientsQuery),
26
+ });
27
+
28
+ function AdminOAuthAppsPage() {
29
+ const qc = useQueryClient();
30
+ const [name, setName] = useState("");
31
+ const [redirectUris, setRedirectUris] = useState(
32
+ "http://127.0.0.1:38256/oauth/callback",
33
+ );
34
+ const [createdSecret, setCreatedSecret] = useState<string | null>(null);
35
+
36
+ const { data, isLoading, error } = useQuery(clientsQuery);
37
+
38
+ const createMut = useMutation({
39
+ mutationFn: () =>
40
+ createOAuthClient({
41
+ data: {
42
+ clientName: name,
43
+ redirectUris: redirectUris
44
+ .split(/[\n,]+/)
45
+ .map((s) => s.trim())
46
+ .filter(Boolean),
47
+ publicClient: false,
48
+ },
49
+ }),
50
+ onSuccess: (res) => {
51
+ setCreatedSecret(res.clientSecret);
52
+ setName("");
53
+ void qc.invalidateQueries({ queryKey: ["oauth-clients"] });
54
+ },
55
+ });
56
+
57
+ const deleteMut = useMutation({
58
+ mutationFn: (clientId: string) => deleteOAuthClient({ data: { clientId } }),
59
+ onSuccess: () => {
60
+ void qc.invalidateQueries({ queryKey: ["oauth-clients"] });
61
+ },
62
+ });
63
+
64
+ return (
65
+ <AdminShell title={m.admin_oauth_title()}>
66
+ <section className="mb-10 max-w-lg">
67
+ <h2 className="mb-3 text-lg font-medium">
68
+ {m.admin_oauth_create_client()}
69
+ </h2>
70
+ <form
71
+ onSubmit={(e) => {
72
+ e.preventDefault();
73
+ createMut.mutate();
74
+ }}
75
+ >
76
+ <Field label={m.admin_oauth_field_name()}>
77
+ <input
78
+ className={inputClass}
79
+ required
80
+ value={name}
81
+ onChange={(e) => setName(e.target.value)}
82
+ />
83
+ </Field>
84
+ <Field label={m.admin_oauth_field_redirect_uris()}>
85
+ <textarea
86
+ className={inputClass}
87
+ rows={3}
88
+ required
89
+ value={redirectUris}
90
+ onChange={(e) => setRedirectUris(e.target.value)}
91
+ />
92
+ </Field>
93
+ <button
94
+ type="submit"
95
+ className={`${buttonClass} max-w-xs`}
96
+ disabled={createMut.isPending}
97
+ >
98
+ {createMut.isPending ? "Creating…" : "Create"}
99
+ </button>
100
+ </form>
101
+ {createdSecret ? (
102
+ <p className="mt-3 text-sm text-amber-800 dark:text-amber-200">
103
+ Client secret (copy now):{" "}
104
+ <code className="break-all">{createdSecret}</code>
105
+ </p>
106
+ ) : null}
107
+ {createMut.error ? (
108
+ <p className="mt-2 text-sm text-red-600" role="alert">
109
+ {(createMut.error as Error).message}
110
+ </p>
111
+ ) : null}
112
+ </section>
113
+
114
+ <section>
115
+ <h2 className="mb-3 text-lg font-medium">
116
+ {m.admin_oauth_registered_clients()}
117
+ </h2>
118
+ {isLoading ? (
119
+ <p className="text-sm text-muted-foreground">{m.common_loading()}</p>
120
+ ) : null}
121
+ {error ? (
122
+ <p className="text-sm text-red-600" role="alert">
123
+ {(error as Error).message}
124
+ </p>
125
+ ) : null}
126
+ {data && data.length === 0 ? (
127
+ <p className="text-sm text-muted-foreground">
128
+ {m.admin_oauth_empty()}
129
+ </p>
130
+ ) : null}
131
+ {data && data.length > 0 ? (
132
+ <ul className="space-y-3">
133
+ {data.map((c) => (
134
+ <li
135
+ key={c.clientId}
136
+ className="rounded-md border border-border p-3 text-sm border-border"
137
+ >
138
+ <div className="flex flex-wrap items-start justify-between gap-2">
139
+ <div>
140
+ <p className="font-medium">{c.name ?? "(unnamed)"}</p>
141
+ <p className="font-mono text-xs text-muted-foreground">
142
+ {c.clientId}
143
+ </p>
144
+ <p className="mt-1 text-xs text-muted-foreground">
145
+ {c.redirectUris.join(", ")}
146
+ </p>
147
+ </div>
148
+ <button
149
+ type="button"
150
+ className={`${buttonClass} w-auto bg-red-800 px-3 py-1 text-xs dark:bg-red-200`}
151
+ disabled={deleteMut.isPending}
152
+ onClick={() => deleteMut.mutate(c.clientId)}
153
+ >
154
+ Delete
155
+ </button>
156
+ </div>
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ ) : null}
161
+ </section>
162
+ </AdminShell>
163
+ );
164
+ }
@@ -0,0 +1,27 @@
1
+ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
2
+
3
+ export const Route = createFileRoute("/admin")({
4
+ // `session` comes from the root route's beforeLoad (one read per request).
5
+ // This gate is chrome only: it decides whether the console renders. Every
6
+ // privileged mutation re-checks the role server-side, so a stale cookie
7
+ // cache here can reveal a layout, never an operation.
8
+ //
9
+ // The role list is spelled out rather than imported from
10
+ // `services/admin-security`: that module also imports Drizzle tables, and a
11
+ // route file is client code, so the import shipped the whole schema — table
12
+ // and column names, index names, and the RLS policy predicates — to every
13
+ // visitor. `check:client-bundle` fails on that now. Server-side role checks
14
+ // keep using `isAdminRole`; this one line is chrome.
15
+ beforeLoad: ({ context: { session } }) => {
16
+ if (!session?.user) throw redirect({ to: "/sign-in" });
17
+ if (!["admin", "superadmin"].includes(session.user.role ?? "")) {
18
+ throw redirect({ to: "/dashboard" });
19
+ }
20
+ return { session };
21
+ },
22
+ // Private area — keep the whole admin subtree out of search indexes.
23
+ head: () => ({
24
+ meta: [{ name: "robots", content: "noindex, nofollow" }],
25
+ }),
26
+ component: () => <Outlet />,
27
+ });
@@ -0,0 +1,215 @@
1
+ import { Checkbox } from "@base-ui/react/checkbox";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { createFileRoute } from "@tanstack/react-router";
4
+ import { useState } from "react";
5
+ import { AdminShell, buttonClass, inputClass } from "@/components/shells";
6
+ import * as m from "@/paraglide/messages";
7
+ import { getUsers, updateUser } from "@/server/admin-users";
8
+ import {
9
+ getPaymentMethodToggles,
10
+ updatePaymentMethodToggles,
11
+ } from "@/server/payment-toggles";
12
+
13
+ const usersQuery = (search: string) => ({
14
+ queryKey: ["admin-users", search],
15
+ queryFn: () =>
16
+ getUsers({ data: { page: 0, pageSize: 50, search, filters: {} } }),
17
+ });
18
+
19
+ const togglesQuery = {
20
+ queryKey: ["payment-toggles"],
21
+ queryFn: () => getPaymentMethodToggles(),
22
+ };
23
+
24
+ export const Route = createFileRoute("/admin/users")({
25
+ component: AdminUsersPage,
26
+ // The list is the page; the payment toggles are a panel beside it, so they
27
+ // are warmed rather than awaited — the user list already sorts on an
28
+ // unindexed column, and blocking navigation on both means waiting for the
29
+ // slower one twice over.
30
+ loader: async ({ context }) => {
31
+ context.queryClient.ensureQueryData(togglesQuery);
32
+ await context.queryClient.ensureQueryData(usersQuery(""));
33
+ },
34
+ });
35
+
36
+ function AdminUsersPage() {
37
+ const qc = useQueryClient();
38
+ const [search, setSearch] = useState("");
39
+ const { data, isLoading, error } = useQuery(usersQuery(search));
40
+
41
+ const toggles = useQuery(togglesQuery);
42
+
43
+ const mutation = useMutation({
44
+ mutationFn: (input: {
45
+ userId: string;
46
+ emailVerified: boolean;
47
+ banned: boolean;
48
+ role?: "user" | "admin";
49
+ }) => updateUser({ data: input }),
50
+ onSuccess: () => {
51
+ void qc.invalidateQueries({ queryKey: ["admin-users"] });
52
+ },
53
+ });
54
+
55
+ const toggleMut = useMutation({
56
+ mutationFn: (next: {
57
+ stripe: boolean;
58
+ creem: boolean;
59
+ nowpayments: boolean;
60
+ }) => updatePaymentMethodToggles({ data: next }),
61
+ onSuccess: () => {
62
+ void qc.invalidateQueries({ queryKey: ["payment-toggles"] });
63
+ },
64
+ });
65
+
66
+ return (
67
+ <AdminShell title={m.admin_users_title()}>
68
+ {toggles.data ? (
69
+ <section className="mb-6 border border-border p-4 text-sm">
70
+ <p className="mb-2 font-medium">{m.admin_payment_toggles()}</p>
71
+ <div className="flex flex-wrap gap-4">
72
+ {(["stripe", "creem", "nowpayments"] as const).map((key) => (
73
+ <div key={key} className="inline-flex items-center gap-2">
74
+ <Checkbox.Root
75
+ className="ui-checkbox"
76
+ id={`pay-toggle-${key}`}
77
+ checked={toggles.data[key]}
78
+ disabled={toggleMut.isPending}
79
+ onCheckedChange={(checked) =>
80
+ toggleMut.mutate({
81
+ ...toggles.data,
82
+ [key]: checked === true,
83
+ })
84
+ }
85
+ >
86
+ <Checkbox.Indicator className="ui-checkbox-indicator">
87
+ <span aria-hidden>✓</span>
88
+ </Checkbox.Indicator>
89
+ </Checkbox.Root>
90
+ <label htmlFor={`pay-toggle-${key}`}>{key}</label>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ <p className="mt-1 text-xs text-muted-foreground">
95
+ {m.admin_payment_toggles_hint()}
96
+ </p>
97
+ </section>
98
+ ) : null}
99
+ <div className="mb-4 flex flex-wrap gap-2">
100
+ <input
101
+ className={`${inputClass} max-w-xs`}
102
+ placeholder={m.admin_users_search_placeholder()}
103
+ value={search}
104
+ onChange={(e) => setSearch(e.target.value)}
105
+ />
106
+ </div>
107
+ {isLoading ? (
108
+ <p className="text-sm text-muted-foreground">{m.common_loading()}</p>
109
+ ) : null}
110
+ {error ? (
111
+ <p className="text-sm text-destructive" role="alert">
112
+ {(error as Error).message}
113
+ </p>
114
+ ) : null}
115
+ {data ? (
116
+ <div className="overflow-x-auto border border-border">
117
+ <table className="w-full min-w-[40rem] text-left text-sm">
118
+ <thead>
119
+ <tr className="border-b border-border">
120
+ <th className="px-3 py-2 font-medium">{m.admin_col_email()}</th>
121
+ <th className="px-3 py-2 font-medium">{m.admin_col_name()}</th>
122
+ <th className="px-3 py-2 font-medium">{m.admin_col_role()}</th>
123
+ <th className="px-3 py-2 font-medium">
124
+ {m.admin_col_status()}
125
+ </th>
126
+ <th className="px-3 py-2 font-medium">
127
+ {m.admin_col_actions()}
128
+ </th>
129
+ </tr>
130
+ </thead>
131
+ <tbody>
132
+ {data.rows.map((row) => (
133
+ <tr
134
+ key={row.id}
135
+ className="border-b border-border last:border-0"
136
+ >
137
+ <td className="px-3 py-2">{row.email}</td>
138
+ <td className="px-3 py-2">{row.name}</td>
139
+ <td className="px-3 py-2">{row.role ?? "user"}</td>
140
+ <td className="px-3 py-2">
141
+ {row.banned ? "banned" : "active"}
142
+ {row.emailVerified ? "" : " · unverified"}
143
+ </td>
144
+ <td className="px-3 py-2">
145
+ <div className="flex flex-wrap gap-2">
146
+ {row.role !== "superadmin" && row.role !== "admin" ? (
147
+ <button
148
+ type="button"
149
+ className={`${buttonClass} h-8 w-auto px-2 text-xs`}
150
+ disabled={mutation.isPending}
151
+ onClick={() =>
152
+ mutation.mutate({
153
+ userId: row.id,
154
+ emailVerified: Boolean(row.emailVerified),
155
+ banned: !row.banned,
156
+ })
157
+ }
158
+ >
159
+ {row.banned ? "Unban" : "Ban"}
160
+ </button>
161
+ ) : null}
162
+ {row.role === "user" ? (
163
+ <button
164
+ type="button"
165
+ className={`${buttonClass} h-8 w-auto px-2 text-xs`}
166
+ disabled={mutation.isPending}
167
+ onClick={() =>
168
+ mutation.mutate({
169
+ userId: row.id,
170
+ emailVerified: Boolean(row.emailVerified),
171
+ banned: Boolean(row.banned),
172
+ role: "admin",
173
+ })
174
+ }
175
+ >
176
+ Make admin
177
+ </button>
178
+ ) : null}
179
+ {row.role === "admin" ? (
180
+ <button
181
+ type="button"
182
+ className={`${buttonClass} h-8 w-auto px-2 text-xs`}
183
+ disabled={mutation.isPending}
184
+ onClick={() =>
185
+ mutation.mutate({
186
+ userId: row.id,
187
+ emailVerified: Boolean(row.emailVerified),
188
+ banned: Boolean(row.banned),
189
+ role: "user",
190
+ })
191
+ }
192
+ >
193
+ Demote
194
+ </button>
195
+ ) : null}
196
+ </div>
197
+ </td>
198
+ </tr>
199
+ ))}
200
+ </tbody>
201
+ </table>
202
+ <p className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
203
+ {data.total} user{data.total === 1 ? "" : "s"} · peer protection
204
+ blocks ban/demote of admins
205
+ </p>
206
+ </div>
207
+ ) : null}
208
+ {mutation.error ? (
209
+ <p className="mt-2 text-sm text-red-600" role="alert">
210
+ {(mutation.error as Error).message}
211
+ </p>
212
+ ) : null}
213
+ </AdminShell>
214
+ );
215
+ }
@@ -0,0 +1,14 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { authRateLimitMiddleware } from "@/middlewares/auth-rate-limit";
3
+ import { betterAuthMiddleware } from "@/middlewares/better-auth";
4
+
5
+ export const Route = createFileRoute("/api/auth/$")({
6
+ server: {
7
+ // Rate-limit first so brute-force is rejected before any auth/db work.
8
+ middleware: [authRateLimitMiddleware, betterAuthMiddleware],
9
+ handlers: {
10
+ GET: ({ request, context }) => context.auth.handler(request),
11
+ POST: ({ request, context }) => context.auth.handler(request),
12
+ },
13
+ },
14
+ });
@@ -0,0 +1,15 @@
1
+ import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+ import { betterAuthMiddleware } from "@/middlewares/better-auth.ts";
4
+
5
+ export const Route = createFileRoute(
6
+ "/api/auth/.well-known/openid-configuration",
7
+ )({
8
+ server: {
9
+ middleware: [betterAuthMiddleware],
10
+ handlers: {
11
+ GET: async ({ request, context }) =>
12
+ oauthProviderOpenIdConfigMetadata(context.auth)(request),
13
+ },
14
+ },
15
+ });