create-nexst-monorepo 0.1.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 (303) hide show
  1. package/dist/index.js +1727 -0
  2. package/package.json +37 -0
  3. package/template/.agents/plugins/marketplace.json +21 -0
  4. package/template/.claude/settings.json +6 -0
  5. package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
  6. package/template/.claude/skills/frontend-design/SKILL.md +154 -0
  7. package/template/.claude/skills/theme-factory/SKILL.md +95 -0
  8. package/template/.claude/skills/theme-factory/references/schema.md +104 -0
  9. package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
  10. package/template/.dockerignore +43 -0
  11. package/template/.editorconfig +10 -0
  12. package/template/.github/workflows/ci.yaml +289 -0
  13. package/template/.github/workflows/publish-app.yaml +66 -0
  14. package/template/.github/workflows/publish.yaml +148 -0
  15. package/template/.github/workflows/snyk.yaml +34 -0
  16. package/template/.vscode/settings.json +8 -0
  17. package/template/AGENTS.md +42 -0
  18. package/template/Dockerfile.base +9 -0
  19. package/template/README.md +54 -0
  20. package/template/_gitignore +10 -0
  21. package/template/apps/api/.env.example +32 -0
  22. package/template/apps/api/AGENTS.md +44 -0
  23. package/template/apps/api/Dockerfile +69 -0
  24. package/template/apps/api/_gitignore +8 -0
  25. package/template/apps/api/knip.config.ts +22 -0
  26. package/template/apps/api/kustomize/base/application.properties +16 -0
  27. package/template/apps/api/kustomize/base/deployment.yaml +94 -0
  28. package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
  29. package/template/apps/api/kustomize/base/service.yaml +10 -0
  30. package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
  31. package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
  32. package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
  33. package/template/apps/api/kustomize/secret.keys +14 -0
  34. package/template/apps/api/oxfmt.config.ts +8 -0
  35. package/template/apps/api/oxlint.config.ts +107 -0
  36. package/template/apps/api/package.json +79 -0
  37. package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +113 -0
  40. package/template/apps/api/prisma.config.ts +7 -0
  41. package/template/apps/api/scripts/auth.cli.ts +19 -0
  42. package/template/apps/api/scripts/seal-secret.sh +171 -0
  43. package/template/apps/api/sonar-project.properties +9 -0
  44. package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
  45. package/template/apps/api/src/app-info/app-info.module.ts +7 -0
  46. package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
  47. package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
  48. package/template/apps/api/src/app.module.spec.ts +93 -0
  49. package/template/apps/api/src/app.module.ts +77 -0
  50. package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
  51. package/template/apps/api/src/auth/auth.guard.ts +97 -0
  52. package/template/apps/api/src/auth/auth.ts +129 -0
  53. package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
  54. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
  55. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
  56. package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
  57. package/template/apps/api/src/auth/testing/make-session.ts +31 -0
  58. package/template/apps/api/src/auth/types/auth.types.ts +27 -0
  59. package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
  60. package/template/apps/api/src/bootstrap/security.ts +24 -0
  61. package/template/apps/api/src/bootstrap/swagger.ts +28 -0
  62. package/template/apps/api/src/config/app.config.spec.ts +22 -0
  63. package/template/apps/api/src/config/app.config.ts +122 -0
  64. package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
  65. package/template/apps/api/src/config/graphql.config.ts +77 -0
  66. package/template/apps/api/src/config/resources/app.yaml +29 -0
  67. package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
  68. package/template/apps/api/src/errors/app.exception.ts +47 -0
  69. package/template/apps/api/src/errors/error-code.enum.ts +54 -0
  70. package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
  71. package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
  72. package/template/apps/api/src/health/health.controller.spec.ts +82 -0
  73. package/template/apps/api/src/health/health.controller.ts +57 -0
  74. package/template/apps/api/src/health/health.module.ts +12 -0
  75. package/template/apps/api/src/logging/context-logger.ts +18 -0
  76. package/template/apps/api/src/logging/logger.config.ts +23 -0
  77. package/template/apps/api/src/logging/logging.module.ts +22 -0
  78. package/template/apps/api/src/mail/mail.module.ts +12 -0
  79. package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
  80. package/template/apps/api/src/mail/mail.service.ts +123 -0
  81. package/template/apps/api/src/main.ts +41 -0
  82. package/template/apps/api/src/prisma/prisma.module.ts +12 -0
  83. package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
  84. package/template/apps/api/src/prisma/prisma.service.ts +138 -0
  85. package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
  86. package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
  87. package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
  88. package/template/apps/api/src/queue/example-queue.service.ts +32 -0
  89. package/template/apps/api/src/queue/queue.module.ts +27 -0
  90. package/template/apps/api/src/r2/r2.module.ts +12 -0
  91. package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
  92. package/template/apps/api/src/r2/r2.service.ts +131 -0
  93. package/template/apps/api/src/user/dto/user.dto.ts +26 -0
  94. package/template/apps/api/src/user/user.module.ts +10 -0
  95. package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
  96. package/template/apps/api/src/user/user.resolver.ts +24 -0
  97. package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
  98. package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
  99. package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
  100. package/template/apps/api/src/utils/error-handler.util.ts +12 -0
  101. package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
  102. package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
  103. package/template/apps/api/src/utils/pagination.input.ts +20 -0
  104. package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
  105. package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
  106. package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
  107. package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
  108. package/template/apps/api/tests/app.e2e-spec.ts +35 -0
  109. package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
  110. package/template/apps/api/tests/helpers/api.ts +27 -0
  111. package/template/apps/api/tsconfig.json +11 -0
  112. package/template/apps/api/vitest.config.e2e.ts +16 -0
  113. package/template/apps/api/vitest.config.ts +40 -0
  114. package/template/apps/web/.env.example +7 -0
  115. package/template/apps/web/.storybook/decorators.tsx +38 -0
  116. package/template/apps/web/.storybook/main.ts +17 -0
  117. package/template/apps/web/.storybook/preview.ts +38 -0
  118. package/template/apps/web/.storybook/vitest.setup.ts +5 -0
  119. package/template/apps/web/AGENTS.md +29 -0
  120. package/template/apps/web/Dockerfile +49 -0
  121. package/template/apps/web/_gitignore +16 -0
  122. package/template/apps/web/knip.config.ts +15 -0
  123. package/template/apps/web/kustomize/base/application.properties +5 -0
  124. package/template/apps/web/kustomize/base/deployment.yaml +60 -0
  125. package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
  126. package/template/apps/web/kustomize/base/service.yaml +10 -0
  127. package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
  128. package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
  129. package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
  130. package/template/apps/web/messages/de.json +124 -0
  131. package/template/apps/web/messages/en.json +124 -0
  132. package/template/apps/web/next-env.d.ts +7 -0
  133. package/template/apps/web/next.config.ts +39 -0
  134. package/template/apps/web/oxfmt.config.ts +9 -0
  135. package/template/apps/web/oxlint.config.ts +36 -0
  136. package/template/apps/web/package.json +80 -0
  137. package/template/apps/web/playwright.config.ts +34 -0
  138. package/template/apps/web/postcss.config.mjs +7 -0
  139. package/template/apps/web/sonar-project.properties +9 -0
  140. package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
  141. package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
  142. package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
  143. package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
  144. package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
  145. package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
  146. package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
  147. package/template/apps/web/src/app/[locale]/page.tsx +23 -0
  148. package/template/apps/web/src/app/api/health/route.ts +3 -0
  149. package/template/apps/web/src/app/global-error.tsx +19 -0
  150. package/template/apps/web/src/app/globals.css +98 -0
  151. package/template/apps/web/src/app/icon.svg +1 -0
  152. package/template/apps/web/src/app/robots.ts +15 -0
  153. package/template/apps/web/src/app/sitemap.ts +24 -0
  154. package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
  155. package/template/apps/web/src/components/alert/alert.tsx +50 -0
  156. package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
  157. package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
  158. package/template/apps/web/src/components/button/button-variants.ts +28 -0
  159. package/template/apps/web/src/components/button/button.stories.tsx +33 -0
  160. package/template/apps/web/src/components/button/button.tsx +32 -0
  161. package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
  162. package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
  163. package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
  164. package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
  165. package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
  166. package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
  167. package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
  168. package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
  169. package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
  170. package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
  171. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
  172. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
  173. package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
  174. package/template/apps/web/src/components/empty-state.tsx +33 -0
  175. package/template/apps/web/src/components/field-group.stories.tsx +47 -0
  176. package/template/apps/web/src/components/field-group.tsx +20 -0
  177. package/template/apps/web/src/components/field-label.stories.tsx +34 -0
  178. package/template/apps/web/src/components/field-label.tsx +28 -0
  179. package/template/apps/web/src/components/icons.tsx +25 -0
  180. package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
  181. package/template/apps/web/src/components/input/input-otp.tsx +184 -0
  182. package/template/apps/web/src/components/input/input.stories.tsx +35 -0
  183. package/template/apps/web/src/components/input/input.tsx +180 -0
  184. package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
  185. package/template/apps/web/src/components/popover/popover.tsx +45 -0
  186. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
  187. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
  188. package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
  189. package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
  190. package/template/apps/web/src/components/select/select.stories.tsx +293 -0
  191. package/template/apps/web/src/components/select/select.tsx +445 -0
  192. package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
  193. package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
  194. package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
  195. package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
  196. package/template/apps/web/src/components/switch/switch.tsx +35 -0
  197. package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
  198. package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
  199. package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
  200. package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
  201. package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
  202. package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
  203. package/template/apps/web/src/components/toast/toaster.tsx +38 -0
  204. package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
  205. package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
  206. package/template/apps/web/src/config/csp.ts +59 -0
  207. package/template/apps/web/src/config/routes.ts +14 -0
  208. package/template/apps/web/src/config/site.ts +5 -0
  209. package/template/apps/web/src/env.ts +15 -0
  210. package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
  211. package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
  212. package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
  213. package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
  214. package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
  215. package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
  216. package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
  217. package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
  218. package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
  219. package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
  220. package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
  221. package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
  222. package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
  223. package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
  224. package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
  225. package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
  226. package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
  227. package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
  228. package/template/apps/web/src/features/shared/provider.tsx +16 -0
  229. package/template/apps/web/src/features/shared/site-header.tsx +48 -0
  230. package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
  231. package/template/apps/web/src/i18n/navigation.ts +5 -0
  232. package/template/apps/web/src/i18n/request.ts +21 -0
  233. package/template/apps/web/src/i18n/routing.ts +6 -0
  234. package/template/apps/web/src/lib/auth-client.ts +19 -0
  235. package/template/apps/web/src/lib/auth-server.ts +46 -0
  236. package/template/apps/web/src/lib/auth.ts +8 -0
  237. package/template/apps/web/src/lib/codegen.ts +30 -0
  238. package/template/apps/web/src/lib/graphql-client.ts +29 -0
  239. package/template/apps/web/src/lib/graphql-server.ts +28 -0
  240. package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
  241. package/template/apps/web/src/lib/graphql-utils.ts +76 -0
  242. package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
  243. package/template/apps/web/src/lib/metadata.ts +17 -0
  244. package/template/apps/web/src/lib/network-error.test.ts +30 -0
  245. package/template/apps/web/src/lib/network-error.ts +17 -0
  246. package/template/apps/web/src/lib/proxy-utils.ts +42 -0
  247. package/template/apps/web/src/lib/query-client.ts +29 -0
  248. package/template/apps/web/src/lib/utils.ts +5 -0
  249. package/template/apps/web/src/proxy.ts +57 -0
  250. package/template/apps/web/test-results/.last-run.json +4 -0
  251. package/template/apps/web/tests/auth.spec.ts +17 -0
  252. package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
  253. package/template/apps/web/tests/home.spec.ts +25 -0
  254. package/template/apps/web/tsconfig.build.json +4 -0
  255. package/template/apps/web/tsconfig.json +28 -0
  256. package/template/apps/web/vitest.config.ts +40 -0
  257. package/template/apps/web/vitest.shims.d.ts +1 -0
  258. package/template/apps/worker/.env.example +4 -0
  259. package/template/apps/worker/AGENTS.md +11 -0
  260. package/template/apps/worker/Dockerfile +35 -0
  261. package/template/apps/worker/knip.config.ts +3 -0
  262. package/template/apps/worker/kustomize/base/application.properties +4 -0
  263. package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
  264. package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
  265. package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
  266. package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
  267. package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
  268. package/template/apps/worker/oxfmt.config.ts +8 -0
  269. package/template/apps/worker/oxlint.config.ts +15 -0
  270. package/template/apps/worker/package.json +33 -0
  271. package/template/apps/worker/sonar-project.properties +8 -0
  272. package/template/apps/worker/src/env.ts +13 -0
  273. package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
  274. package/template/apps/worker/src/lib/logger.ts +20 -0
  275. package/template/apps/worker/src/main.ts +17 -0
  276. package/template/apps/worker/src/processors/example-processor.ts +15 -0
  277. package/template/apps/worker/src/worker/redis.ts +19 -0
  278. package/template/apps/worker/src/worker/worker.ts +37 -0
  279. package/template/apps/worker/tests/example-processor.test.ts +13 -0
  280. package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
  281. package/template/apps/worker/tsconfig.json +4 -0
  282. package/template/apps/worker/vitest.config.ts +13 -0
  283. package/template/bun.lock +3053 -0
  284. package/template/bunfig.toml +2 -0
  285. package/template/compose.yaml +53 -0
  286. package/template/docker-bake.hcl +86 -0
  287. package/template/package.json +47 -0
  288. package/template/packages/lint/knip.ts +40 -0
  289. package/template/packages/lint/oxfmt.ts +8 -0
  290. package/template/packages/lint/oxlint.ts +136 -0
  291. package/template/packages/lint/package.json +13 -0
  292. package/template/packages/queues/oxfmt.config.ts +8 -0
  293. package/template/packages/queues/oxlint.config.ts +7 -0
  294. package/template/packages/queues/package.json +15 -0
  295. package/template/packages/queues/src/example-job.ts +11 -0
  296. package/template/packages/queues/src/index.ts +3 -0
  297. package/template/packages/queues/src/queue-names.ts +2 -0
  298. package/template/packages/queues/src/redis-connection.ts +27 -0
  299. package/template/packages/queues/tsconfig.json +4 -0
  300. package/template/packages/tsconfig/base.json +23 -0
  301. package/template/packages/tsconfig/node.json +11 -0
  302. package/template/packages/tsconfig/package.json +4 -0
  303. package/template/turbo.json +50 -0
