create-freakpunch 0.0.0-stage → 0.2.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 (216) hide show
  1. package/README.md +24 -3
  2. package/bin/create-freakpunch.mjs +227 -0
  3. package/files/README.md +19 -0
  4. package/files/app.config.ts +50 -0
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -6
  8. package/template/.dockerignore +17 -0
  9. package/template/.oxlintrc.json +9 -0
  10. package/template/AGENTS.md +6 -0
  11. package/template/BACKEND.md +108 -0
  12. package/template/CLAUDE.md +297 -0
  13. package/template/Dockerfile +19 -0
  14. package/template/_gitignore +26 -0
  15. package/template/components.json +27 -0
  16. package/template/design/COMPONENTS.md +145 -0
  17. package/template/design/DESIGN.md +212 -0
  18. package/template/design/PATTERNS.md +110 -0
  19. package/template/index.html +25 -0
  20. package/template/nginx.conf +32 -0
  21. package/template/package-lock.json +6484 -0
  22. package/template/package.json +51 -0
  23. package/template/public/favicon.svg +1 -0
  24. package/template/public/robots.txt +2 -0
  25. package/template/src/app.config.ts +50 -0
  26. package/template/src/app.roles.ts +24 -0
  27. package/template/src/components/ui/alert-dialog.tsx +181 -0
  28. package/template/src/components/ui/avatar.tsx +116 -0
  29. package/template/src/components/ui/badge.tsx +57 -0
  30. package/template/src/components/ui/breadcrumb.tsx +130 -0
  31. package/template/src/components/ui/button.tsx +58 -0
  32. package/template/src/components/ui/calendar.tsx +277 -0
  33. package/template/src/components/ui/card.tsx +99 -0
  34. package/template/src/components/ui/chart.tsx +370 -0
  35. package/template/src/components/ui/checkbox.tsx +28 -0
  36. package/template/src/components/ui/combobox.tsx +329 -0
  37. package/template/src/components/ui/command.tsx +191 -0
  38. package/template/src/components/ui/dialog.tsx +162 -0
  39. package/template/src/components/ui/dropdown-menu.tsx +271 -0
  40. package/template/src/components/ui/empty.tsx +103 -0
  41. package/template/src/components/ui/field.tsx +236 -0
  42. package/template/src/components/ui/input-group.tsx +263 -0
  43. package/template/src/components/ui/input.tsx +45 -0
  44. package/template/src/components/ui/kbd.tsx +31 -0
  45. package/template/src/components/ui/label.tsx +17 -0
  46. package/template/src/components/ui/pagination.tsx +134 -0
  47. package/template/src/components/ui/popover.tsx +87 -0
  48. package/template/src/components/ui/radio-group.tsx +35 -0
  49. package/template/src/components/ui/select.tsx +204 -0
  50. package/template/src/components/ui/separator.tsx +25 -0
  51. package/template/src/components/ui/sheet.tsx +139 -0
  52. package/template/src/components/ui/sidebar.tsx +741 -0
  53. package/template/src/components/ui/skeleton.tsx +13 -0
  54. package/template/src/components/ui/sonner.tsx +78 -0
  55. package/template/src/components/ui/spinner.tsx +42 -0
  56. package/template/src/components/ui/switch.tsx +29 -0
  57. package/template/src/components/ui/table.tsx +121 -0
  58. package/template/src/components/ui/tabs.tsx +191 -0
  59. package/template/src/components/ui/textarea.tsx +17 -0
  60. package/template/src/components/ui/toggle-group.tsx +94 -0
  61. package/template/src/components/ui/toggle.tsx +48 -0
  62. package/template/src/components/ui/tooltip.tsx +91 -0
  63. package/template/src/core/api/demo.ts +8 -0
  64. package/template/src/core/api/http.ts +76 -0
  65. package/template/src/core/api/mock.ts +88 -0
  66. package/template/src/core/api/rest.ts +67 -0
  67. package/template/src/core/api/types.ts +21 -0
  68. package/template/src/core/audit/audit.service.ts +94 -0
  69. package/template/src/core/audit/hooks.ts +19 -0
  70. package/template/src/core/auth/auth-store.ts +52 -0
  71. package/template/src/core/auth/auth.service.ts +86 -0
  72. package/template/src/core/auth/can.tsx +39 -0
  73. package/template/src/core/auth/dev-role-switcher.tsx +64 -0
  74. package/template/src/core/auth/local-credentials.ts +46 -0
  75. package/template/src/core/auth/permissions.ts +115 -0
  76. package/template/src/core/auth/require-auth.tsx +16 -0
  77. package/template/src/core/auth/require-permission.tsx +24 -0
  78. package/template/src/core/auth/roles.ts +9 -0
  79. package/template/src/core/auth/types.ts +20 -0
  80. package/template/src/core/auth/use-auth.ts +19 -0
  81. package/template/src/core/company/company.service.ts +94 -0
  82. package/template/src/core/company/hooks.ts +40 -0
  83. package/template/src/core/company/password-policy.ts +40 -0
  84. package/template/src/core/company/types.ts +46 -0
  85. package/template/src/core/company/use-app-name.ts +7 -0
  86. package/template/src/core/components/button-link.tsx +14 -0
  87. package/template/src/core/components/confirm-dialog.tsx +116 -0
  88. package/template/src/core/components/data-table/data-table.tsx +297 -0
  89. package/template/src/core/components/data-table/features.ts +24 -0
  90. package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
  91. package/template/src/core/components/data-table/toolbar.tsx +100 -0
  92. package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
  93. package/template/src/core/components/date-picker.tsx +71 -0
  94. package/template/src/core/components/file-attachments.tsx +84 -0
  95. package/template/src/core/components/form-error.tsx +9 -0
  96. package/template/src/core/components/form-page.tsx +93 -0
  97. package/template/src/core/components/image-upload.tsx +86 -0
  98. package/template/src/core/components/page.tsx +75 -0
  99. package/template/src/core/components/phone-input.tsx +58 -0
  100. package/template/src/core/components/search-select.tsx +57 -0
  101. package/template/src/core/components/segmented.tsx +41 -0
  102. package/template/src/core/components/states.tsx +97 -0
  103. package/template/src/core/components/status-badge.tsx +26 -0
  104. package/template/src/core/components/user-avatar.tsx +23 -0
  105. package/template/src/core/config/runtime.ts +16 -0
  106. package/template/src/core/config/types.ts +37 -0
  107. package/template/src/core/i18n/i18n.ts +69 -0
  108. package/template/src/core/i18n/i18next.d.ts +12 -0
  109. package/template/src/core/i18n/keys.ts +37 -0
  110. package/template/src/core/i18n/language-switcher.tsx +45 -0
  111. package/template/src/core/i18n/types.ts +17 -0
  112. package/template/src/core/i18n/use-format.ts +31 -0
  113. package/template/src/core/layout/app-shell.tsx +50 -0
  114. package/template/src/core/layout/app-sidebar.tsx +285 -0
  115. package/template/src/core/layout/command-menu.tsx +117 -0
  116. package/template/src/core/layout/top-bar.tsx +100 -0
  117. package/template/src/core/lib/csv.ts +27 -0
  118. package/template/src/core/lib/format.ts +87 -0
  119. package/template/src/core/lib/phone.ts +50 -0
  120. package/template/src/core/lib/storage.ts +27 -0
  121. package/template/src/core/modules/lazy-page.ts +9 -0
  122. package/template/src/core/modules/navigation.ts +39 -0
  123. package/template/src/core/modules/types.ts +55 -0
  124. package/template/src/core/notifications/hooks.ts +84 -0
  125. package/template/src/core/notifications/notification-center.tsx +251 -0
  126. package/template/src/core/notifications/notifications.service.ts +132 -0
  127. package/template/src/core/notifications/types.ts +70 -0
  128. package/template/src/core/preferences/preferences-store.ts +70 -0
  129. package/template/src/core/setup/setup-gate.tsx +20 -0
  130. package/template/src/core/setup/setup.service.ts +101 -0
  131. package/template/src/core/theme/brand-context.tsx +53 -0
  132. package/template/src/core/theme/brand.ts +29 -0
  133. package/template/src/core/theme/theme-context.tsx +62 -0
  134. package/template/src/core/theme/theme-options.ts +8 -0
  135. package/template/src/core/theme/theme-toggle.tsx +45 -0
  136. package/template/src/core/users/hooks.ts +51 -0
  137. package/template/src/core/users/types.ts +37 -0
  138. package/template/src/core/users/users.service.ts +167 -0
  139. package/template/src/hooks/use-mobile.ts +19 -0
  140. package/template/src/index.css +321 -0
  141. package/template/src/lib/utils.ts +1 -0
  142. package/template/src/locales/en/account.ts +180 -0
  143. package/template/src/locales/en/auth.ts +18 -0
  144. package/template/src/locales/en/common.ts +151 -0
  145. package/template/src/locales/en/company.ts +380 -0
  146. package/template/src/locales/en/dashboard.ts +46 -0
  147. package/template/src/locales/en/errors.ts +18 -0
  148. package/template/src/locales/en/notifications.ts +43 -0
  149. package/template/src/locales/en/settings.ts +25 -0
  150. package/template/src/locales/en/setup.ts +45 -0
  151. package/template/src/locales/index.ts +58 -0
  152. package/template/src/locales/tr/account.ts +184 -0
  153. package/template/src/locales/tr/auth.ts +22 -0
  154. package/template/src/locales/tr/common.ts +154 -0
  155. package/template/src/locales/tr/company.ts +384 -0
  156. package/template/src/locales/tr/dashboard.ts +50 -0
  157. package/template/src/locales/tr/errors.ts +22 -0
  158. package/template/src/locales/tr/notifications.ts +47 -0
  159. package/template/src/locales/tr/settings.ts +29 -0
  160. package/template/src/locales/tr/setup.ts +49 -0
  161. package/template/src/main.tsx +16 -0
  162. package/template/src/modules/locales.ts +10 -0
  163. package/template/src/modules/registry.ts +17 -0
  164. package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
  165. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  166. package/template/src/pages/login-page.tsx +152 -0
  167. package/template/src/pages/no-access-page.tsx +11 -0
  168. package/template/src/pages/not-found-page.tsx +61 -0
  169. package/template/src/pages/settings/account/account.service.ts +128 -0
  170. package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
  171. package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
  172. package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
  173. package/template/src/pages/settings/account/hooks.ts +73 -0
  174. package/template/src/pages/settings/account/notifications-page.tsx +103 -0
  175. package/template/src/pages/settings/account/preferences-page.tsx +157 -0
  176. package/template/src/pages/settings/account/profile-page.tsx +242 -0
  177. package/template/src/pages/settings/account/security-page.tsx +364 -0
  178. package/template/src/pages/settings/account/types.ts +37 -0
  179. package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
  180. package/template/src/pages/settings/company/branding-page.tsx +278 -0
  181. package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
  182. package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
  183. package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
  184. package/template/src/pages/settings/company/general-page.tsx +234 -0
  185. package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
  186. package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
  187. package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
  188. package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
  189. package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
  190. package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
  191. package/template/src/pages/settings/company/modules-page.tsx +65 -0
  192. package/template/src/pages/settings/company/roles-page.tsx +116 -0
  193. package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
  194. package/template/src/pages/settings/company/users-page.tsx +262 -0
  195. package/template/src/pages/settings/settings-layout.tsx +57 -0
  196. package/template/src/pages/settings/settings-nav.ts +69 -0
  197. package/template/src/pages/settings/settings-routes.tsx +49 -0
  198. package/template/src/pages/setup-page.tsx +387 -0
  199. package/template/src/providers.tsx +68 -0
  200. package/template/src/router.tsx +59 -0
  201. package/template/tools/check-design.mjs +73 -0
  202. package/template/tools/new-module.mjs +90 -0
  203. package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
  204. package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
  205. package/template/tools/templates/module/hooks.ts.tpl +57 -0
  206. package/template/tools/templates/module/index.ts.tpl +44 -0
  207. package/template/tools/templates/module/locales/en.ts.tpl +51 -0
  208. package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
  209. package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
  210. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
  211. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
  212. package/template/tools/templates/module/types.ts.tpl +32 -0
  213. package/template/tsconfig.app.json +32 -0
  214. package/template/tsconfig.json +12 -0
  215. package/template/tsconfig.node.json +23 -0
  216. package/template/vite.config.ts +47 -0
