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,53 @@
1
+ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState, type ReactNode } from 'react'
2
+
3
+ import { appConfig } from '@/app.config'
4
+ import { storage } from '@/core/lib/storage'
5
+ import { applyBrand, type BrandSetting } from './brand'
6
+
7
+ const STORAGE_KEY = 'brand'
8
+
9
+ type BrandContextValue = {
10
+ brand: BrandSetting
11
+ /** The brand from app.config.ts, used when no user override is stored. */
12
+ defaultBrand: BrandSetting
13
+ setBrand: (brand: BrandSetting) => void
14
+ resetBrand: () => void
15
+ /** Temporarily apply a brand (live preview while editing); null restores the saved one. */
16
+ previewBrand: (brand: BrandSetting | null) => void
17
+ }
18
+
19
+ const BrandContext = createContext<BrandContextValue | null>(null)
20
+
21
+ export function BrandProvider({ children }: { children: ReactNode }) {
22
+ const [brand, setBrandState] = useState<BrandSetting>(
23
+ () => storage.get<BrandSetting>(STORAGE_KEY) ?? appConfig.brand,
24
+ )
25
+
26
+ const [preview, setPreview] = useState<BrandSetting | null>(null)
27
+
28
+ useLayoutEffect(() => applyBrand(preview ?? brand), [brand, preview])
29
+
30
+ const setBrand = useCallback((next: BrandSetting) => {
31
+ storage.set(STORAGE_KEY, next)
32
+ setBrandState(next)
33
+ }, [])
34
+
35
+ const resetBrand = useCallback(() => {
36
+ storage.remove(STORAGE_KEY)
37
+ setBrandState(appConfig.brand)
38
+ }, [])
39
+
40
+ const value = useMemo(
41
+ () => ({ brand, defaultBrand: appConfig.brand, setBrand, resetBrand, previewBrand: setPreview }),
42
+ [brand, setBrand, resetBrand],
43
+ )
44
+
45
+ return <BrandContext value={value}>{children}</BrandContext>
46
+ }
47
+
48
+ // eslint-disable-next-line react/only-export-components
49
+ export function useBrand() {
50
+ const ctx = useContext(BrandContext)
51
+ if (!ctx) throw new Error('useBrand must be used within <BrandProvider>')
52
+ return ctx
53
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Brand color presets (labels: `settings:brandPresets.<id>`). The active brand is written to the --brand and
3
+ * --brand-foreground CSS variables on <html>; everything else (primary
4
+ * buttons, focus rings, active states, chart series 1) derives from them.
5
+ */
6
+ export const brandPresets = {
7
+ indigo: { color: 'oklch(0.585 0.233 277.117)', foreground: 'oklch(1 0 0)' },
8
+ blue: { color: 'oklch(0.588 0.217 257)', foreground: 'oklch(1 0 0)' },
9
+ violet: { color: 'oklch(0.56 0.25 302)', foreground: 'oklch(1 0 0)' },
10
+ emerald: { color: 'oklch(0.6 0.15 163)', foreground: 'oklch(1 0 0)' },
11
+ orange: { color: 'oklch(0.68 0.2 45)', foreground: 'oklch(1 0 0)' },
12
+ rose: { color: 'oklch(0.6 0.22 12)', foreground: 'oklch(1 0 0)' },
13
+ graphite: { color: 'oklch(0.25 0.005 84.6)', foreground: 'oklch(1 0 0)' },
14
+ } as const
15
+
16
+ export type BrandPresetId = keyof typeof brandPresets
17
+
18
+ export type BrandSetting = BrandPresetId | { color: string; foreground: string }
19
+
20
+ export function resolveBrand(setting: BrandSetting) {
21
+ return typeof setting === 'string' ? brandPresets[setting] : setting
22
+ }
23
+
24
+ export function applyBrand(setting: BrandSetting) {
25
+ const { color, foreground } = resolveBrand(setting)
26
+ const root = document.documentElement.style
27
+ root.setProperty('--brand', color)
28
+ root.setProperty('--brand-foreground', foreground)
29
+ }
@@ -0,0 +1,62 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
2
+
3
+ import { storage } from '@/core/lib/storage'
4
+
5
+ export type Theme = 'light' | 'dark' | 'system'
6
+ type Resolved = 'light' | 'dark'
7
+
8
+ /** Keep in sync with the pre-paint script in index.html. */
9
+ export const THEME_STORAGE_KEY = 'theme'
10
+
11
+ type ThemeContextValue = {
12
+ theme: Theme
13
+ resolvedTheme: Resolved
14
+ setTheme: (theme: Theme) => void
15
+ }
16
+
17
+ const ThemeContext = createContext<ThemeContextValue | null>(null)
18
+ const media = () => window.matchMedia('(prefers-color-scheme: dark)')
19
+
20
+ function apply(resolved: Resolved) {
21
+ const root = document.documentElement
22
+ // Suppress transitions while swapping so the whole UI changes at once.
23
+ const style = document.createElement('style')
24
+ style.textContent = '*,*::before,*::after{transition:none!important}'
25
+ document.head.append(style)
26
+ root.classList.toggle('dark', resolved === 'dark')
27
+ root.style.colorScheme = resolved
28
+ void window.getComputedStyle(document.body).opacity
29
+ requestAnimationFrame(() => style.remove())
30
+ }
31
+
32
+ /** Light / dark / system theme on <html class="dark">. Defaults to the system preference. */
33
+ export function ThemeProvider({ children }: { children: ReactNode }) {
34
+ const [theme, setThemeState] = useState<Theme>(() => storage.get<Theme>(THEME_STORAGE_KEY) ?? 'system')
35
+ const [systemDark, setSystemDark] = useState(() => media().matches)
36
+
37
+ useEffect(() => {
38
+ const mql = media()
39
+ const onChange = (e: MediaQueryListEvent) => setSystemDark(e.matches)
40
+ mql.addEventListener('change', onChange)
41
+ return () => mql.removeEventListener('change', onChange)
42
+ }, [])
43
+
44
+ const resolvedTheme: Resolved = theme === 'system' ? (systemDark ? 'dark' : 'light') : theme
45
+
46
+ useEffect(() => apply(resolvedTheme), [resolvedTheme])
47
+
48
+ const setTheme = useCallback((next: Theme) => {
49
+ storage.set(THEME_STORAGE_KEY, next)
50
+ setThemeState(next)
51
+ }, [])
52
+
53
+ const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme])
54
+ return <ThemeContext value={value}>{children}</ThemeContext>
55
+ }
56
+
57
+ // eslint-disable-next-line react/only-export-components
58
+ export function useTheme() {
59
+ const ctx = useContext(ThemeContext)
60
+ if (!ctx) throw new Error('useTheme must be used within <ThemeProvider>')
61
+ return ctx
62
+ }
@@ -0,0 +1,8 @@
1
+ import { Monitor, Moon, Sun } from 'lucide-react'
2
+
3
+ /** Labels are `common:theme.<value>`. */
4
+ export const themeOptions = [
5
+ { value: 'light', icon: Sun },
6
+ { value: 'dark', icon: Moon },
7
+ { value: 'system', icon: Monitor },
8
+ ] as const
@@ -0,0 +1,45 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { Moon, Sun } from 'lucide-react'
3
+
4
+ import { Button } from '@/components/ui/button'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuGroup,
9
+ DropdownMenuLabel,
10
+ DropdownMenuRadioGroup,
11
+ DropdownMenuRadioItem,
12
+ DropdownMenuTrigger,
13
+ } from '@/components/ui/dropdown-menu'
14
+ import { useTheme, type Theme } from './theme-context'
15
+ import { themeOptions } from './theme-options'
16
+
17
+ export function ThemeToggle() {
18
+ const { theme, setTheme } = useTheme()
19
+ const { t } = useTranslation()
20
+ return (
21
+ <DropdownMenu>
22
+ <DropdownMenuTrigger
23
+ render={
24
+ <Button variant="ghost" size="icon-sm" aria-label={t('theme.change')}>
25
+ <Sun className="dark:hidden" />
26
+ <Moon className="hidden dark:block" />
27
+ </Button>
28
+ }
29
+ />
30
+ <DropdownMenuContent align="end" className="w-36">
31
+ <DropdownMenuGroup>
32
+ <DropdownMenuLabel>{t('theme.label')}</DropdownMenuLabel>
33
+ <DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
34
+ {themeOptions.map(({ value, icon: Icon }) => (
35
+ <DropdownMenuRadioItem key={value} value={value}>
36
+ <Icon />
37
+ {t(`theme.${value}`)}
38
+ </DropdownMenuRadioItem>
39
+ ))}
40
+ </DropdownMenuRadioGroup>
41
+ </DropdownMenuGroup>
42
+ </DropdownMenuContent>
43
+ </DropdownMenu>
44
+ )
45
+ }
@@ -0,0 +1,51 @@
1
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+
3
+ import type { ListParams } from '@/core/api/types'
4
+ import type { AssignableRole, InviteInput, ProfilePatch, UserFilters } from './types'
5
+ import { usersService } from './users.service'
6
+
7
+ /** Query keys: invalidate `userKeys.all` after any write. */
8
+ export const userKeys = {
9
+ all: ['users'] as const,
10
+ list: (params: ListParams<UserFilters>) => [...userKeys.all, 'list', params] as const,
11
+ active: () => [...userKeys.all, 'active'] as const,
12
+ }
13
+
14
+ export function useUsers(params: ListParams<UserFilters>) {
15
+ return useQuery({
16
+ queryKey: userKeys.list(params),
17
+ queryFn: () => usersService.list(params),
18
+ placeholderData: keepPreviousData,
19
+ })
20
+ }
21
+
22
+ export function useActiveUsers() {
23
+ return useQuery({ queryKey: userKeys.active(), queryFn: () => usersService.listActive() })
24
+ }
25
+
26
+ function useUsersMutation<TVars, TResult>(fn: (vars: TVars) => Promise<TResult>) {
27
+ const qc = useQueryClient()
28
+ return useMutation({
29
+ mutationFn: fn,
30
+ // Audit entries are recorded by user writes, so refresh the log too.
31
+ onSuccess: () => Promise.all([qc.invalidateQueries({ queryKey: userKeys.all }), qc.invalidateQueries({ queryKey: ['audit'] })]),
32
+ })
33
+ }
34
+
35
+ export const useUpdateProfile = () =>
36
+ useUsersMutation(({ id, patch }: { id: string; patch: ProfilePatch }) => usersService.updateProfile(id, patch))
37
+
38
+ export const useResolveEmailChange = () =>
39
+ useUsersMutation(({ id, accept }: { id: string; accept: boolean }) => usersService.resolveEmailChange(id, accept))
40
+
41
+ export const useInviteUser = () => useUsersMutation((input: InviteInput) => usersService.invite(input))
42
+
43
+ export const useResendInvite = () => useUsersMutation((id: string) => usersService.resendInvite(id))
44
+
45
+ export const useChangeRole = () =>
46
+ useUsersMutation(({ id, role }: { id: string; role: AssignableRole }) => usersService.changeRole(id, role))
47
+
48
+ export const useSetUserStatus = () =>
49
+ useUsersMutation(({ id, status }: { id: string; status: 'active' | 'deactivated' }) => usersService.setStatus(id, status))
50
+
51
+ export const useTransferOwnership = () => useUsersMutation((toUserId: string) => usersService.transferOwnership(toUserId))
@@ -0,0 +1,37 @@
1
+ import { roles, type Role } from '@/core/auth/roles'
2
+
3
+ export const userStatuses = ['active', 'invited', 'deactivated'] as const
4
+ export type UserStatus = (typeof userStatuses)[number]
5
+
6
+ export type User = {
7
+ id: string
8
+ firstName: string
9
+ lastName: string
10
+ email: string
11
+ /** New email waiting for verification; `email` stays in use until then. */
12
+ pendingEmail?: string
13
+ /** E.164, e.g. +905321234567. */
14
+ phone?: string
15
+ jobTitle?: string
16
+ /** Image URL (a data URL in mock mode). */
17
+ avatarUrl?: string
18
+ role: Role
19
+ status: UserStatus
20
+ createdAt: string
21
+ lastActiveAt?: string
22
+ }
23
+
24
+ export type UserFilters = { role?: string; status?: string }
25
+
26
+ /** Roles that can be given by invite or role change; Owner only via transfer. */
27
+ export type AssignableRole = Exclude<Role, 'owner'>
28
+ export const assignableRoles = roles.filter((r) => r !== 'owner') as [AssignableRole, ...AssignableRole[]]
29
+
30
+ export type InviteInput = { email: string; role: AssignableRole }
31
+
32
+ export type ProfilePatch = Partial<Pick<User, 'firstName' | 'lastName' | 'phone' | 'jobTitle' | 'avatarUrl'>> & {
33
+ /** Requesting a different email starts verification (sets pendingEmail). */
34
+ email?: string
35
+ }
36
+
37
+ export const fullName = (user: Pick<User, 'firstName' | 'lastName'>) => `${user.firstName} ${user.lastName}`.trim()
@@ -0,0 +1,167 @@
1
+ import { demoSeed } from '@/core/api/demo'
2
+ import { ApiError } from '@/core/api/http'
3
+ import { createMockCollection, delay, notFound, paginate } from '@/core/api/mock'
4
+ import { auditService } from '@/core/audit/audit.service'
5
+ import type { ListParams, Paginated } from '@/core/api/types'
6
+ import { authStore } from '@/core/auth/auth-store'
7
+ import { canManageUser, type Role } from '@/core/auth/permissions'
8
+ import { fullName, type AssignableRole, type InviteInput, type ProfilePatch, type User, type UserFilters } from './types'
9
+
10
+ /**
11
+ * Team members. Screens depend only on this interface; a real backend
12
+ * implements it with `http` (e.g. GET /users, POST /users/invitations,
13
+ * PATCH /users/:id/role, POST /company/ownership-transfer).
14
+ * The mock enforces the same Owner rules a backend must enforce.
15
+ */
16
+ export interface UsersService {
17
+ list(params: ListParams<UserFilters>): Promise<Paginated<User>>
18
+ /** All active users (for pickers such as the ownership transfer). */
19
+ listActive(): Promise<User[]>
20
+ get(id: string): Promise<User>
21
+ findByEmail(email: string): Promise<User | undefined>
22
+ invite(input: InviteInput): Promise<User>
23
+ resendInvite(id: string): Promise<User>
24
+ changeRole(id: string, role: AssignableRole): Promise<User>
25
+ setStatus(id: string, status: 'active' | 'deactivated'): Promise<User>
26
+ transferOwnership(toUserId: string): Promise<void>
27
+ updateProfile(id: string, patch: ProfilePatch): Promise<User>
28
+ /** Mock helper: completes or cancels a pending email change. */
29
+ resolveEmailChange(id: string, accept: boolean): Promise<User>
30
+ }
31
+
32
+ /** Error codes in ApiError.message; screens map them to translations. */
33
+ export const userErrors = {
34
+ duplicateEmail: 'duplicate_email',
35
+ forbidden: 'forbidden',
36
+ inactive: 'inactive_account',
37
+ } as const
38
+
39
+ function createMockUsersService(): UsersService {
40
+ const db = createMockCollection('users', () => demoSeed<() => User[]>('users')?.() ?? [])
41
+ const find = (id: string) => db.find(id) ?? notFound('User', id)
42
+ const nextId = () => `u_${String(db.all().length + 1).padStart(4, '0')}_${Date.now().toString(36)}`
43
+ const actor = () => authStore.getSession()?.user ?? null
44
+
45
+ function save(user: User) {
46
+ db.save(db.all().map((u) => (u.id === user.id ? user : u)))
47
+ // Keep the signed-in user's session in sync with profile/role changes.
48
+ const session = authStore.getSession()
49
+ if (session?.user.id === user.id) authStore.set({ ...session, user })
50
+ return user
51
+ }
52
+
53
+ function assertCanManage(target: User) {
54
+ if (!canManageUser(actor(), target)) throw new ApiError(403, userErrors.forbidden)
55
+ }
56
+
57
+ return {
58
+ async list(params) {
59
+ await delay()
60
+ const { role, status } = params.filters ?? {}
61
+ const rows = db
62
+ .all()
63
+ .filter((u) => (!role || u.role === role) && (!status || u.status === status))
64
+ .map((u) => ({ ...u, name: fullName(u) || u.email }))
65
+ return paginate(rows, params, ['name', 'email', 'jobTitle'])
66
+ },
67
+
68
+ async listActive() {
69
+ await delay(150, 300)
70
+ return db.all().filter((u) => u.status === 'active')
71
+ },
72
+
73
+ async get(id) {
74
+ await delay(150, 300)
75
+ return find(id)
76
+ },
77
+
78
+ async findByEmail(email) {
79
+ return db.all().find((u) => u.email.toLowerCase() === email.trim().toLowerCase())
80
+ },
81
+
82
+ async invite({ email, role }) {
83
+ await delay()
84
+ if (!canManageUser(actor(), { id: '', role })) throw new ApiError(403, userErrors.forbidden)
85
+ if (db.all().some((u) => u.email.toLowerCase() === email.toLowerCase())) {
86
+ throw new ApiError(409, userErrors.duplicateEmail)
87
+ }
88
+ const user: User = {
89
+ id: nextId(),
90
+ firstName: '',
91
+ lastName: '',
92
+ email: email.trim().toLowerCase(),
93
+ role,
94
+ status: 'invited',
95
+ createdAt: new Date().toISOString(),
96
+ }
97
+ db.save([...db.all(), user])
98
+ auditService.record('userInvited', user.email, { role })
99
+ return user
100
+ },
101
+
102
+ async resendInvite(id) {
103
+ await delay()
104
+ const user = find(id)
105
+ assertCanManage(user)
106
+ auditService.record('inviteResent', user.email)
107
+ return save({ ...user, createdAt: new Date().toISOString() })
108
+ },
109
+
110
+ async changeRole(id, role) {
111
+ await delay()
112
+ const user = find(id)
113
+ assertCanManage(user)
114
+ const updated = save({ ...user, role })
115
+ auditService.record('roleChanged', user.email, { from: user.role, to: role })
116
+ return updated
117
+ },
118
+
119
+ async setStatus(id, status) {
120
+ await delay()
121
+ const user = find(id)
122
+ assertCanManage(user)
123
+ const updated = save({ ...user, status })
124
+ auditService.record(status === 'active' ? 'userReactivated' : 'userDeactivated', user.email)
125
+ return updated
126
+ },
127
+
128
+ async transferOwnership(toUserId) {
129
+ await delay(500, 900)
130
+ const current = actor()
131
+ if (current?.role !== 'owner') throw new ApiError(403, userErrors.forbidden)
132
+ const target = find(toUserId)
133
+ if (target.status !== 'active' || target.id === current.id) throw new ApiError(400, userErrors.forbidden)
134
+ const owner = find(current.id)
135
+ const demoted: Role = 'admin'
136
+ save({ ...target, role: 'owner' })
137
+ save({ ...owner, role: demoted })
138
+ auditService.record('ownershipTransferred', target.email)
139
+ },
140
+
141
+ async updateProfile(id, { email, ...patch }) {
142
+ await delay()
143
+ const user = find(id)
144
+ const changingEmail = email !== undefined && email.toLowerCase() !== user.email.toLowerCase()
145
+ if (changingEmail && db.all().some((u) => u.id !== id && u.email.toLowerCase() === email.toLowerCase())) {
146
+ throw new ApiError(409, userErrors.duplicateEmail)
147
+ }
148
+ return save({
149
+ ...user,
150
+ ...patch,
151
+ pendingEmail: changingEmail ? email.toLowerCase() : user.pendingEmail,
152
+ })
153
+ },
154
+
155
+ async resolveEmailChange(id, accept) {
156
+ await delay()
157
+ const user = find(id)
158
+ if (!user.pendingEmail) return user
159
+ return save({ ...user, email: accept ? user.pendingEmail : user.email, pendingEmail: undefined })
160
+ },
161
+ }
162
+ }
163
+
164
+ export const usersService: UsersService = createMockUsersService()
165
+
166
+ /** Mock login lookup used by the auth service. */
167
+ export const findUserByEmail = (email: string) => usersService.findByEmail(email)
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener("change", onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }