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,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
+ }