@@ -0,0 +1,278 @@
1
+ import { useEffect } from 'react'
2
+ import { Controller, useForm, useWatch } from 'react-hook-form'
3
+ import { Trans, useTranslation } from 'react-i18next'
4
+ import { Check, ImageOff, RotateCcw } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+
7
+ import { Badge } from '@/components/ui/badge'
8
+ import { Button } from '@/components/ui/button'
9
+ import { Checkbox } from '@/components/ui/checkbox'
10
+ import { Input } from '@/components/ui/input'
11
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
12
+ import { Switch } from '@/components/ui/switch'
13
+ import { useCan } from '@/core/auth/can'
14
+ import { useCompanySettings, useUpdateBranding } from '@/core/company/hooks'
15
+ import type { CompanyBranding } from '@/core/company/types'
16
+ import { FormLayout, FormSection } from '@/core/components/form-page'
17
+ import { ImageUpload } from '@/core/components/image-upload'
18
+ import { Page } from '@/core/components/page'
19
+ import { ErrorState, LoadingState } from '@/core/components/states'
20
+ import { brandPresets, type BrandPresetId, type BrandSetting } from '@/core/theme/brand'
21
+ import { useBrand } from '@/core/theme/brand-context'
22
+ import { cn } from '@/lib/utils'
23
+ import { ReadOnlyNotice } from './components/read-only-notice'
24
+
25
+ const presetIds = Object.keys(brandPresets) as BrandPresetId[]
26
+ const LIGHT_TEXT = 'oklch(1 0 0)' // fp-design-ignore: custom brand color math
27
+ const DARK_TEXT = 'oklch(0.17 0.004 84.6)' // fp-design-ignore: custom brand color math
28
+
29
+ /** White text on dark colors, near-black on light ones (relative luminance of a #rrggbb color). */
30
+ function foregroundFor(hex: string) {
31
+ const [r, g, b] = [1, 3, 5].map((i) => {
32
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255
33
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
34
+ }) as [number, number, number]
35
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? DARK_TEXT : LIGHT_TEXT
36
+ }
37
+
38
+ export default function BrandingPage() {
39
+ const { t } = useTranslation('company')
40
+ const query = useCompanySettings()
41
+ return (
42
+ <Page title={t('branding.title')} description={t('branding.description')}>
43
+ {query.isPending ? (
44
+ <LoadingState />
45
+ ) : query.error ? (
46
+ <ErrorState error={query.error} onRetry={() => query.refetch()} />
47
+ ) : (
48
+ <BrandingForm branding={query.data.branding} />
49
+ )}
50
+ </Page>
51
+ )
52
+ }
53
+
54
+ function BrandingForm({ branding }: { branding: CompanyBranding }) {
55
+ const { t } = useTranslation(['company', 'common'])
56
+ const canEdit = useCan('settings.company.edit')
57
+ const update = useUpdateBranding()
58
+ const { setBrand, previewBrand, defaultBrand } = useBrand()
59
+ const form = useForm<CompanyBranding>({ defaultValues: branding })
60
+ const { control, handleSubmit, formState, reset, setValue } = form
61
+ const brand = useWatch({ control, name: 'brand' })
62
+
63
+ // Live preview: the whole app (sidebar, buttons, focus rings) follows the selection until saved or left.
64
+ useEffect(() => previewBrand(brand), [brand, previewBrand])
65
+ useEffect(() => () => previewBrand(null), [previewBrand])
66
+
67
+ const onSubmit = handleSubmit(async (values) => {
68
+ const saved = await update.mutateAsync(values)
69
+ setBrand(saved.branding.brand)
70
+ reset(saved.branding)
71
+ toast.success(t('branding.saved'))
72
+ })
73
+
74
+ const onImageError = (err: 'tooLarge' | 'invalidType') => toast.error(t(`branding.imageError.${err}`))
75
+ const brandId = typeof brand === 'string' ? brand : 'custom'
76
+ const customHex = typeof brand === 'object' && brand.color.startsWith('#') ? brand.color : '#4f46e5' // fp-design-ignore: native color input default
77
+ const isDefault = JSON.stringify(brand) === JSON.stringify(defaultBrand)
78
+ const defaultName = typeof defaultBrand === 'string' ? t(`branding.presets.${defaultBrand}`) : t('branding.color.custom')
79
+
80
+ return (
81
+ <>
82
+ {!canEdit && <ReadOnlyNotice />}
83
+ <FormLayout
84
+ onSubmit={onSubmit}
85
+ saveBar="dirty"
86
+ isDirty={formState.isDirty}
87
+ isSubmitting={formState.isSubmitting}
88
+ onCancel={() => reset()}
89
+ readOnly={!canEdit}
90
+ >
91
+ <FormSection title={t('branding.logos.title')} description={t('branding.logos.description')}>
92
+ <div className="grid gap-4 sm:grid-cols-2">
93
+ {(['logoLight', 'logoDark'] as const).map((name) => (
94
+ <div key={name} className="flex flex-col gap-2">
95
+ <span className="text-xs font-medium">{t(name === 'logoLight' ? 'branding.logos.light' : 'branding.logos.dark')}</span>
96
+ <Controller
97
+ control={control}
98
+ name={name}
99
+ render={({ field }) => (
100
+ <ImageUpload
101
+ value={field.value}
102
+ onChange={field.onChange}
103
+ onError={onImageError}
104
+ disabled={!canEdit}
105
+ label={t(name === 'logoLight' ? 'branding.logos.uploadLight' : 'branding.logos.uploadDark')}
106
+ className="flex-col items-start"
107
+ preview={(src) => (
108
+ // `.light` / `.dark` scope the tile to that theme regardless of the app theme.
109
+ <div
110
+ className={cn(
111
+ name === 'logoLight' ? 'light' : 'dark',
112
+ 'flex h-20 w-full items-center justify-center rounded-xl border bg-background p-3 text-muted-foreground',
113
+ )}
114
+ >
115
+ {src ? (
116
+ <img src={src} alt="" className="max-h-full max-w-full object-contain" />
117
+ ) : (
118
+ <span className="flex items-center gap-1.5 text-xs">
119
+ <ImageOff className="size-4" />
120
+ {t('branding.logos.empty')}
121
+ </span>
122
+ )}
123
+ </div>
124
+ )}
125
+ />
126
+ )}
127
+ />
128
+ </div>
129
+ ))}
130
+ </div>
131
+ </FormSection>
132
+
133
+ <FormSection title={t('branding.favicon.title')} description={t('branding.favicon.description')}>
134
+ <Controller
135
+ control={control}
136
+ name="favicon"
137
+ render={({ field }) => (
138
+ <ImageUpload
139
+ value={field.value}
140
+ onChange={field.onChange}
141
+ onError={onImageError}
142
+ disabled={!canEdit}
143
+ label={t('branding.favicon.upload')}
144
+ preview={(src) => (
145
+ <div className="flex size-12 shrink-0 items-center justify-center rounded-xl border bg-fill-1">
146
+ <img src={src ?? `${import.meta.env.BASE_URL}favicon.svg`} alt="" className="size-6 object-contain" />
147
+ </div>
148
+ )}
149
+ />
150
+ )}
151
+ />
152
+ </FormSection>
153
+
154
+ <FormSection title={t('branding.color.title')} description={t('branding.color.description')}>
155
+ <RadioGroup
156
+ value={brandId}
157
+ onValueChange={(v) =>
158
+ setValue(
159
+ 'brand',
160
+ v === 'custom' ? { color: customHex, foreground: foregroundFor(customHex) } : (v as BrandPresetId),
161
+ { shouldDirty: true },
162
+ )
163
+ }
164
+ className="flex flex-wrap gap-3"
165
+ aria-label={t('branding.color.title')}
166
+ >
167
+ {presetIds.map((id) => (
168
+ <label key={id} className="flex w-14 flex-col items-center gap-1.5" title={t(`branding.presets.${id}`)}>
169
+ <RadioGroupItem
170
+ value={id}
171
+ aria-label={t(`branding.presets.${id}`)}
172
+ style={{ background: brandPresets[id].color }}
173
+ className="size-8 ring-offset-2 ring-offset-card data-checked:ring-2 data-checked:ring-(--brand) *:[span]:hidden"
174
+ />
175
+ <span className={cn('text-2xs', brandId === id ? 'text-foreground' : 'text-muted-foreground')}>
176
+ {t(`branding.presets.${id}`)}
177
+ </span>
178
+ </label>
179
+ ))}
180
+ <label className="flex w-14 flex-col items-center gap-1.5">
181
+ <RadioGroupItem
182
+ value="custom"
183
+ aria-label={t('branding.color.custom')}
184
+ style={{ background: brandId === 'custom' ? customHex : undefined }}
185
+ className="size-8 bg-fill-3 ring-offset-2 ring-offset-card data-checked:ring-2 data-checked:ring-(--brand) *:[span]:hidden"
186
+ />
187
+ <span className={cn('text-2xs', brandId === 'custom' ? 'text-foreground' : 'text-muted-foreground')}>
188
+ {t('branding.color.custom')}
189
+ </span>
190
+ </label>
191
+ </RadioGroup>
192
+
193
+ {brandId === 'custom' && (
194
+ <div className="flex items-center gap-2">
195
+ <input
196
+ type="color"
197
+ value={customHex}
198
+ aria-label={t('branding.color.customLabel')}
199
+ onChange={(e) =>
200
+ setValue('brand', { color: e.target.value, foreground: foregroundFor(e.target.value) }, { shouldDirty: true })
201
+ }
202
+ className="size-8 cursor-pointer rounded-lg border bg-transparent p-0.5"
203
+ />
204
+ <Input value={customHex.toUpperCase()} readOnly aria-label={t('branding.color.customLabel')} className="w-28 tabular-nums" />
205
+ </div>
206
+ )}
207
+
208
+ <BrandPreview />
209
+
210
+ <div>
211
+ <Button
212
+ type="button"
213
+ variant="ghost"
214
+ size="sm"
215
+ disabled={isDefault}
216
+ onClick={() => setValue('brand', defaultBrand as BrandSetting, { shouldDirty: true })}
217
+ >
218
+ <RotateCcw data-icon="inline-start" />
219
+ {t('branding.color.reset', { name: defaultName })}
220
+ </Button>
221
+ </div>
222
+ </FormSection>
223
+ </FormLayout>
224
+ </>
225
+ )
226
+ }
227
+
228
+ /** Live sample of every place the brand token shows up. */
229
+ function BrandPreview() {
230
+ const { t } = useTranslation('company')
231
+ return (
232
+ <div className="grid gap-4 rounded-xl border bg-canvas p-4 sm:grid-cols-2" aria-label={t('branding.preview.title')}>
233
+ <div className="flex flex-col gap-3">
234
+ <span className="label-caps">{t('branding.preview.controls')}</span>
235
+ <div className="flex flex-wrap items-center gap-2">
236
+ <Button type="button" tabIndex={-1}>
237
+ {t('branding.preview.primary')}
238
+ </Button>
239
+ <Button type="button" variant="outline" tabIndex={-1}>
240
+ {t('branding.preview.secondary')}
241
+ </Button>
242
+ <Badge>{t('branding.preview.new')}</Badge>
243
+ </div>
244
+ <div className="flex items-center gap-4 text-sm">
245
+ <span className="flex items-center gap-2">
246
+ <Switch defaultChecked tabIndex={-1} aria-label={t('branding.preview.enabled')} /> {t('branding.preview.enabled')}
247
+ </span>
248
+ <span className="flex items-center gap-2">
249
+ <Checkbox defaultChecked tabIndex={-1} aria-label={t('branding.preview.selected')} /> {t('branding.preview.selected')}
250
+ </span>
251
+ </div>
252
+ <Input
253
+ placeholder={t('branding.preview.focusRing')}
254
+ aria-label={t('branding.preview.focusRing')}
255
+ className="max-w-xs bg-background"
256
+ />
257
+ </div>
258
+ <div className="flex flex-col gap-3">
259
+ <span className="label-caps">{t('branding.preview.navigation')}</span>
260
+ <div className="flex flex-col gap-0.5 text-[13px]">
261
+ <div className="flex h-8 items-center gap-2 rounded-lg bg-raised px-2 font-medium shadow-(--shadow-raised) ring-1 ring-border">
262
+ <Check className="size-4 text-primary" /> {t('branding.preview.activeItem')}
263
+ </div>
264
+ <div className="flex h-8 items-center gap-2 rounded-lg px-2 text-muted-foreground">
265
+ <Check className="size-4" /> {t('branding.preview.inactiveItem')}
266
+ </div>
267
+ </div>
268
+ <p className="text-sm">
269
+ <Trans
270
+ t={t}
271
+ i18nKey="branding.preview.link"
272
+ components={{ a: <span className="font-medium text-primary underline underline-offset-4" /> }}
273
+ />
274
+ </p>
275
+ </div>
276
+ </div>
277
+ )
278
+ }
@@ -0,0 +1,106 @@
1
+ import { Controller, useForm } from 'react-hook-form'
2
+ import { zodResolver } from '@hookform/resolvers/zod'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { toast } from 'sonner'
5
+ import { z } from 'zod'
6
+
7
+ import { Button } from '@/components/ui/button'
8
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
9
+ import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
10
+ import { Input } from '@/components/ui/input'
11
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
12
+ import { Spinner } from '@/components/ui/spinner'
13
+ import { ApiError } from '@/core/api/http'
14
+ import { useCompanySettings } from '@/core/company/hooks'
15
+ import { FormFieldError } from '@/core/components/form-error'
16
+ import { vmsg } from '@/core/i18n/keys'
17
+ import { useInviteUser } from '@/core/users/hooks'
18
+ import { assignableRoles } from '@/core/users/types'
19
+ import { userErrors } from '@/core/users/users.service'
20
+
21
+ const schema = z.object({
22
+ email: z.string().trim().min(1, vmsg('validation.required')).pipe(z.email(vmsg('validation.email'))),
23
+ role: z.enum(assignableRoles),
24
+ })
25
+ type Values = z.infer<typeof schema>
26
+
27
+ export function InviteDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
28
+ const { t } = useTranslation(['company', 'common'])
29
+ const invite = useInviteUser()
30
+ const companyName = useCompanySettings().data?.general.name ?? ''
31
+ const { register, control, handleSubmit, formState, reset, setError } = useForm<Values>({
32
+ resolver: zodResolver(schema),
33
+ defaultValues: { email: '', role: 'editor' },
34
+ })
35
+ const { errors } = formState
36
+ const roleItems = assignableRoles.map((r) => ({ value: r, label: t(`common:roles.${r as 'owner'}`) }))
37
+
38
+ function close() {
39
+ onOpenChange(false)
40
+ reset()
41
+ }
42
+
43
+ const onSubmit = handleSubmit(async (values) => {
44
+ try {
45
+ await invite.mutateAsync(values)
46
+ toast.success(t('users.toasts.invited', { email: values.email }))
47
+ close()
48
+ } catch (e) {
49
+ if (e instanceof ApiError && e.message === userErrors.duplicateEmail) {
50
+ setError('email', { message: vmsg('company:users.inviteDialog.duplicate') })
51
+ return
52
+ }
53
+ toast.error(t('users.toasts.failed'))
54
+ }
55
+ })
56
+
57
+ return (
58
+ <Dialog open={open} onOpenChange={(next) => !next && close()}>
59
+ <DialogContent showCloseButton={false} className="sm:max-w-md">
60
+ <DialogHeader>
61
+ <DialogTitle>{t('users.inviteDialog.title')}</DialogTitle>
62
+ <DialogDescription>{t('users.inviteDialog.description', { company: companyName })}</DialogDescription>
63
+ </DialogHeader>
64
+ <form id="invite-form" onSubmit={onSubmit} noValidate>
65
+ <FieldGroup className="gap-4">
66
+ <Field data-invalid={!!errors.email}>
67
+ <FieldLabel htmlFor="invite-email">{t('users.inviteDialog.email')}</FieldLabel>
68
+ <Input id="invite-email" type="email" autoFocus autoComplete="off" aria-invalid={!!errors.email} {...register('email')} />
69
+ <FormFieldError error={errors.email} />
70
+ </Field>
71
+ <Field>
72
+ <FieldLabel htmlFor="invite-role">{t('users.inviteDialog.role')}</FieldLabel>
73
+ <Controller
74
+ control={control}
75
+ name="role"
76
+ render={({ field }) => (
77
+ <Select items={roleItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
78
+ <SelectTrigger id="invite-role" className="w-full">
79
+ <SelectValue />
80
+ </SelectTrigger>
81
+ <SelectContent>
82
+ {roleItems.map((r) => (
83
+ <SelectItem key={r.value} value={r.value}>
84
+ {r.label}
85
+ </SelectItem>
86
+ ))}
87
+ </SelectContent>
88
+ </Select>
89
+ )}
90
+ />
91
+ </Field>
92
+ </FieldGroup>
93
+ </form>
94
+ <DialogFooter>
95
+ <Button variant="ghost" onClick={close}>
96
+ {t('common:actions.cancel')}
97
+ </Button>
98
+ <Button type="submit" form="invite-form" disabled={formState.isSubmitting}>
99
+ {formState.isSubmitting && <Spinner data-icon="inline-start" />}
100
+ {t('users.inviteDialog.submit')}
101
+ </Button>
102
+ </DialogFooter>
103
+ </DialogContent>
104
+ </Dialog>
105
+ )
106
+ }
@@ -0,0 +1,13 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { Lock } from 'lucide-react'
3
+
4
+ /** Shown above company settings the current role can view but not change. */
5
+ export function ReadOnlyNotice() {
6
+ const { t } = useTranslation('company')
7
+ return (
8
+ <p className="flex items-center gap-2 rounded-xl border bg-fill-1 px-4 py-3 text-xs text-muted-foreground">
9
+ <Lock className="size-3.5 shrink-0" />
10
+ {t('readOnly')}
11
+ </p>
12
+ )
13
+ }
@@ -0,0 +1,127 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { useNavigate } from 'react-router'
4
+ import { toast } from 'sonner'
5
+
6
+ import { Button } from '@/components/ui/button'
7
+ import { Field, FieldLabel } from '@/components/ui/field'
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
9
+ import { useSession } from '@/core/auth/use-auth'
10
+ import { useCompanySettings, useDeleteCompany } from '@/core/company/hooks'
11
+ import { useConfirm } from '@/core/components/confirm-dialog'
12
+ import { Page } from '@/core/components/page'
13
+ import { useActiveUsers, useTransferOwnership } from '@/core/users/hooks'
14
+ import { fullName } from '@/core/users/types'
15
+
16
+ /** Owner only (route permission `company.delete`). Each action needs a typed confirmation. */
17
+ export default function DangerZonePage() {
18
+ const { t } = useTranslation('company')
19
+ return (
20
+ <Page title={t('danger.title')} description={t('danger.description')}>
21
+ <TransferOwnership />
22
+ <DeleteCompany />
23
+ </Page>
24
+ )
25
+ }
26
+
27
+ function DangerCard({ title, description, children }: { title: string; description: string; children: ReactNode }) {
28
+ return (
29
+ <section className="flex flex-col gap-4 rounded-xl border bg-card p-5 shadow-(--shadow-card) md:p-6">
30
+ <div>
31
+ <h2 className="text-sm font-medium">{title}</h2>
32
+ <p className="mt-1 max-w-prose text-xs text-muted-foreground">{description}</p>
33
+ </div>
34
+ {children}
35
+ </section>
36
+ )
37
+ }
38
+
39
+ function TransferOwnership() {
40
+ const { t } = useTranslation(['company', 'common'])
41
+ const me = useSession()?.user
42
+ const users = useActiveUsers()
43
+ const company = useCompanySettings().data?.general.name ?? ''
44
+ const transfer = useTransferOwnership()
45
+ const confirm = useConfirm()
46
+ const navigate = useNavigate()
47
+ const [target, setTarget] = useState<string | null>(null)
48
+
49
+ // Admins first, then the rest alphabetically.
50
+ const candidates = (users.data ?? [])
51
+ .filter((u) => u.id !== me?.id && fullName(u))
52
+ .sort((a, b) => Number(b.role === 'admin') - Number(a.role === 'admin') || fullName(a).localeCompare(fullName(b)))
53
+ const items = candidates.map((u) => ({ value: u.id, label: `${fullName(u)} · ${t(`common:roles.${u.role as 'owner'}`)}` }))
54
+ const selected = candidates.find((u) => u.id === target)
55
+
56
+ async function onTransfer() {
57
+ if (!selected) return
58
+ const name = fullName(selected)
59
+ const ok = await confirm({
60
+ title: t('danger.transfer.confirmTitle', { name }),
61
+ description: t('danger.transfer.confirmDescription', { name, company }),
62
+ confirmLabel: t('danger.transfer.button'),
63
+ destructive: true,
64
+ typeToConfirm: company,
65
+ })
66
+ if (!ok) return
67
+ await transfer.mutateAsync(selected.id)
68
+ toast.success(t('danger.transfer.done', { name }))
69
+ navigate('/app/settings/company/users', { replace: true })
70
+ }
71
+
72
+ return (
73
+ <DangerCard title={t('danger.transfer.title')} description={t('danger.transfer.description')}>
74
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
75
+ <Field className="sm:max-w-xs">
76
+ <FieldLabel htmlFor="new-owner">{t('danger.transfer.select')}</FieldLabel>
77
+ <Select items={items} value={target} onValueChange={(v) => setTarget(v as string | null)}>
78
+ <SelectTrigger id="new-owner" className="w-full" disabled={items.length === 0}>
79
+ <SelectValue placeholder={t('danger.transfer.placeholder')} />
80
+ </SelectTrigger>
81
+ <SelectContent>
82
+ {items.map((i) => (
83
+ <SelectItem key={i.value} value={i.value}>
84
+ {i.label}
85
+ </SelectItem>
86
+ ))}
87
+ </SelectContent>
88
+ </Select>
89
+ </Field>
90
+ <Button variant="destructive" disabled={!selected || transfer.isPending} onClick={() => void onTransfer()}>
91
+ {t('danger.transfer.button')}
92
+ </Button>
93
+ </div>
94
+ {users.data && items.length === 0 && <p className="text-xs text-muted-foreground">{t('danger.transfer.noCandidates')}</p>}
95
+ </DangerCard>
96
+ )
97
+ }
98
+
99
+ function DeleteCompany() {
100
+ const { t } = useTranslation(['company', 'common'])
101
+ const company = useCompanySettings().data?.general.name ?? ''
102
+ const confirm = useConfirm()
103
+ const remove = useDeleteCompany()
104
+
105
+ async function onDelete() {
106
+ const ok = await confirm({
107
+ title: t('danger.delete.confirmTitle', { company }),
108
+ description: t('danger.delete.confirmDescription'),
109
+ confirmLabel: t('danger.delete.button'),
110
+ destructive: true,
111
+ typeToConfirm: company,
112
+ })
113
+ if (!ok) return
114
+ toast.success(t('danger.delete.done'))
115
+ await remove.mutateAsync(company)
116
+ }
117
+
118
+ return (
119
+ <DangerCard title={t('danger.delete.title')} description={t('danger.delete.description', { company })}>
120
+ <div>
121
+ <Button variant="destructive" disabled={!company || remove.isPending} onClick={() => void onDelete()}>
122
+ {t('danger.delete.button')}
123
+ </Button>
124
+ </div>
125
+ </DangerCard>
126
+ )
127
+ }