create-freakpunch 0.0.0-stage → 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 (220) hide show
  1. package/README.md +22 -2
  2. package/bin/create-freakpunch.mjs +227 -0
  3. package/files/README.md +15 -0
  4. package/files/app.config.ts +58 -0
  5. package/package.json +21 -4
  6. package/template/.dockerignore +17 -0
  7. package/template/.oxlintrc.json +9 -0
  8. package/template/AGENTS.md +6 -0
  9. package/template/BACKEND.md +86 -0
  10. package/template/CLAUDE.md +292 -0
  11. package/template/Dockerfile +19 -0
  12. package/template/_gitignore +26 -0
  13. package/template/components.json +27 -0
  14. package/template/design/COMPONENTS.md +145 -0
  15. package/template/design/DESIGN.md +212 -0
  16. package/template/design/PATTERNS.md +110 -0
  17. package/template/index.html +25 -0
  18. package/template/nginx.conf +32 -0
  19. package/template/package-lock.json +6484 -0
  20. package/template/package.json +51 -0
  21. package/template/public/favicon.svg +1 -0
  22. package/template/public/robots.txt +2 -0
  23. package/template/src/app.config.ts +61 -0
  24. package/template/src/app.roles.ts +24 -0
  25. package/template/src/components/ui/alert-dialog.tsx +181 -0
  26. package/template/src/components/ui/avatar.tsx +116 -0
  27. package/template/src/components/ui/badge.tsx +57 -0
  28. package/template/src/components/ui/breadcrumb.tsx +130 -0
  29. package/template/src/components/ui/button.tsx +58 -0
  30. package/template/src/components/ui/calendar.tsx +277 -0
  31. package/template/src/components/ui/card.tsx +99 -0
  32. package/template/src/components/ui/chart.tsx +370 -0
  33. package/template/src/components/ui/checkbox.tsx +28 -0
  34. package/template/src/components/ui/combobox.tsx +329 -0
  35. package/template/src/components/ui/command.tsx +191 -0
  36. package/template/src/components/ui/dialog.tsx +162 -0
  37. package/template/src/components/ui/dropdown-menu.tsx +271 -0
  38. package/template/src/components/ui/empty.tsx +103 -0
  39. package/template/src/components/ui/field.tsx +236 -0
  40. package/template/src/components/ui/input-group.tsx +263 -0
  41. package/template/src/components/ui/input.tsx +45 -0
  42. package/template/src/components/ui/kbd.tsx +31 -0
  43. package/template/src/components/ui/label.tsx +17 -0
  44. package/template/src/components/ui/pagination.tsx +134 -0
  45. package/template/src/components/ui/popover.tsx +87 -0
  46. package/template/src/components/ui/radio-group.tsx +35 -0
  47. package/template/src/components/ui/select.tsx +204 -0
  48. package/template/src/components/ui/separator.tsx +25 -0
  49. package/template/src/components/ui/sheet.tsx +139 -0
  50. package/template/src/components/ui/sidebar.tsx +741 -0
  51. package/template/src/components/ui/skeleton.tsx +13 -0
  52. package/template/src/components/ui/sonner.tsx +78 -0
  53. package/template/src/components/ui/spinner.tsx +42 -0
  54. package/template/src/components/ui/switch.tsx +29 -0
  55. package/template/src/components/ui/table.tsx +121 -0
  56. package/template/src/components/ui/tabs.tsx +191 -0
  57. package/template/src/components/ui/textarea.tsx +17 -0
  58. package/template/src/components/ui/toggle-group.tsx +94 -0
  59. package/template/src/components/ui/toggle.tsx +48 -0
  60. package/template/src/components/ui/tooltip.tsx +91 -0
  61. package/template/src/core/api/http.ts +76 -0
  62. package/template/src/core/api/mock.ts +88 -0
  63. package/template/src/core/api/rest.ts +67 -0
  64. package/template/src/core/api/types.ts +21 -0
  65. package/template/src/core/audit/audit.service.ts +125 -0
  66. package/template/src/core/audit/hooks.ts +19 -0
  67. package/template/src/core/auth/auth-store.ts +52 -0
  68. package/template/src/core/auth/auth.service.ts +75 -0
  69. package/template/src/core/auth/can.tsx +39 -0
  70. package/template/src/core/auth/dev-role-switcher.tsx +64 -0
  71. package/template/src/core/auth/permissions.ts +115 -0
  72. package/template/src/core/auth/require-auth.tsx +16 -0
  73. package/template/src/core/auth/require-permission.tsx +24 -0
  74. package/template/src/core/auth/roles.ts +9 -0
  75. package/template/src/core/auth/types.ts +20 -0
  76. package/template/src/core/auth/use-auth.ts +19 -0
  77. package/template/src/core/company/company.service.ts +92 -0
  78. package/template/src/core/company/hooks.ts +40 -0
  79. package/template/src/core/company/password-policy.ts +40 -0
  80. package/template/src/core/company/types.ts +46 -0
  81. package/template/src/core/components/button-link.tsx +14 -0
  82. package/template/src/core/components/confirm-dialog.tsx +116 -0
  83. package/template/src/core/components/data-table/data-table.tsx +297 -0
  84. package/template/src/core/components/data-table/features.ts +24 -0
  85. package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
  86. package/template/src/core/components/data-table/toolbar.tsx +100 -0
  87. package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
  88. package/template/src/core/components/date-picker.tsx +71 -0
  89. package/template/src/core/components/file-attachments.tsx +84 -0
  90. package/template/src/core/components/form-error.tsx +9 -0
  91. package/template/src/core/components/form-page.tsx +93 -0
  92. package/template/src/core/components/image-upload.tsx +86 -0
  93. package/template/src/core/components/page.tsx +74 -0
  94. package/template/src/core/components/phone-input.tsx +58 -0
  95. package/template/src/core/components/search-select.tsx +57 -0
  96. package/template/src/core/components/segmented.tsx +41 -0
  97. package/template/src/core/components/states.tsx +97 -0
  98. package/template/src/core/components/status-badge.tsx +26 -0
  99. package/template/src/core/components/user-avatar.tsx +23 -0
  100. package/template/src/core/config/runtime.ts +14 -0
  101. package/template/src/core/config/types.ts +36 -0
  102. package/template/src/core/i18n/i18n.ts +69 -0
  103. package/template/src/core/i18n/i18next.d.ts +12 -0
  104. package/template/src/core/i18n/keys.ts +37 -0
  105. package/template/src/core/i18n/language-switcher.tsx +45 -0
  106. package/template/src/core/i18n/types.ts +17 -0
  107. package/template/src/core/i18n/use-format.ts +31 -0
  108. package/template/src/core/layout/app-shell.tsx +50 -0
  109. package/template/src/core/layout/app-sidebar.tsx +268 -0
  110. package/template/src/core/layout/command-menu.tsx +117 -0
  111. package/template/src/core/layout/top-bar.tsx +100 -0
  112. package/template/src/core/lib/csv.ts +27 -0
  113. package/template/src/core/lib/format.ts +87 -0
  114. package/template/src/core/lib/phone.ts +50 -0
  115. package/template/src/core/lib/storage.ts +27 -0
  116. package/template/src/core/modules/lazy-page.ts +9 -0
  117. package/template/src/core/modules/navigation.ts +39 -0
  118. package/template/src/core/modules/types.ts +55 -0
  119. package/template/src/core/notifications/hooks.ts +84 -0
  120. package/template/src/core/notifications/notification-center.tsx +251 -0
  121. package/template/src/core/notifications/notifications.service.ts +237 -0
  122. package/template/src/core/notifications/types.ts +65 -0
  123. package/template/src/core/preferences/preferences-store.ts +70 -0
  124. package/template/src/core/theme/brand-context.tsx +53 -0
  125. package/template/src/core/theme/brand.ts +29 -0
  126. package/template/src/core/theme/theme-context.tsx +62 -0
  127. package/template/src/core/theme/theme-options.ts +8 -0
  128. package/template/src/core/theme/theme-toggle.tsx +45 -0
  129. package/template/src/core/users/hooks.ts +51 -0
  130. package/template/src/core/users/types.ts +37 -0
  131. package/template/src/core/users/users.mock.ts +24 -0
  132. package/template/src/core/users/users.service.ts +167 -0
  133. package/template/src/hooks/use-mobile.ts +19 -0
  134. package/template/src/index.css +321 -0
  135. package/template/src/lib/utils.ts +1 -0
  136. package/template/src/locales/en/account.ts +180 -0
  137. package/template/src/locales/en/auth.ts +17 -0
  138. package/template/src/locales/en/common.ts +150 -0
  139. package/template/src/locales/en/company.ts +378 -0
  140. package/template/src/locales/en/dashboard.ts +41 -0
  141. package/template/src/locales/en/errors.ts +18 -0
  142. package/template/src/locales/en/landing.ts +15 -0
  143. package/template/src/locales/en/notifications.ts +43 -0
  144. package/template/src/locales/en/settings.ts +25 -0
  145. package/template/src/locales/index.ts +58 -0
  146. package/template/src/locales/tr/account.ts +184 -0
  147. package/template/src/locales/tr/auth.ts +21 -0
  148. package/template/src/locales/tr/common.ts +153 -0
  149. package/template/src/locales/tr/company.ts +382 -0
  150. package/template/src/locales/tr/dashboard.ts +45 -0
  151. package/template/src/locales/tr/errors.ts +22 -0
  152. package/template/src/locales/tr/landing.ts +19 -0
  153. package/template/src/locales/tr/notifications.ts +47 -0
  154. package/template/src/locales/tr/settings.ts +29 -0
  155. package/template/src/main.tsx +16 -0
  156. package/template/src/modules/locales.ts +14 -0
  157. package/template/src/modules/products/api/products.mock.ts +46 -0
  158. package/template/src/modules/products/api/products.service.ts +84 -0
  159. package/template/src/modules/products/components/product-status.tsx +21 -0
  160. package/template/src/modules/products/hooks.ts +73 -0
  161. package/template/src/modules/products/index.ts +50 -0
  162. package/template/src/modules/products/locales/en.ts +78 -0
  163. package/template/src/modules/products/locales/tr.ts +82 -0
  164. package/template/src/modules/products/pages/product-detail-page.tsx +108 -0
  165. package/template/src/modules/products/pages/product-form-page.tsx +211 -0
  166. package/template/src/modules/products/pages/products-list-page.tsx +286 -0
  167. package/template/src/modules/products/types.ts +61 -0
  168. package/template/src/modules/registry.ts +19 -0
  169. package/template/src/pages/dashboard/dashboard-page.tsx +270 -0
  170. package/template/src/pages/dashboard/dashboard.service.ts +92 -0
  171. package/template/src/pages/landing-page.tsx +79 -0
  172. package/template/src/pages/login-page.tsx +140 -0
  173. package/template/src/pages/no-access-page.tsx +11 -0
  174. package/template/src/pages/not-found-page.tsx +62 -0
  175. package/template/src/pages/settings/account/account.service.ts +147 -0
  176. package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
  177. package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
  178. package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
  179. package/template/src/pages/settings/account/hooks.ts +73 -0
  180. package/template/src/pages/settings/account/notifications-page.tsx +103 -0
  181. package/template/src/pages/settings/account/preferences-page.tsx +157 -0
  182. package/template/src/pages/settings/account/profile-page.tsx +242 -0
  183. package/template/src/pages/settings/account/security-page.tsx +364 -0
  184. package/template/src/pages/settings/account/types.ts +37 -0
  185. package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
  186. package/template/src/pages/settings/company/branding-page.tsx +278 -0
  187. package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
  188. package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
  189. package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
  190. package/template/src/pages/settings/company/general-page.tsx +232 -0
  191. package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
  192. package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
  193. package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
  194. package/template/src/pages/settings/company/integrations/integrations.service.ts +173 -0
  195. package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
  196. package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
  197. package/template/src/pages/settings/company/modules-page.tsx +57 -0
  198. package/template/src/pages/settings/company/roles-page.tsx +115 -0
  199. package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
  200. package/template/src/pages/settings/company/users-page.tsx +262 -0
  201. package/template/src/pages/settings/settings-layout.tsx +57 -0
  202. package/template/src/pages/settings/settings-nav.ts +69 -0
  203. package/template/src/pages/settings/settings-routes.tsx +49 -0
  204. package/template/src/providers.tsx +68 -0
  205. package/template/src/router.tsx +54 -0
  206. package/template/tools/check-design.mjs +73 -0
  207. package/template/tools/new-module.mjs +90 -0
  208. package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
  209. package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
  210. package/template/tools/templates/module/hooks.ts.tpl +57 -0
  211. package/template/tools/templates/module/index.ts.tpl +37 -0
  212. package/template/tools/templates/module/locales/en.ts.tpl +45 -0
  213. package/template/tools/templates/module/locales/tr.ts.tpl +49 -0
  214. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
  215. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +237 -0
  216. package/template/tools/templates/module/types.ts.tpl +32 -0
  217. package/template/tsconfig.app.json +32 -0
  218. package/template/tsconfig.json +12 -0
  219. package/template/tsconfig.node.json +23 -0
  220. package/template/vite.config.ts +44 -0