@@ -0,0 +1,5 @@
1
+ import { createNavigation } from "next-intl/navigation";
2
+ import { routing } from "./routing";
3
+
4
+ export const { Link, useRouter } = createNavigation(routing); // @if auth
5
+ // export const { Link } = createNavigation(routing); // @if !auth
@@ -0,0 +1,21 @@
1
+ import { hasLocale } from "next-intl";
2
+ import { getRequestConfig } from "next-intl/server";
3
+ import { notFound } from "next/navigation";
4
+ import { locale as rootLocale } from "next/root-params";
5
+ import { routing } from "./routing";
6
+
7
+ export default getRequestConfig(async ({ locale }) => {
8
+ if (!locale) {
9
+ const paramValue = await rootLocale();
10
+ if (hasLocale(routing.locales, paramValue)) {
11
+ locale = paramValue;
12
+ } else {
13
+ notFound();
14
+ }
15
+ }
16
+
17
+ return {
18
+ locale,
19
+ messages: (await import(`../../messages/${locale}.json`)).default,
20
+ };
21
+ });
@@ -0,0 +1,6 @@
1
+ import { defineRouting } from "next-intl/routing";
2
+
3
+ export const routing = defineRouting({
4
+ defaultLocale: "en",
5
+ locales: ["en", "de"],
6
+ });
@@ -0,0 +1,19 @@
1
+ import { passkeyClient } from "@better-auth/passkey/client";
2
+ import { createAuthClient } from "better-auth/client";
3
+ import { oneTapClient } from "better-auth/client/plugins";
4
+ import { env } from "@/env";
5
+
6
+ /** Google sign-in is optional: without a client ID the button and One Tap stay hidden. */
7
+ export const isGoogleEnabled = Boolean(env.NEXT_PUBLIC_GOOGLE_CLIENT_ID);
8
+
9
+ export const authClient = createAuthClient({
10
+ baseURL: env.NEXT_PUBLIC_API_URL,
11
+ plugins: [oneTapClient({ clientId: env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "" }), passkeyClient()],
12
+ });
13
+
14
+ export type AuthError = {
15
+ code?: string;
16
+ message?: string;
17
+ status: number;
18
+ statusText: string;
19
+ };
@@ -0,0 +1,46 @@
1
+ import type { User as BetterAuthUser, Session } from "better-auth";
2
+ import { env } from "@/env";
3
+
4
+ export type SessionResponse = null | {
5
+ session: SerializedSession;
6
+ user: SessionUser;
7
+ };
8
+
9
+ /** The Better Auth user plus the fields the API's `customSession` plugin adds. */
10
+ type SessionUser = BetterAuthUser & {
11
+ role?: null | string[];
12
+ };
13
+
14
+ type SerializedSession = Omit<Session, "createdAt" | "expiresAt" | "updatedAt"> & {
15
+ createdAt?: string;
16
+ expiresAt?: string;
17
+ updatedAt?: string;
18
+ };
19
+
20
+ /**
21
+ * Resolves the session behind a `Cookie` header against the API.
22
+ *
23
+ * Only the cookie is forwarded on purpose: passing the incoming request's headers
24
+ * through would include its `Host`, which Bun's fetch sends as-is and thereby
25
+ * routes the call to the web host instead of the API.
26
+ */
27
+ export async function fetchSession(cookie: null | string | undefined): Promise<SessionResponse> {
28
+ if (!cookie) {
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/auth/get-session`, {
34
+ cache: "no-store",
35
+ headers: { cookie },
36
+ });
37
+
38
+ if (!response.ok) {
39
+ return null;
40
+ }
41
+
42
+ return (await response.json()) as SessionResponse;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
@@ -0,0 +1,8 @@
1
+ import { headers } from "next/headers";
2
+ import { fetchSession } from "@/lib/auth-server";
3
+
4
+ /** The current session in a server component, route handler or action. */
5
+ export async function getSession() {
6
+ const headerStore = await headers();
7
+ return fetchSession(headerStore.get("cookie"));
8
+ }
@@ -0,0 +1,30 @@
1
+ import type { CodegenConfig } from "@graphql-codegen/cli";
2
+
3
+ /**
4
+ * `bun run codegen` — introspects the running API (or `CODEGEN_SCHEMA`) and
5
+ * generates typed `graphql()` documents into src/generated/gql.
6
+ */
7
+ const config: CodegenConfig = {
8
+ documents: ["src/**/*.{ts,tsx}", "!src/generated/**"],
9
+ generates: {
10
+ "./src/generated/gql/": {
11
+ config: {
12
+ enumValues: "./schema-types",
13
+ scalars: { DateTime: "string" },
14
+ },
15
+ preset: "client",
16
+ },
17
+ "./src/generated/gql/schema-types.ts": {
18
+ config: {
19
+ scalars: { DateTime: "string" },
20
+ },
21
+ plugins: ["typescript"],
22
+ },
23
+ },
24
+ ignoreNoDocuments: true,
25
+ schema:
26
+ process.env.CODEGEN_SCHEMA ??
27
+ `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"}/graphql`,
28
+ };
29
+
30
+ export default config;
@@ -0,0 +1,29 @@
1
+ import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
2
+ import { GraphQLClient } from "graphql-request";
3
+ import { env } from "@/env";
4
+ import { toApiError } from "./graphql-utils";
5
+
6
+ const client = new GraphQLClient(`${env.NEXT_PUBLIC_API_URL}/graphql`, {
7
+ credentials: "include",
8
+ });
9
+
10
+ /** Browser-side API client: sends the session cookie, throws `ApiError`s. */
11
+ export const graphqlClient = {
12
+ async request<TData, TVars>(
13
+ doc: TypedDocumentNode<TData, TVars>,
14
+ variables?: TVars,
15
+ ): Promise<TData> {
16
+ try {
17
+ // @ts-expect-error -- graphql-request's overloads don't accept an optional variables arg
18
+ return await client.request<TData, TVars>(doc, variables);
19
+ } catch (error) {
20
+ const apiError = toApiError(error);
21
+ // @if auth
22
+ if (apiError.code === "UNAUTHORIZED" && typeof window !== "undefined") {
23
+ window.location.href = "/sign-in";
24
+ }
25
+ // @endif
26
+ throw apiError;
27
+ }
28
+ },
29
+ };
@@ -0,0 +1,28 @@
1
+ import "server-only";
2
+ import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
3
+ import { GraphQLClient } from "graphql-request";
4
+ import { env } from "@/env";
5
+ import type { ApiError } from "./graphql-utils";
6
+ import { toApiError } from "./graphql-utils";
7
+
8
+ const client = new GraphQLClient(`${env.NEXT_PUBLIC_API_URL}/graphql`);
9
+
10
+ type APIResult<TData> = { data?: TData; error?: ApiError };
11
+
12
+ /** Server-side API client: forward the incoming cookie to act as the user. */
13
+ export async function APIClient<TData, TVars>(
14
+ doc: TypedDocumentNode<TData, TVars>,
15
+ { cookie, variables }: { cookie?: string; variables?: TVars } = {},
16
+ ): Promise<APIResult<TData>> {
17
+ try {
18
+ // @ts-expect-error -- graphql-request's overloads don't accept an optional variables arg
19
+ const data = await client.request<TData, TVars>(
20
+ doc,
21
+ variables,
22
+ cookie ? { cookie } : undefined,
23
+ );
24
+ return { data };
25
+ } catch (error: unknown) {
26
+ return { error: toApiError(error) };
27
+ }
28
+ }
@@ -0,0 +1,32 @@
1
+ import { ClientError } from "graphql-request";
2
+ import { describe, expect, it } from "vitest";
3
+ import { apiErrorMessage, toApiError } from "./graphql-utils";
4
+
5
+ describe("toApiError", () => {
6
+ it("maps a dropped connection to NETWORK_ERROR", () => {
7
+ expect(toApiError(new TypeError("Failed to fetch")).code).toBe("NETWORK_ERROR");
8
+ });
9
+
10
+ it("reads the code and params from the first GraphQL error", () => {
11
+ const response = {
12
+ errors: [{ extensions: { code: "NOT_FOUND", params: { id: "1" } }, message: "NOT_FOUND" }],
13
+ status: 200,
14
+ } as unknown as ConstructorParameters<typeof ClientError>[0];
15
+ const error = new ClientError(response, { query: "query { x }" });
16
+
17
+ expect(toApiError(error)).toMatchObject({ code: "NOT_FOUND", params: { id: "1" } });
18
+ });
19
+ });
20
+
21
+ describe("apiErrorMessage", () => {
22
+ const t = (key: string) => `t:${key}`;
23
+
24
+ it("translates known codes", () => {
25
+ expect(apiErrorMessage(t, { code: "UNAUTHORIZED" })).toBe("t:UNAUTHORIZED");
26
+ });
27
+
28
+ it("falls back for unknown or missing codes", () => {
29
+ expect(apiErrorMessage(t, { code: "SOMETHING_NEW" })).toBe("t:defaultError");
30
+ expect(apiErrorMessage(t, null)).toBe("t:defaultError");
31
+ });
32
+ });
@@ -0,0 +1,76 @@
1
+ import { ClientError } from "graphql-request";
2
+ import { isNetworkError } from "./network-error";
3
+
4
+ export type ApiErrorParams = Record<string, unknown>;
5
+
6
+ /**
7
+ * A backend failure, carrying the machine-readable `code` the API returns
8
+ * (never prose) plus any structured `params` for interpolation.
9
+ *
10
+ * `message` mirrors `code` so anything that still reads `.message` sees the
11
+ * code rather than blank text, but display code should go through
12
+ * `apiErrorMessage` (backed by the `ApiErrors` next-intl namespace) instead of
13
+ * showing `.message` directly.
14
+ */
15
+ export class ApiError extends Error {
16
+ readonly code?: string;
17
+ readonly params?: ApiErrorParams;
18
+
19
+ constructor(code?: string, params?: ApiErrorParams) {
20
+ super(code ?? "UNKNOWN_ERROR");
21
+ this.name = "ApiError";
22
+ this.code = code;
23
+ this.params = params;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Builds the `ApiError` a `graphql-request` failure carries. A request that
29
+ * never reached the API gets the client-side `NETWORK_ERROR` code; anything
30
+ * else without a response error is codeless.
31
+ */
32
+ export function toApiError(error: unknown): ApiError {
33
+ if (isNetworkError(error)) return new ApiError("NETWORK_ERROR");
34
+
35
+ const first = error instanceof ClientError ? error.response.errors?.[0] : undefined;
36
+ const code = first?.extensions?.code as string | undefined;
37
+ const params = first?.extensions?.params as ApiErrorParams | undefined;
38
+ return new ApiError(code, params);
39
+ }
40
+
41
+ /**
42
+ * The API's `ErrorCode` values (plus the client-side `NETWORK_ERROR`) that have
43
+ * their own copy in the `ApiErrors` namespace of `messages/*.json`. Anything else
44
+ * falls back to `ApiErrors.defaultError`, so an unmapped code never shows a raw
45
+ * translation key on screen.
46
+ */
47
+ const TRANSLATED_ERROR_CODES = new Set([
48
+ "BAD_REQUEST",
49
+ "CONFLICT",
50
+ "EMAIL_NOT_VERIFIED",
51
+ "FORBIDDEN",
52
+ "INSUFFICIENT_ROLE",
53
+ "INTERNAL_SERVER_ERROR",
54
+ "NETWORK_ERROR",
55
+ "NOT_FOUND",
56
+ "SERVICE_UNAVAILABLE",
57
+ "TOO_MANY_REQUESTS",
58
+ "UNAUTHORIZED",
59
+ "UNPROCESSABLE_ENTITY",
60
+ "VALIDATION_FAILED",
61
+ ]);
62
+
63
+ /**
64
+ * Translates an API error's `code` through the `ApiErrors` next-intl namespace,
65
+ * falling back to `ApiErrors.defaultError`.
66
+ *
67
+ * @param t - `useTranslations("ApiErrors")` from the calling component
68
+ * @param error - The error a mutation/query's `onError`/`error` carries
69
+ */
70
+ export function apiErrorMessage(
71
+ t: (key: string) => string,
72
+ error: null | undefined | { code?: string },
73
+ ): string {
74
+ const code = error?.code;
75
+ return code && TRANSLATED_ERROR_CODES.has(code) ? t(code) : t("defaultError");
76
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ export function useMediaQuery(query: string) {
6
+ const subscribe = (callback: () => void) => {
7
+ const media = window.matchMedia(query);
8
+ media.addEventListener("change", callback);
9
+ return () => media.removeEventListener("change", callback);
10
+ };
11
+
12
+ const getSnapshot = () => {
13
+ return window.matchMedia(query).matches;
14
+ };
15
+
16
+ const getServerSnapshot = () => {
17
+ return false;
18
+ };
19
+
20
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
21
+ }
@@ -0,0 +1,17 @@
1
+ import type { Metadata } from "next";
2
+ import { siteConfig } from "@/config/site";
3
+
4
+ export function constructMetadata(): Metadata {
5
+ return {
6
+ description: siteConfig.description,
7
+ metadataBase: new URL(siteConfig.url),
8
+ openGraph: {
9
+ description: siteConfig.description,
10
+ title: siteConfig.name,
11
+ },
12
+ title: {
13
+ default: siteConfig.name,
14
+ template: `%s | ${siteConfig.name}`,
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isNetworkError } from "./network-error";
3
+
4
+ describe("isNetworkError", () => {
5
+ it.each([
6
+ ["Firefox", "NetworkError when attempting to fetch resource."],
7
+ ["Chromium", "Failed to fetch"],
8
+ ["Safari", "Load failed"],
9
+ ["Node", "fetch failed"],
10
+ ])("recognises the %s wording of a failed fetch", (_engine, message) => {
11
+ expect(isNetworkError(new TypeError(message))).toBe(true);
12
+ });
13
+
14
+ it("ignores a TypeError that is not a failed request", () => {
15
+ expect(
16
+ isNetworkError(new TypeError("Cannot read properties of undefined (reading 'id')")),
17
+ ).toBe(false);
18
+ });
19
+
20
+ it("ignores an error that merely carries the same text", () => {
21
+ expect(isNetworkError(new Error("Failed to fetch"))).toBe(false);
22
+ expect(isNetworkError({ message: "Failed to fetch" })).toBe(false);
23
+ });
24
+
25
+ it("ignores values that are not errors at all", () => {
26
+ expect(isNetworkError(null)).toBe(false);
27
+ expect(isNetworkError(undefined)).toBe(false);
28
+ expect(isNetworkError("Failed to fetch")).toBe(false);
29
+ });
30
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Wording each engine puts on the `TypeError` `fetch` rejects with when the
3
+ * request never got an HTTP response (offline, DNS, refused connection, CORS
4
+ * block): Firefox, Chromium, Safari, and Node's undici for server-side calls.
5
+ */
6
+ const FETCH_FAILURE_MESSAGE =
7
+ /^(?:networkerror when attempting to fetch resource|failed to fetch|load failed|fetch failed|network request failed)/i;
8
+
9
+ /**
10
+ * Whether `error` is a request that never reached the API, as opposed to one
11
+ * the API answered with an error. The browser's wording for it (e.g. "NetworkError
12
+ * when attempting to fetch resource.") is meaningless to a user, so callers
13
+ * show translated copy instead of `error.message`.
14
+ */
15
+ export function isNetworkError(error: unknown): boolean {
16
+ return error instanceof TypeError && FETCH_FAILURE_MESSAGE.test(error.message);
17
+ }
@@ -0,0 +1,42 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { generateCspHeader } from "@/config/csp";
3
+
4
+ /**
5
+ * Generates a per-request nonce + CSP and returns the request to hand to the
6
+ * next middleware (so Next.js nonces its own scripts) plus a helper that stamps
7
+ * the same header on the response.
8
+ */
9
+ export function createCspContext(request: NextRequest) {
10
+ const nonce = crypto.randomUUID().replaceAll("-", "");
11
+ const cspHeader = generateCspHeader({ nonce });
12
+ const requestHeaders = new Headers(request.headers);
13
+ requestHeaders.set("Content-Security-Policy", cspHeader);
14
+ requestHeaders.set("x-nonce", nonce);
15
+
16
+ return {
17
+ applyTo(response: NextResponse) {
18
+ response.headers.set("Content-Security-Policy", cspHeader);
19
+ return response;
20
+ },
21
+ request: new NextRequest(request, { headers: requestHeaders }),
22
+ };
23
+ }
24
+ // @if auth
25
+
26
+ /** Sends the user to sign-in, remembering where they wanted to go. */
27
+ export function redirectToSignIn(
28
+ request: NextRequest,
29
+ pathname: string,
30
+ locale: string,
31
+ ): NextResponse {
32
+ const signInUrl = new URL(`/${locale}/sign-in`, request.url);
33
+
34
+ const requestedPath =
35
+ (locale ? pathname.slice(locale.length + 1) : pathname) + request.nextUrl.search;
36
+ if (requestedPath && requestedPath !== "/" && !requestedPath.startsWith("/sign-in")) {
37
+ signInUrl.searchParams.set("returnTo", requestedPath);
38
+ }
39
+
40
+ return NextResponse.redirect(signInUrl);
41
+ }
42
+ // @endif
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { isServer, QueryClient } from "@tanstack/react-query";
4
+
5
+ function makeQueryClient() {
6
+ return new QueryClient({
7
+ defaultOptions: {
8
+ mutations: {
9
+ onError: (error) => {
10
+ console.error("Query mutation failed:", error);
11
+ },
12
+ },
13
+ queries: {
14
+ staleTime: 60 * 1000,
15
+ },
16
+ },
17
+ });
18
+ }
19
+
20
+ let browserQueryClient: QueryClient | undefined;
21
+
22
+ export function getQueryClient() {
23
+ if (isServer) {
24
+ return makeQueryClient();
25
+ } else {
26
+ browserQueryClient ??= makeQueryClient();
27
+ return browserQueryClient;
28
+ }
29
+ }
@@ -0,0 +1,5 @@
1
+ import type { ClassValue } from "clsx";
2
+ import { clsx } from "clsx";
3
+ import { twMerge } from "tailwind-merge";
4
+
5
+ export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
@@ -0,0 +1,57 @@
1
+ import createMiddleware from "next-intl/middleware";
2
+ import type { NextRequest } from "next/server";
3
+ import { NextResponse } from "next/server"; // @if auth
4
+ import { guestOnlyRoutes, publicPaths } from "./config/routes"; // @if auth
5
+ import { routing } from "./i18n/routing";
6
+ import { fetchSession } from "./lib/auth-server"; // @if auth
7
+ import { createCspContext, redirectToSignIn } from "./lib/proxy-utils"; // @if auth
8
+ // import { createCspContext } from "./lib/proxy-utils"; // @if !auth
9
+
10
+ const i18nMiddleware = createMiddleware(routing);
11
+
12
+ export async function proxy(request: NextRequest) {
13
+ const csp = createCspContext(request);
14
+ const response = await handleRequest(request, csp.request);
15
+ return csp.applyTo(response);
16
+ }
17
+
18
+ // @if auth
19
+ async function handleRequest(request: NextRequest, requestWithCsp: NextRequest) {
20
+ const i18nResponse = i18nMiddleware(requestWithCsp);
21
+ if (i18nResponse.status >= 300 && i18nResponse.status < 400) {
22
+ return i18nResponse;
23
+ }
24
+
25
+ const { pathname } = request.nextUrl;
26
+ const locale = pathname.match(/^\/([^/]+)/)?.[1] ?? routing.defaultLocale;
27
+ const route = pathname.slice(locale.length + 1) || "/";
28
+ const session = await fetchSession(request.headers.get("cookie"));
29
+
30
+ if (!session && !publicPaths.has(pathname)) {
31
+ return redirectToSignIn(request, pathname, locale);
32
+ }
33
+
34
+ if (session && guestOnlyRoutes.includes(route)) {
35
+ return NextResponse.redirect(new URL(`/${locale}/dashboard`, request.url));
36
+ }
37
+
38
+ return i18nResponse;
39
+ }
40
+ // @endif
41
+ // @if !auth
42
+ // function handleRequest(_request: NextRequest, requestWithCsp: NextRequest) {
43
+ // return i18nMiddleware(requestWithCsp);
44
+ // }
45
+ // @endif
46
+
47
+ export const config = {
48
+ matcher: [
49
+ {
50
+ missing: [
51
+ { key: "next-router-prefetch", type: "header" },
52
+ { key: "purpose", type: "header", value: "prefetch" },
53
+ ],
54
+ source: "/((?!api|_next|.*\\.[\\w]+$).*)",
55
+ },
56
+ ],
57
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "status": "passed",
3
+ "failedTests": []
4
+ }
@@ -0,0 +1,17 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test.describe("auth", () => {
4
+ test("sends anonymous visitors of a protected page to sign-in", async ({ page }) => {
5
+ await page.goto("/en/dashboard");
6
+
7
+ await expect(page).toHaveURL(/\/en\/sign-in\?returnTo=%2Fdashboard$/);
8
+ });
9
+
10
+ test("validates the sign-in form before calling the API", async ({ page }) => {
11
+ await page.goto("/en/sign-in");
12
+
13
+ await page.getByRole("button", { name: "Sign in" }).click();
14
+
15
+ await expect(page.getByText("Enter a valid email address.")).toBeVisible();
16
+ });
17
+ });
@@ -0,0 +1,37 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ /**
4
+ * Guards the hash allowlist in src/config/csp.ts: next-themes' theme-init
5
+ * <script> (see src/features/shared/provider.tsx) gets no per-request nonce,
6
+ * so it only runs if its exact rendered bytes match one of the checked-in
7
+ * sha256 hashes. Turbopack renders it differently in dev vs. `next build`, so
8
+ * a next-themes/Turbopack upgrade can silently drift the hash and get the
9
+ * script CSP-blocked (theme flash, no thrown error) without this failing.
10
+ */
11
+ test.describe("CSP: theme init script", () => {
12
+ test("next-themes inline script runs without a CSP violation", async ({ page }) => {
13
+ await page.addInitScript(() => {
14
+ (window as unknown as { __cspViolations: string[] }).__cspViolations = [];
15
+ window.addEventListener("securitypolicyviolation", (event) => {
16
+ (window as unknown as { __cspViolations: string[] }).__cspViolations.push(
17
+ `${event.violatedDirective}: ${event.blockedURI}`,
18
+ );
19
+ });
20
+ });
21
+
22
+ await page.goto("http://localhost:3000/en");
23
+ await page.waitForLoadState("domcontentloaded");
24
+
25
+ const appliedThemeClass = await page.evaluate(
26
+ () =>
27
+ document.documentElement.classList.contains("light") ||
28
+ document.documentElement.classList.contains("dark"),
29
+ );
30
+ expect(appliedThemeClass, "next-themes should apply light/dark before hydration").toBe(true);
31
+
32
+ const violations = await page.evaluate(
33
+ () => (window as unknown as { __cspViolations: string[] }).__cspViolations,
34
+ );
35
+ expect(violations, "a hash in src/config/csp.ts is stale — see the comment there").toEqual([]);
36
+ });
37
+ });
@@ -0,0 +1,25 @@
1
+ import AxeBuilder from "@axe-core/playwright";
2
+ import { expect, test } from "@playwright/test";
3
+
4
+ test.describe("home", () => {
5
+ test("renders the hero without accessibility violations", async ({ page }) => {
6
+ await page.goto("/en");
7
+
8
+ await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
9
+
10
+ const results = await new AxeBuilder({ page }).analyze();
11
+ expect(results.violations).toEqual([]);
12
+ });
13
+
14
+ test("redirects the bare root to the default locale", async ({ page }) => {
15
+ await page.goto("/");
16
+
17
+ await expect(page).toHaveURL(/\/en$/);
18
+ });
19
+
20
+ test("serves the German locale", async ({ page }) => {
21
+ await page.goto("/de");
22
+
23
+ await expect(page.locator("html")).toHaveAttribute("lang", "de");
24
+ });
25
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", ".storybook", "**/*.stories.ts", "**/*.stories.tsx"]
4
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "extends": "@nexst/tsconfig/base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "lib": ["dom", "dom.iterable", "esnext"],
6
+ "module": "esnext",
7
+ "moduleResolution": "bundler",
8
+ "paths": {
9
+ "@/*": ["./src/*"],
10
+ "~sb/*": ["./.storybook/*"]
11
+ },
12
+ "allowJs": true,
13
+ "plugins": [
14
+ {
15
+ "name": "next"
16
+ }
17
+ ]
18
+ },
19
+ "include": [
20
+ "next-env.d.ts",
21
+ "**/*.ts",
22
+ "**/*.tsx",
23
+ ".storybook/**/*.ts",
24
+ ".storybook/**/*.tsx",
25
+ ".next/types/**/*.ts",
26
+ ".next/dev/types/**/*.ts"
27
+ ]
28
+ }