@stjoseph/prumo 0.0.1

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 (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,224 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { cn } from 'cn'
3
+ import { useMemo } from 'react'
4
+
5
+ import { Label } from '@/components/ui/label'
6
+ import { Separator } from '@/components/ui/separator'
7
+
8
+ function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
9
+ return (
10
+ <fieldset
11
+ data-slot="field-set"
12
+ className={cn(
13
+ 'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ function FieldLegend({
22
+ className,
23
+ variant = 'legend',
24
+ ...props
25
+ }: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
26
+ return (
27
+ <legend
28
+ data-slot="field-legend"
29
+ data-variant={variant}
30
+ className={cn(
31
+ 'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
32
+ className,
33
+ )}
34
+ {...props}
35
+ />
36
+ )
37
+ }
38
+
39
+ function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
40
+ return (
41
+ <div
42
+ data-slot="field-group"
43
+ className={cn(
44
+ 'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
45
+ className,
46
+ )}
47
+ {...props}
48
+ />
49
+ )
50
+ }
51
+
52
+ const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', {
53
+ variants: {
54
+ orientation: {
55
+ vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
56
+ horizontal:
57
+ 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
58
+ responsive:
59
+ 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
60
+ },
61
+ },
62
+ defaultVariants: {
63
+ orientation: 'vertical',
64
+ },
65
+ })
66
+
67
+ function Field({
68
+ className,
69
+ orientation = 'vertical',
70
+ ...props
71
+ }: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
72
+ return (
73
+ <div
74
+ role="group"
75
+ data-slot="field"
76
+ data-orientation={orientation}
77
+ className={cn(fieldVariants({ orientation }), className)}
78
+ {...props}
79
+ />
80
+ )
81
+ }
82
+
83
+ function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
84
+ return (
85
+ <div
86
+ data-slot="field-content"
87
+ className={cn('group/field-content flex flex-1 flex-col gap-0.5 leading-snug', className)}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
94
+ return (
95
+ <Label
96
+ data-slot="field-label"
97
+ className={cn(
98
+ 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
99
+ 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
100
+ className,
101
+ )}
102
+ {...props}
103
+ />
104
+ )
105
+ }
106
+
107
+ function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
108
+ return (
109
+ <div
110
+ data-slot="field-label"
111
+ className={cn(
112
+ 'flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50',
113
+ className,
114
+ )}
115
+ {...props}
116
+ />
117
+ )
118
+ }
119
+
120
+ function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
121
+ return (
122
+ <p
123
+ data-slot="field-description"
124
+ className={cn(
125
+ 'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
126
+ 'last:mt-0 nth-last-2:-mt-1',
127
+ '[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
128
+ className,
129
+ )}
130
+ {...props}
131
+ />
132
+ )
133
+ }
134
+
135
+ function FieldSeparator({
136
+ children,
137
+ className,
138
+ ...props
139
+ }: React.ComponentProps<'div'> & {
140
+ children?: React.ReactNode
141
+ }) {
142
+ return (
143
+ <div
144
+ data-slot="field-separator"
145
+ data-content={!!children}
146
+ className={cn(
147
+ 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
148
+ className,
149
+ )}
150
+ {...props}
151
+ >
152
+ <Separator className="absolute inset-0 top-1/2" />
153
+ {children && (
154
+ <span
155
+ className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
156
+ data-slot="field-separator-content"
157
+ >
158
+ {children}
159
+ </span>
160
+ )}
161
+ </div>
162
+ )
163
+ }
164
+
165
+ function FieldError({
166
+ className,
167
+ children,
168
+ errors,
169
+ ...props
170
+ }: React.ComponentProps<'div'> & {
171
+ errors?: Array<{ message?: string } | undefined>
172
+ }) {
173
+ const content = useMemo(() => {
174
+ if (children) {
175
+ return children
176
+ }
177
+
178
+ if (!errors?.length) {
179
+ return null
180
+ }
181
+
182
+ const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
183
+
184
+ if (uniqueErrors?.length === 1) {
185
+ return uniqueErrors[0]?.message
186
+ }
187
+
188
+ return (
189
+ <ul className="ml-4 flex list-disc flex-col gap-1">
190
+ {uniqueErrors.map(
191
+ (error) => error?.message && <li key={error.message}>{error.message}</li>,
192
+ )}
193
+ </ul>
194
+ )
195
+ }, [children, errors])
196
+
197
+ if (!content) {
198
+ return null
199
+ }
200
+
201
+ return (
202
+ <div
203
+ role="alert"
204
+ data-slot="field-error"
205
+ className={cn('text-sm font-normal text-destructive', className)}
206
+ {...props}
207
+ >
208
+ {content}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export {
214
+ Field,
215
+ FieldContent,
216
+ FieldDescription,
217
+ FieldError,
218
+ FieldGroup,
219
+ FieldLabel,
220
+ FieldLegend,
221
+ FieldSeparator,
222
+ FieldSet,
223
+ FieldTitle,
224
+ }
@@ -0,0 +1,19 @@
1
+ import { Input as InputPrimitive } from '@base-ui/react/input'
2
+ import { cn } from 'cn'
3
+ import type * as React from 'react'
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
6
+ return (
7
+ <InputPrimitive
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ 'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ export { Input }
@@ -0,0 +1,17 @@
1
+ import { cn } from 'cn'
2
+ import type * as React from 'react'
3
+
4
+ function Label({ className, ...props }: React.ComponentProps<'label'>) {
5
+ return (
6
+ <label
7
+ data-slot="label"
8
+ className={cn(
9
+ 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
10
+ className,
11
+ )}
12
+ {...props}
13
+ />
14
+ )
15
+ }
16
+
17
+ export { Label }
@@ -0,0 +1,20 @@
1
+ 'use client'
2
+
3
+ import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
4
+ import { cn } from 'cn'
5
+
6
+ function Separator({ className, orientation = 'horizontal', ...props }: SeparatorPrimitive.Props) {
7
+ return (
8
+ <SeparatorPrimitive
9
+ data-slot="separator"
10
+ orientation={orientation}
11
+ className={cn(
12
+ 'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Separator }
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod'
2
+
3
+ const schema = z.object({
4
+ VITE_API_URL: z.url(),
5
+ })
6
+
7
+ export const config = schema.parse(import.meta.env)
@@ -0,0 +1,12 @@
1
+ import { createAuthClient } from 'better-auth/react'
2
+ import type { Transport } from '@/api-contract'
3
+
4
+ export type AuthClient = ReturnType<typeof createAuth>
5
+
6
+ export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
7
+ return createAuthClient({
8
+ baseURL: baseUrl,
9
+ basePath: '/api/auth',
10
+ fetchOptions: { customFetchImpl: fetch },
11
+ })
12
+ }
@@ -0,0 +1,17 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import { ApiError } from '@/api-contract'
3
+ import type { AuthClient } from './auth-client'
4
+
5
+ export const sessionQuery = (auth: AuthClient) =>
6
+ queryOptions({
7
+ queryKey: ['session'],
8
+ queryFn: async () => {
9
+ const { data, error } = await auth.getSession()
10
+
11
+ if (error !== null) {
12
+ throw ApiError.fromAuthError(error)
13
+ }
14
+
15
+ return data
16
+ },
17
+ })
@@ -0,0 +1,30 @@
1
+ import { screen, within } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { fakeTransport, json } from '../../../test/fake-transport'
5
+ import { renderApp } from '../../../test/render-app'
6
+
7
+ describe('SignInForm', () => {
8
+ it('shows a rejected sign-in as a form-level error', async () => {
9
+ const user = userEvent.setup()
10
+
11
+ renderApp(
12
+ '/sign-in',
13
+ fakeTransport({
14
+ 'POST /api/auth/sign-in/email': () =>
15
+ json(401, { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }),
16
+ }),
17
+ )
18
+
19
+ await user.type(await screen.findByLabelText('Email'), 'ana@example.com')
20
+ await user.type(screen.getByLabelText('Password'), 'wrong-password')
21
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
22
+
23
+ const form = screen.getByRole('button', { name: 'Sign in' }).closest('form')
24
+
25
+ expect(form).not.toBeNull()
26
+ expect(
27
+ await within(form as HTMLFormElement).findByText('Invalid email or password'),
28
+ ).toBeInTheDocument()
29
+ })
30
+ })
@@ -0,0 +1,73 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { Link, useNavigate } from '@tanstack/react-router'
4
+ import { useForm } from 'react-hook-form'
5
+ import { z } from 'zod'
6
+ import { ApiError } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
+ import { Input } from '@/components/ui/input'
10
+ import { FormError } from '@/features/forms/form-error'
11
+ import { applyServerError } from '@/features/forms/server-errors'
12
+ import type { AuthClient } from './auth-client'
13
+ import { sessionQuery } from './session'
14
+
15
+ const schema = z.object({
16
+ email: z.email(),
17
+ password: z.string().min(1),
18
+ })
19
+
20
+ type SignInValues = z.infer<typeof schema>
21
+
22
+ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: string | undefined }) {
23
+ const queryClient = useQueryClient()
24
+ const navigate = useNavigate()
25
+ const form = useForm<SignInValues>({
26
+ resolver: zodResolver(schema),
27
+ mode: 'onTouched',
28
+ reValidateMode: 'onChange',
29
+ defaultValues: { email: '', password: '' },
30
+ })
31
+ const { errors, isSubmitting } = form.formState
32
+
33
+ async function onSubmit(values: SignInValues) {
34
+ const { error } = await auth.signIn.email(values)
35
+
36
+ if (error !== null) {
37
+ applyServerError(ApiError.fromAuthError(error), ['email', 'password'], form.setError)
38
+ return
39
+ }
40
+
41
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
42
+ await navigate({ to: redirect ?? '/' })
43
+ }
44
+
45
+ return (
46
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
47
+ <FormError errors={errors} />
48
+ <FieldGroup>
49
+ <Field data-invalid={errors.email !== undefined}>
50
+ <FieldLabel htmlFor="email">Email</FieldLabel>
51
+ <Input id="email" type="email" autoComplete="email" {...form.register('email')} />
52
+ <FieldError errors={[errors.email]} />
53
+ </Field>
54
+ <Field data-invalid={errors.password !== undefined}>
55
+ <FieldLabel htmlFor="password">Password</FieldLabel>
56
+ <Input
57
+ id="password"
58
+ type="password"
59
+ autoComplete="current-password"
60
+ {...form.register('password')}
61
+ />
62
+ <FieldError errors={[errors.password]} />
63
+ </Field>
64
+ </FieldGroup>
65
+ <Button type="submit" disabled={isSubmitting}>
66
+ Sign in
67
+ </Button>
68
+ <p className="text-sm text-muted-foreground">
69
+ No account? <Link to="/sign-up">Sign up</Link>
70
+ </p>
71
+ </form>
72
+ )
73
+ }
@@ -0,0 +1,79 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { Link, useNavigate } from '@tanstack/react-router'
4
+ import { useForm } from 'react-hook-form'
5
+ import { z } from 'zod'
6
+ import { ApiError } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
+ import { Input } from '@/components/ui/input'
10
+ import { FormError } from '@/features/forms/form-error'
11
+ import { applyServerError } from '@/features/forms/server-errors'
12
+ import type { AuthClient } from './auth-client'
13
+ import { sessionQuery } from './session'
14
+
15
+ const schema = z.object({
16
+ name: z.string().min(1).max(80),
17
+ email: z.email(),
18
+ password: z.string().min(8),
19
+ })
20
+
21
+ type SignUpValues = z.infer<typeof schema>
22
+
23
+ export function SignUpForm({ auth }: { auth: AuthClient }) {
24
+ const queryClient = useQueryClient()
25
+ const navigate = useNavigate()
26
+ const form = useForm<SignUpValues>({
27
+ resolver: zodResolver(schema),
28
+ mode: 'onTouched',
29
+ reValidateMode: 'onChange',
30
+ defaultValues: { name: '', email: '', password: '' },
31
+ })
32
+ const { errors, isSubmitting } = form.formState
33
+
34
+ async function onSubmit(values: SignUpValues) {
35
+ const { error } = await auth.signUp.email(values)
36
+
37
+ if (error !== null) {
38
+ applyServerError(ApiError.fromAuthError(error), ['name', 'email', 'password'], form.setError)
39
+ return
40
+ }
41
+
42
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
43
+ await navigate({ to: '/' })
44
+ }
45
+
46
+ return (
47
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
48
+ <FormError errors={errors} />
49
+ <FieldGroup>
50
+ <Field data-invalid={errors.name !== undefined}>
51
+ <FieldLabel htmlFor="name">Name</FieldLabel>
52
+ <Input id="name" autoComplete="name" {...form.register('name')} />
53
+ <FieldError errors={[errors.name]} />
54
+ </Field>
55
+ <Field data-invalid={errors.email !== undefined}>
56
+ <FieldLabel htmlFor="email">Email</FieldLabel>
57
+ <Input id="email" type="email" autoComplete="email" {...form.register('email')} />
58
+ <FieldError errors={[errors.email]} />
59
+ </Field>
60
+ <Field data-invalid={errors.password !== undefined}>
61
+ <FieldLabel htmlFor="password">Password</FieldLabel>
62
+ <Input
63
+ id="password"
64
+ type="password"
65
+ autoComplete="new-password"
66
+ {...form.register('password')}
67
+ />
68
+ <FieldError errors={[errors.password]} />
69
+ </Field>
70
+ </FieldGroup>
71
+ <Button type="submit" disabled={isSubmitting}>
72
+ Create account
73
+ </Button>
74
+ <p className="text-sm text-muted-foreground">
75
+ Already registered? <Link to="/sign-in">Sign in</Link>
76
+ </p>
77
+ </form>
78
+ )
79
+ }
@@ -0,0 +1,15 @@
1
+ import type { FieldErrors } from 'react-hook-form'
2
+
3
+ export function FormError({ errors }: { errors: FieldErrors }) {
4
+ const message = errors.root?.server?.message
5
+
6
+ if (message === undefined) {
7
+ return null
8
+ }
9
+
10
+ return (
11
+ <p role="alert" className="text-sm text-destructive">
12
+ {message}
13
+ </p>
14
+ )
15
+ }
@@ -0,0 +1,33 @@
1
+ import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
2
+ import { ApiError } from '@/api-contract'
3
+
4
+ export const FORM_ERROR = 'root.server'
5
+
6
+ export function applyServerError<T extends FieldValues>(
7
+ error: unknown,
8
+ fields: readonly Path<T>[],
9
+ setError: UseFormSetError<T>,
10
+ ): void {
11
+ if (!(error instanceof ApiError)) {
12
+ throw error
13
+ }
14
+
15
+ const unmatched: string[] = []
16
+
17
+ for (const [field, messages] of Object.entries(error.errors)) {
18
+ const message = messages.join(' ')
19
+
20
+ if ((fields as readonly string[]).includes(field)) {
21
+ setError(field as Path<T>, { type: 'server', message })
22
+ } else {
23
+ unmatched.push(message)
24
+ }
25
+ }
26
+
27
+ if (unmatched.length > 0 || Object.keys(error.errors).length === 0) {
28
+ const detail = unmatched.length > 0 ? unmatched.join(' ') : (error.detail ?? error.title)
29
+ const reference = error.requestId === undefined ? '' : ` (${error.requestId})`
30
+
31
+ setError(FORM_ERROR as Path<T>, { type: 'server', message: `${detail}${reference}` })
32
+ }
33
+ }
@@ -0,0 +1,58 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { createClient, type Profile } from '@/api-contract'
6
+ import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
7
+ import { ProfileForm } from './profile-form'
8
+
9
+ const profile: Profile = {
10
+ id: '01a09ffe-635c-77e5-9aeb-d3e9d1a7ee24',
11
+ userId: 'ef51f640-611b-4b9a-a188-510c455539eb',
12
+ displayName: 'Ana',
13
+ locale: 'en',
14
+ timezone: 'UTC',
15
+ createdAt: '2026-09-14T12:57:24.057Z',
16
+ updatedAt: '2026-09-14T12:57:24.057Z',
17
+ }
18
+
19
+ describe('ProfileForm', () => {
20
+ it('puts a server validation message on the field it names', async () => {
21
+ const user = userEvent.setup()
22
+ const api = createClient({
23
+ baseUrl: API_URL,
24
+ fetch: fakeTransport({
25
+ 'PATCH /api/v1/users/me': () =>
26
+ json(
27
+ 400,
28
+ {
29
+ type: 'about:blank',
30
+ title: 'Bad Request',
31
+ status: 400,
32
+ detail: 'Validation failed',
33
+ instance: '/api/v1/users/me',
34
+ requestId: 'req-1',
35
+ errors: { timezone: ['timezone must be a valid IANA zone'] },
36
+ },
37
+ 'application/problem+json',
38
+ ),
39
+ }),
40
+ })
41
+
42
+ render(
43
+ <QueryClientProvider client={new QueryClient()}>
44
+ <ProfileForm api={api} profile={profile} />
45
+ </QueryClientProvider>,
46
+ )
47
+
48
+ await user.click(screen.getByRole('button', { name: 'Save' }))
49
+
50
+ const timezone = screen.getByLabelText('Timezone').closest('[data-slot="field"]')
51
+
52
+ expect(timezone).not.toBeNull()
53
+ expect(timezone).toHaveTextContent('timezone must be a valid IANA zone')
54
+ expect(
55
+ screen.getByLabelText('Display name').closest('[data-slot="field"]'),
56
+ ).not.toHaveTextContent('timezone must be a valid IANA zone')
57
+ })
58
+ })