@@ -0,0 +1,164 @@
1
+ import { Controller, useForm, type Control } from 'react-hook-form'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { toast } from 'sonner'
4
+
5
+ import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
7
+ import { Switch } from '@/components/ui/switch'
8
+ import { useCan } from '@/core/auth/can'
9
+ import { useCompanySettings, useUpdateSecurity } from '@/core/company/hooks'
10
+ import {
11
+ minLengthOptions,
12
+ passwordExpiryOptions,
13
+ sessionTimeoutOptions,
14
+ type SecurityPolicies,
15
+ } from '@/core/company/types'
16
+ import { FormLayout, FormSection } from '@/core/components/form-page'
17
+ import { Page } from '@/core/components/page'
18
+ import { ErrorState, LoadingState } from '@/core/components/states'
19
+ import { ReadOnlyNotice } from './components/read-only-notice'
20
+
21
+ export default function SecurityPoliciesPage() {
22
+ const { t } = useTranslation('company')
23
+ const query = useCompanySettings()
24
+ return (
25
+ <Page title={t('policies.title')} description={t('policies.description')}>
26
+ {query.isPending ? (
27
+ <LoadingState />
28
+ ) : query.error ? (
29
+ <ErrorState error={query.error} onRetry={() => query.refetch()} />
30
+ ) : (
31
+ <PoliciesForm policies={query.data.security} />
32
+ )}
33
+ </Page>
34
+ )
35
+ }
36
+
37
+ function PoliciesForm({ policies }: { policies: SecurityPolicies }) {
38
+ const { t } = useTranslation('company')
39
+ const canEdit = useCan('settings.company.edit')
40
+ const update = useUpdateSecurity()
41
+ const { control, handleSubmit, formState, reset } = useForm<SecurityPolicies>({ defaultValues: policies })
42
+
43
+ const onSubmit = handleSubmit(async (values) => {
44
+ const saved = await update.mutateAsync(values)
45
+ reset(saved.security)
46
+ toast.success(t('policies.saved'))
47
+ })
48
+
49
+ const timeoutLabel = (minutes: number) =>
50
+ minutes < 60
51
+ ? t('policies.session.minutes', { count: minutes })
52
+ : minutes < 1440
53
+ ? t('policies.session.hours', { count: minutes / 60 })
54
+ : t('policies.session.days', { count: minutes / 1440 })
55
+
56
+ return (
57
+ <>
58
+ {!canEdit && <ReadOnlyNotice />}
59
+ <FormLayout
60
+ onSubmit={onSubmit}
61
+ saveBar="dirty"
62
+ isDirty={formState.isDirty}
63
+ isSubmitting={formState.isSubmitting}
64
+ onCancel={() => reset()}
65
+ readOnly={!canEdit}
66
+ >
67
+ <FormSection title={t('policies.twoFactor.title')} description={t('policies.twoFactor.description')}>
68
+ <SwitchRow control={control} name="requireTwoFactor" label={t('policies.twoFactor.label')} />
69
+ </FormSection>
70
+
71
+ <FormSection title={t('policies.password.title')} description={t('policies.password.description')}>
72
+ <div className="grid gap-4 sm:grid-cols-2">
73
+ <NumberSelect
74
+ control={control}
75
+ name="password.minLength"
76
+ label={t('policies.password.minLength')}
77
+ options={minLengthOptions.map((n) => ({ value: n, label: t('policies.password.characters', { count: n }) }))}
78
+ />
79
+ <NumberSelect
80
+ control={control}
81
+ name="password.expiryDays"
82
+ label={t('policies.password.expiry')}
83
+ options={passwordExpiryOptions.map((n) => ({
84
+ value: n,
85
+ label: n === 0 ? t('policies.password.never') : t('policies.password.days', { count: n }),
86
+ }))}
87
+ />
88
+ </div>
89
+ <div className="flex flex-col gap-3">
90
+ <SwitchRow control={control} name="password.requireUppercase" label={t('policies.password.uppercase')} />
91
+ <SwitchRow control={control} name="password.requireNumber" label={t('policies.password.number')} />
92
+ <SwitchRow control={control} name="password.requireSymbol" label={t('policies.password.symbol')} />
93
+ </div>
94
+ </FormSection>
95
+
96
+ <FormSection title={t('policies.session.title')} description={t('policies.session.description')}>
97
+ <div className="sm:max-w-xs">
98
+ <NumberSelect
99
+ control={control}
100
+ name="sessionTimeout"
101
+ label={t('policies.session.timeout')}
102
+ options={sessionTimeoutOptions.map((n) => ({ value: n, label: timeoutLabel(n) }))}
103
+ />
104
+ </div>
105
+ </FormSection>
106
+ </FormLayout>
107
+ </>
108
+ )
109
+ }
110
+
111
+ type BoolPath = 'requireTwoFactor' | 'password.requireUppercase' | 'password.requireNumber' | 'password.requireSymbol'
112
+ type NumberPath = 'password.minLength' | 'password.expiryDays' | 'sessionTimeout'
113
+
114
+ function SwitchRow({ control, name, label, hint }: { control: Control<SecurityPolicies>; name: BoolPath; label: string; hint?: string }) {
115
+ return (
116
+ <Field orientation="horizontal">
117
+ <FieldContent>
118
+ <FieldLabel htmlFor={name}>{label}</FieldLabel>
119
+ {hint && <FieldDescription>{hint}</FieldDescription>}
120
+ </FieldContent>
121
+ <Controller
122
+ control={control}
123
+ name={name}
124
+ render={({ field }) => <Switch id={name} checked={field.value} onCheckedChange={field.onChange} />}
125
+ />
126
+ </Field>
127
+ )
128
+ }
129
+
130
+ function NumberSelect({
131
+ control,
132
+ name,
133
+ label,
134
+ options,
135
+ }: {
136
+ control: Control<SecurityPolicies>
137
+ name: NumberPath
138
+ label: string
139
+ options: { value: number; label: string }[]
140
+ }) {
141
+ return (
142
+ <Field>
143
+ <FieldLabel htmlFor={name}>{label}</FieldLabel>
144
+ <Controller
145
+ control={control}
146
+ name={name}
147
+ render={({ field }) => (
148
+ <Select items={options} value={field.value} onValueChange={(v) => v != null && field.onChange(Number(v))}>
149
+ <SelectTrigger id={name} className="w-full">
150
+ <SelectValue />
151
+ </SelectTrigger>
152
+ <SelectContent>
153
+ {options.map((o) => (
154
+ <SelectItem key={o.value} value={o.value}>
155
+ {o.label}
156
+ </SelectItem>
157
+ ))}
158
+ </SelectContent>
159
+ </Select>
160
+ )}
161
+ />
162
+ </Field>
163
+ )
164
+ }
@@ -0,0 +1,262 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { createColumnHelper } from '@tanstack/react-table'
4
+ import { Lock, MailPlus, MoreHorizontal, Send, UserCheck, UserX } 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 {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuRadioGroup,
14
+ DropdownMenuRadioItem,
15
+ DropdownMenuSeparator,
16
+ DropdownMenuSub,
17
+ DropdownMenuSubContent,
18
+ DropdownMenuSubTrigger,
19
+ DropdownMenuTrigger,
20
+ } from '@/components/ui/dropdown-menu'
21
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
22
+ import { ApiError } from '@/core/api/http'
23
+ import { Can } from '@/core/auth/can'
24
+ import { canManageUser, roles } from '@/core/auth/permissions'
25
+ import { useSession } from '@/core/auth/use-auth'
26
+ import { useConfirm } from '@/core/components/confirm-dialog'
27
+ import { DataTable } from '@/core/components/data-table/data-table'
28
+ import type { DataTableFeatures } from '@/core/components/data-table/features'
29
+ import { ClearFiltersButton, FilterSelect, TableSearch } from '@/core/components/data-table/toolbar'
30
+ import { useListUrlState } from '@/core/components/data-table/use-list-url-state'
31
+ import { Page } from '@/core/components/page'
32
+ import { EmptyState } from '@/core/components/states'
33
+ import { StatusBadge, type StatusTone } from '@/core/components/status-badge'
34
+ import { UserAvatar } from '@/core/components/user-avatar'
35
+ import { useFormat } from '@/core/i18n/use-format'
36
+ import { useChangeRole, useResendInvite, useSetUserStatus, useUsers } from '@/core/users/hooks'
37
+ import { assignableRoles, fullName, userStatuses, type AssignableRole, type User, type UserStatus } from '@/core/users/types'
38
+ import { InviteDialog } from './components/invite-dialog'
39
+
40
+ const FILTER_KEYS = ['role', 'status'] as const
41
+ const DEFAULT_SORT = { id: 'name', desc: false }
42
+ const helper = createColumnHelper<DataTableFeatures, User>()
43
+ const statusTone: Record<UserStatus, StatusTone> = { active: 'success', invited: 'warning', deactivated: 'neutral' }
44
+
45
+ export default function UsersPage() {
46
+ const { t } = useTranslation(['company', 'common'])
47
+ const fmt = useFormat()
48
+ const list = useListUrlState({ filterKeys: FILTER_KEYS, defaultSort: DEFAULT_SORT })
49
+ const query = useUsers(list.listParams)
50
+ const session = useSession()
51
+ const me = session?.user ?? null
52
+ const confirm = useConfirm()
53
+ const { mutateAsync: changeRole } = useChangeRole()
54
+ const { mutateAsync: setStatus } = useSetUserStatus()
55
+ const { mutateAsync: resendInvite } = useResendInvite()
56
+ const [inviteOpen, setInviteOpen] = useState(false)
57
+
58
+ const columns = useMemo(() => {
59
+ const displayName = (u: User) => fullName(u) || u.email
60
+
61
+ async function run(action: () => Promise<unknown>, success: string) {
62
+ try {
63
+ await action()
64
+ toast.success(success)
65
+ } catch (e) {
66
+ toast.error(e instanceof ApiError && e.status === 403 ? t('users.toasts.forbidden') : t('users.toasts.failed'))
67
+ }
68
+ }
69
+
70
+ async function deactivate(user: User) {
71
+ const name = displayName(user)
72
+ const ok = await confirm({
73
+ title: t('users.deactivateConfirm.title', { name }),
74
+ description: t('users.deactivateConfirm.description'),
75
+ confirmLabel: t('users.actions.deactivate'),
76
+ destructive: true,
77
+ })
78
+ if (ok) await run(() => setStatus({ id: user.id, status: 'deactivated' }), t('users.toasts.deactivated', { name }))
79
+ }
80
+
81
+ return helper.columns([
82
+ helper.accessor((u) => displayName(u), {
83
+ id: 'name',
84
+ header: t('users.columns.user'),
85
+ cell: ({ row: { original: u } }) => (
86
+ <div className="flex min-w-0 items-center gap-3">
87
+ <UserAvatar user={u} />
88
+ <div className="min-w-0">
89
+ <div className="flex items-center gap-2">
90
+ <span className="truncate font-medium">{fullName(u) || u.email}</span>
91
+ {u.id === me?.id && <Badge variant="secondary">{t('users.you')}</Badge>}
92
+ </div>
93
+ <div className="truncate text-xs text-muted-foreground">{fullName(u) ? u.email : u.jobTitle}</div>
94
+ <div className="mt-1 sm:hidden">
95
+ <Badge variant="secondary">{t(`common:roles.${u.role as 'owner'}`)}</Badge>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ ),
100
+ }),
101
+ helper.accessor('role', {
102
+ header: t('users.columns.role'),
103
+ meta: { hideOnMobile: true },
104
+ cell: ({ getValue }) => <Badge variant="secondary">{t(`common:roles.${getValue() as 'owner'}`)}</Badge>,
105
+ }),
106
+ helper.accessor('status', {
107
+ header: t('users.columns.status'),
108
+ meta: { hideOnMobile: true },
109
+ cell: ({ getValue }) => <StatusBadge tone={statusTone[getValue()]}>{t(`users.status.${getValue()}`)}</StatusBadge>,
110
+ }),
111
+ helper.accessor('lastActiveAt', {
112
+ header: t('users.columns.lastActive'),
113
+ meta: { hideOnMobile: true, className: 'text-muted-foreground' },
114
+ cell: ({ row: { original: u } }) =>
115
+ u.status === 'invited'
116
+ ? t('users.pendingInvite', { when: fmt.relative(u.createdAt) })
117
+ : u.lastActiveAt
118
+ ? fmt.relative(u.lastActiveAt)
119
+ : t('users.never'),
120
+ }),
121
+ helper.display({
122
+ id: 'actions',
123
+ header: () => <span className="sr-only">{t('common:table.actions')}</span>,
124
+ meta: { align: 'right', className: 'w-12' },
125
+ cell: ({ row: { original: u } }) => {
126
+ if (u.role === 'owner') {
127
+ return (
128
+ <Tooltip>
129
+ <TooltipTrigger
130
+ render={
131
+ <span className="inline-flex size-7 items-center justify-center text-muted-foreground" aria-label={t('users.ownerLocked')}>
132
+ <Lock className="size-3.5" />
133
+ </span>
134
+ }
135
+ />
136
+ <TooltipContent className="max-w-64">{t('users.ownerLocked')}</TooltipContent>
137
+ </Tooltip>
138
+ )
139
+ }
140
+ if (!canManageUser(me, u)) return null
141
+ const name = displayName(u)
142
+ return (
143
+ <DropdownMenu>
144
+ <DropdownMenuTrigger
145
+ render={
146
+ <Button variant="ghost" size="icon-sm" aria-label={t('common:table.actionsFor', { name })}>
147
+ <MoreHorizontal />
148
+ </Button>
149
+ }
150
+ />
151
+ <DropdownMenuContent align="end" className="w-52">
152
+ <DropdownMenuSub>
153
+ <DropdownMenuSubTrigger>{t('users.actions.changeRole')}</DropdownMenuSubTrigger>
154
+ <DropdownMenuSubContent>
155
+ <DropdownMenuRadioGroup
156
+ value={u.role}
157
+ onValueChange={(role) => {
158
+ if (role === u.role) return
159
+ void run(
160
+ () => changeRole({ id: u.id, role: role as AssignableRole }),
161
+ t('users.toasts.roleChanged', { name, role: t(`common:roles.${role as 'owner'}`) }),
162
+ )
163
+ }}
164
+ >
165
+ {assignableRoles.map((r) => (
166
+ <DropdownMenuRadioItem key={r} value={r}>
167
+ {t(`common:roles.${r as 'owner'}`)}
168
+ </DropdownMenuRadioItem>
169
+ ))}
170
+ </DropdownMenuRadioGroup>
171
+ </DropdownMenuSubContent>
172
+ </DropdownMenuSub>
173
+ {u.status === 'invited' && (
174
+ <DropdownMenuItem onClick={() => void run(() => resendInvite(u.id), t('users.toasts.resent', { email: u.email }))}>
175
+ <Send />
176
+ {t('users.actions.resendInvite')}
177
+ </DropdownMenuItem>
178
+ )}
179
+ <DropdownMenuSeparator />
180
+ {u.status === 'deactivated' ? (
181
+ <DropdownMenuItem
182
+ onClick={() => void run(() => setStatus({ id: u.id, status: 'active' }), t('users.toasts.reactivated', { name }))}
183
+ >
184
+ <UserCheck />
185
+ {t('users.actions.reactivate')}
186
+ </DropdownMenuItem>
187
+ ) : (
188
+ <DropdownMenuItem variant="destructive" onClick={() => void deactivate(u)}>
189
+ <UserX />
190
+ {t('users.actions.deactivate')}
191
+ </DropdownMenuItem>
192
+ )}
193
+ </DropdownMenuContent>
194
+ </DropdownMenu>
195
+ )
196
+ },
197
+ }),
198
+ ])
199
+ }, [t, fmt, me, confirm, changeRole, setStatus, resendInvite])
200
+
201
+ const roleOptions = roles.map((r) => ({ value: r, label: t(`common:roles.${r as 'owner'}`) }))
202
+ const statusOptions = userStatuses.map((s) => ({ value: s, label: t(`users.status.${s}`) }))
203
+
204
+ return (
205
+ <Page
206
+ title={t('users.title')}
207
+ description={t('users.description')}
208
+ actions={
209
+ <Can permission="users.manage">
210
+ <Button onClick={() => setInviteOpen(true)}>
211
+ <MailPlus data-icon="inline-start" />
212
+ {t('users.invite')}
213
+ </Button>
214
+ </Can>
215
+ }
216
+ >
217
+ <DataTable
218
+ columns={columns}
219
+ data={query.data?.items}
220
+ total={query.data?.total ?? 0}
221
+ getRowId={(u) => u.id}
222
+ pagination={list.pagination}
223
+ onPaginationChange={list.onPaginationChange}
224
+ sorting={list.sorting}
225
+ onSortingChange={list.onSortingChange}
226
+ isLoading={query.isPending}
227
+ isFetching={query.isFetching}
228
+ error={query.error}
229
+ onRetry={() => query.refetch()}
230
+ noun={t('users.noun')}
231
+ toolbar={
232
+ <>
233
+ <TableSearch value={list.search} onChange={list.setSearch} placeholder={t('users.search')} />
234
+ <FilterSelect
235
+ label={t('users.filters.role')}
236
+ allLabel={t('users.filters.allRoles')}
237
+ value={list.filters.role}
238
+ options={roleOptions}
239
+ onChange={(v) => list.setFilter('role', v)}
240
+ />
241
+ <FilterSelect
242
+ label={t('users.filters.status')}
243
+ allLabel={t('users.filters.allStatuses')}
244
+ value={list.filters.status}
245
+ options={statusOptions}
246
+ onChange={(v) => list.setFilter('status', v)}
247
+ />
248
+ {list.hasActiveFilters && <ClearFiltersButton onClick={list.clearAll} />}
249
+ </>
250
+ }
251
+ empty={
252
+ <EmptyState
253
+ title={t('users.emptyTitle')}
254
+ description={t('users.emptyDescription')}
255
+ action={list.hasActiveFilters ? <ClearFiltersButton onClick={list.clearAll} /> : undefined}
256
+ />
257
+ }
258
+ />
259
+ <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} />
260
+ </Page>
261
+ )
262
+ }
@@ -0,0 +1,57 @@
1
+ import { Fragment } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { NavLink, Outlet } from 'react-router'
4
+
5
+ import { usePermissions } from '@/core/auth/can'
6
+ import { useAnyT } from '@/core/i18n/keys'
7
+ import { cn } from '@/lib/utils'
8
+ import { settingsNav } from './settings-nav'
9
+
10
+ /** Same raised pill as the main sidebar's active item. */
11
+ const linkClass = ({ isActive }: { isActive: boolean }) =>
12
+ cn(
13
+ 'flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] whitespace-nowrap text-muted-foreground outline-none',
14
+ 'hover:bg-fill-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40 [&>svg]:size-4 [&>svg]:shrink-0',
15
+ isActive &&
16
+ 'bg-raised text-foreground shadow-(--shadow-raised) ring-1 ring-border hover:bg-raised [&>svg]:text-primary',
17
+ )
18
+
19
+ /**
20
+ * /app/settings: left sub-navigation inside the content panel (a horizontal
21
+ * scroller on mobile) and the active settings page on the right.
22
+ */
23
+ export default function SettingsLayout() {
24
+ const t = useAnyT()
25
+ const { t: ts } = useTranslation('settings')
26
+ const { can } = usePermissions()
27
+
28
+ const groups = settingsNav
29
+ .filter((g) => !g.permission || can(g.permission))
30
+ .map((g) => ({ ...g, items: g.items.filter((i) => !i.permission || can(i.permission)) }))
31
+
32
+ return (
33
+ <div className="mx-auto flex w-full max-w-(--page-max-width) flex-col gap-6 md:flex-row md:gap-10">
34
+ <nav aria-label={ts('navLabel')} className="-mx-1 md:mx-0 md:w-52 md:shrink-0">
35
+ <div className="scrollbar-none flex gap-1 overflow-x-auto px-1 pb-1 md:sticky md:top-0 md:flex-col md:gap-5 md:overflow-visible md:px-0 md:pb-0">
36
+ {groups.map((group, gi) => (
37
+ <Fragment key={group.id}>
38
+ {gi > 0 && <span aria-hidden className="mx-1 w-px shrink-0 self-stretch bg-border md:hidden" />}
39
+ <div className="flex gap-1 md:flex-col md:gap-0.5">
40
+ <h2 className="label-caps hidden px-2.5 pb-1.5 md:block">{t(group.label)}</h2>
41
+ {group.items.map((item) => (
42
+ <NavLink key={item.path} to={item.path} className={linkClass}>
43
+ <item.icon />
44
+ <span>{t(item.label)}</span>
45
+ </NavLink>
46
+ ))}
47
+ </div>
48
+ </Fragment>
49
+ ))}
50
+ </div>
51
+ </nav>
52
+ <div className="min-w-0 flex-1">
53
+ <Outlet />
54
+ </div>
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ Bell,
3
+ Blocks,
4
+ Building2,
5
+ KeyRound,
6
+ Palette,
7
+ Plug,
8
+ ScrollText,
9
+ ShieldCheck,
10
+ SlidersHorizontal,
11
+ TriangleAlert,
12
+ UserRound,
13
+ Users,
14
+ LockKeyhole,
15
+ type LucideIcon,
16
+ } from 'lucide-react'
17
+
18
+ import type { Permission } from '@/core/auth/permissions'
19
+ import { isFeatureOn } from '@/core/config/runtime'
20
+ import type { FeatureId } from '@/core/config/types'
21
+ import type { I18nKey } from '@/core/i18n/keys'
22
+
23
+ export type SettingsNavItem = {
24
+ /** Path relative to /app/settings. */
25
+ path: string
26
+ label: I18nKey
27
+ icon: LucideIcon
28
+ /** Same permission as the route handle (see settings routes). */
29
+ permission?: Permission
30
+ /** Hidden when app.config.ts switches this feature off (same check as the route). */
31
+ feature?: FeatureId
32
+ }
33
+
34
+ export type SettingsNavGroup = { id: string; label: I18nKey; permission?: Permission; items: SettingsNavItem[] }
35
+
36
+ /** Left sub-navigation of /app/settings. Keep in sync with settingsRoutes. */
37
+ const allSettingsNav: SettingsNavGroup[] = [
38
+ {
39
+ id: 'account',
40
+ label: 'settings:groups.account',
41
+ items: [
42
+ { path: 'profile', label: 'settings:nav.profile', icon: UserRound },
43
+ { path: 'security', label: 'settings:nav.security', icon: KeyRound },
44
+ { path: 'notifications', label: 'settings:nav.notifications', icon: Bell, feature: 'notifications' },
45
+ { path: 'preferences', label: 'settings:nav.preferences', icon: SlidersHorizontal },
46
+ ],
47
+ },
48
+ {
49
+ id: 'company',
50
+ label: 'settings:groups.company',
51
+ permission: 'settings.company.view',
52
+ items: [
53
+ { path: 'company/general', label: 'settings:nav.general', icon: Building2 },
54
+ { path: 'company/branding', label: 'settings:nav.branding', icon: Palette },
55
+ { path: 'company/users', label: 'settings:nav.users', icon: Users, permission: 'users.view', feature: 'users' },
56
+ { path: 'company/roles', label: 'settings:nav.roles', icon: ShieldCheck, permission: 'roles.view', feature: 'roles' },
57
+ { path: 'company/security', label: 'settings:nav.securityPolicies', icon: LockKeyhole, feature: 'securityPolicies' },
58
+ { path: 'company/modules', label: 'settings:nav.modules', icon: Blocks, feature: 'modules' },
59
+ { path: 'company/integrations', label: 'settings:nav.integrations', icon: Plug, permission: 'integrations.manage', feature: 'integrations' },
60
+ { path: 'company/audit-log', label: 'settings:nav.auditLog', icon: ScrollText, permission: 'audit.view', feature: 'auditLog' },
61
+ { path: 'company/danger-zone', label: 'settings:nav.dangerZone', icon: TriangleAlert, permission: 'company.delete', feature: 'dangerZone' },
62
+ ],
63
+ },
64
+ ]
65
+
66
+ export const settingsNav = allSettingsNav.map((group) => ({
67
+ ...group,
68
+ items: group.items.filter((item) => !item.feature || isFeatureOn(item.feature)),
69
+ }))
@@ -0,0 +1,49 @@
1
+ import { Navigate, type RouteObject } from 'react-router'
2
+
3
+ import { isFeatureOn } from '@/core/config/runtime'
4
+ import type { FeatureId } from '@/core/config/types'
5
+ import { lazyPage } from '@/core/modules/lazy-page'
6
+ import type { RouteHandle } from '@/core/modules/types'
7
+
8
+ const page = (crumb: RouteHandle['crumb'], load: Parameters<typeof lazyPage>[0], permission?: RouteHandle['permission']) => ({
9
+ handle: { crumb, permission } satisfies RouteHandle,
10
+ lazy: lazyPage(load),
11
+ })
12
+
13
+ const when = (feature: FeatureId, route: RouteObject): RouteObject[] => (isFeatureOn(feature) ? [route] : [])
14
+
15
+ /**
16
+ * /app/settings/* — each page is its own sub-route. Permissions here must
17
+ * match settings-nav.ts (the guard redirects to /app/no-access).
18
+ */
19
+ export const settingsRoutes: RouteObject = {
20
+ path: 'settings',
21
+ handle: { crumb: 'common:nav.settings' } satisfies RouteHandle,
22
+ lazy: lazyPage(() => import('./settings-layout')),
23
+ children: [
24
+ { index: true, element: <Navigate to="profile" replace /> },
25
+ { path: 'profile', ...page('settings:nav.profile', () => import('./account/profile-page')) },
26
+ { path: 'security', ...page('settings:nav.security', () => import('./account/security-page')) },
27
+ ...when('notifications', { path: 'notifications', ...page('settings:nav.notifications', () => import('./account/notifications-page')) }),
28
+ { path: 'preferences', ...page('settings:nav.preferences', () => import('./account/preferences-page')) },
29
+ {
30
+ path: 'company',
31
+ handle: { crumb: 'settings:nav.company', permission: 'settings.company.view' } satisfies RouteHandle,
32
+ children: [
33
+ { index: true, element: <Navigate to="general" replace /> },
34
+ { path: 'general', ...page('settings:nav.general', () => import('./company/general-page')) },
35
+ { path: 'branding', ...page('settings:nav.branding', () => import('./company/branding-page')) },
36
+ ...when('users', { path: 'users', ...page('settings:nav.users', () => import('./company/users-page'), 'users.view') }),
37
+ ...when('roles', { path: 'roles', ...page('settings:nav.roles', () => import('./company/roles-page'), 'roles.view') }),
38
+ ...when('securityPolicies', { path: 'security', ...page('settings:nav.securityPolicies', () => import('./company/security-policies-page')) }),
39
+ ...when('modules', { path: 'modules', ...page('settings:nav.modules', () => import('./company/modules-page')) }),
40
+ ...when('integrations', {
41
+ path: 'integrations',
42
+ ...page('settings:nav.integrations', () => import('./company/integrations/integrations-page'), 'integrations.manage'),
43
+ }),
44
+ ...when('auditLog', { path: 'audit-log', ...page('settings:nav.auditLog', () => import('./company/audit-log-page'), 'audit.view') }),
45
+ ...when('dangerZone', { path: 'danger-zone', ...page('settings:nav.dangerZone', () => import('./company/danger-zone-page'), 'company.delete') }),
46
+ ],
47
+ },
48
+ ],
49
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3
+ import { CircleCheck, Info, OctagonX, TriangleAlert } from 'lucide-react'
4
+
5
+ import { Toaster } from '@/components/ui/sonner'
6
+ import { TooltipProvider } from '@/components/ui/tooltip'
7
+ import { ApiError } from '@/core/api/http'
8
+ import { useCompanySettings } from '@/core/company/hooks'
9
+ import { ConfirmProvider } from '@/core/components/confirm-dialog'
10
+ import { BrandProvider } from '@/core/theme/brand-context'
11
+ import { ThemeProvider, useTheme } from '@/core/theme/theme-context'
12
+
13
+ function createQueryClient() {
14
+ return new QueryClient({
15
+ defaultOptions: {
16
+ queries: {
17
+ staleTime: 30_000,
18
+ refetchOnWindowFocus: false,
19
+ // Don't retry client errors (404, 401, validation).
20
+ retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
21
+ },
22
+ },
23
+ })
24
+ }
25
+
26
+ /** Semantic-token icons (the vendored defaults hard-code blue/orange). */
27
+ const toastIcons = {
28
+ success: <CircleCheck className="size-4 text-success" />,
29
+ info: <Info className="size-4 text-info" />,
30
+ warning: <TriangleAlert className="size-4 text-warning" />,
31
+ error: <OctagonX className="size-4 text-danger" />,
32
+ }
33
+
34
+ /** Applies the company favicon (Settings → Company → Branding) to the document. */
35
+ function CompanyFavicon() {
36
+ const favicon = useCompanySettings().data?.branding.favicon
37
+ useEffect(() => {
38
+ const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
39
+ if (!link) return
40
+ link.type = favicon ? '' : 'image/svg+xml'
41
+ link.href = favicon ?? `${import.meta.env.BASE_URL}favicon.svg`
42
+ }, [favicon])
43
+ return null
44
+ }
45
+
46
+ /** All notifications appear bottom-center, on every screen size. */
47
+ function ThemedToaster() {
48
+ const { resolvedTheme } = useTheme()
49
+ return <Toaster theme={resolvedTheme} position="bottom-center" icons={toastIcons} />
50
+ }
51
+
52
+ export function Providers({ children }: { children: ReactNode }) {
53
+ const [queryClient] = useState(createQueryClient)
54
+
55
+ return (
56
+ <ThemeProvider>
57
+ <BrandProvider>
58
+ <QueryClientProvider client={queryClient}>
59
+ <TooltipProvider>
60
+ <ConfirmProvider>{children}</ConfirmProvider>
61
+ <ThemedToaster />
62
+ <CompanyFavicon />
63
+ </TooltipProvider>
64
+ </QueryClientProvider>
65
+ </BrandProvider>
66
+ </ThemeProvider>
67
+ )
68
+ }