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,91 @@
1
+ import * as React from "react"
2
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function TooltipProvider({
6
+ delay = 0,
7
+ ...props
8
+ }: TooltipPrimitive.Provider.Props) {
9
+ return (
10
+ <TooltipPrimitive.Provider
11
+ data-slot="tooltip-provider"
12
+ delay={delay}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
19
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
20
+ }
21
+
22
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
23
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
24
+ }
25
+
26
+ function tooltipLabels(children: React.ReactNode) {
27
+ const nodes: React.ReactNode[] = []
28
+ let text = ""
29
+ React.Children.toArray(children).forEach((child, index, all) => {
30
+ if (typeof child !== "string" && typeof child !== "number") {
31
+ nodes.push(child)
32
+ return
33
+ }
34
+ text += child
35
+ const next = all[index + 1]
36
+ if (typeof next === "string" || typeof next === "number") return
37
+ if (text.trim()) {
38
+ nodes.push(
39
+ <span
40
+ key={`label-${index}`}
41
+ data-slot="tooltip-label"
42
+ className="max-w-62"
43
+ >
44
+ {text}
45
+ </span>
46
+ )
47
+ }
48
+ text = ""
49
+ })
50
+ return nodes
51
+ }
52
+
53
+ function TooltipContent({
54
+ className,
55
+ side = "top",
56
+ sideOffset = 8,
57
+ align = "center",
58
+ alignOffset = 0,
59
+ children,
60
+ ...props
61
+ }: TooltipPrimitive.Popup.Props &
62
+ Pick<
63
+ TooltipPrimitive.Positioner.Props,
64
+ "align" | "alignOffset" | "side" | "sideOffset"
65
+ >) {
66
+ return (
67
+ <TooltipPrimitive.Portal>
68
+ <TooltipPrimitive.Positioner
69
+ align={align}
70
+ alignOffset={alignOffset}
71
+ side={side}
72
+ sideOffset={sideOffset}
73
+ className="isolate z-50"
74
+ >
75
+ <TooltipPrimitive.Popup
76
+ data-slot="tooltip-content"
77
+ className={cn(
78
+ "z-50 flex w-fit max-w-80 origin-(--transform-origin) items-center gap-2 rounded-md bg-foreground px-2 py-1 text-2xs text-background has-data-[slot=kbd]:pr-1 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
79
+ className
80
+ )}
81
+ {...props}
82
+ >
83
+ {tooltipLabels(children)}
84
+ <TooltipPrimitive.Arrow className="z-50 size-2 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-0.5 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:translate-x-[1.5px] data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:translate-x-[-1.5px] data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:translate-x-[-1.5px] data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:translate-x-[1.5px] data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
85
+ </TooltipPrimitive.Popup>
86
+ </TooltipPrimitive.Positioner>
87
+ </TooltipPrimitive.Portal>
88
+ )
89
+ }
90
+
91
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,8 @@
1
+ import { isDemo } from '@/core/config/runtime'
2
+
3
+ // A glob, not an import: src/demo doesn't exist in installed copies, and this still builds without it.
4
+ const files = import.meta.glob<{ default: Record<string, unknown> }>('/src/demo/index.ts', { eager: true })
5
+ const data = isDemo ? Object.values(files)[0]?.default : undefined
6
+
7
+ /** Sample content by name, or undefined in real installs. */
8
+ export const demoSeed = <T>(name: string) => data?.[name] as T | undefined
@@ -0,0 +1,76 @@
1
+ import { appConfig } from '@/app.config'
2
+ import { authStore } from '@/core/auth/auth-store'
3
+ import { authMode } from '@/core/config/runtime'
4
+
5
+ /**
6
+ * Thin fetch wrapper for the real REST API. Mock services don't use it yet;
7
+ * real service implementations call `http.get('/products', { query })` etc.
8
+ */
9
+ export class ApiError extends Error {
10
+ readonly status: number
11
+ readonly body: unknown
12
+
13
+ constructor(status: number, message: string, body?: unknown) {
14
+ super(message)
15
+ this.name = 'ApiError'
16
+ this.status = status
17
+ this.body = body
18
+ }
19
+ }
20
+
21
+ type Query = Record<string, string | number | boolean | undefined>
22
+ type RequestOptions = Omit<RequestInit, 'body' | 'method'> & { query?: Query; body?: unknown }
23
+
24
+ function buildUrl(path: string, query?: Query) {
25
+ const url = new URL(appConfig.apiBaseUrl.replace(/\/$/, '') + path, window.location.origin)
26
+ for (const [key, value] of Object.entries(query ?? {})) {
27
+ if (value !== undefined && value !== '') url.searchParams.set(key, String(value))
28
+ }
29
+ return url.toString()
30
+ }
31
+
32
+ /** Laravel Sanctum and similar: the server sets this cookie, we echo it in a header. */
33
+ function xsrfHeader(): Record<string, string> {
34
+ if (authMode !== 'cookie') return {}
35
+ const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
36
+ return match ? { 'X-XSRF-TOKEN': decodeURIComponent(match[1]) } : {}
37
+ }
38
+
39
+ async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
40
+ const { query, body, headers, ...init } = options
41
+ const token = authStore.getSession()?.token
42
+
43
+ // FormData (file uploads) sets its own multipart Content-Type.
44
+ const isForm = body instanceof FormData
45
+
46
+ const res = await fetch(buildUrl(path, query), {
47
+ ...init,
48
+ method,
49
+ ...(authMode === 'cookie' && { credentials: 'include' as const }),
50
+ headers: {
51
+ Accept: 'application/json',
52
+ ...(body !== undefined && !isForm && { 'Content-Type': 'application/json' }),
53
+ ...(token && { Authorization: `Bearer ${token}` }),
54
+ ...xsrfHeader(),
55
+ ...headers,
56
+ },
57
+ body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
58
+ })
59
+
60
+ if (res.status === 401) authStore.clear()
61
+
62
+ const data: unknown = res.status === 204 ? undefined : await res.json().catch(() => undefined)
63
+ if (!res.ok) {
64
+ const message = (data as { message?: string } | undefined)?.message ?? res.statusText
65
+ throw new ApiError(res.status, message, data)
66
+ }
67
+ return data as T
68
+ }
69
+
70
+ export const http = {
71
+ get: <T>(path: string, options?: RequestOptions) => request<T>('GET', path, options),
72
+ post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>('POST', path, { ...options, body }),
73
+ put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>('PUT', path, { ...options, body }),
74
+ patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>('PATCH', path, { ...options, body }),
75
+ delete: <T>(path: string, options?: RequestOptions) => request<T>('DELETE', path, options),
76
+ }
@@ -0,0 +1,88 @@
1
+ import { storage } from '@/core/lib/storage'
2
+ import { ApiError } from './http'
3
+ import type { ListParams, Paginated } from './types'
4
+
5
+ /** Simulated network latency for mock services. */
6
+ export function delay(min = 250, max = 650) {
7
+ return new Promise<void>((resolve) => setTimeout(resolve, min + Math.random() * (max - min)))
8
+ }
9
+
10
+ export function notFound(entity: string, id: string): never {
11
+ throw new ApiError(404, `${entity} ${id} not found`)
12
+ }
13
+
14
+ /** Deterministic PRNG so mock data is the same on every load. */
15
+ export function seededRandom(seed: number) {
16
+ let s = seed
17
+ return () => {
18
+ s = (s * 16807) % 2147483647
19
+ return (s - 1) / 2147483646
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Applies search, sort and pagination to an in-memory list the same way a
25
+ * backend would. Filters are applied by the caller before this.
26
+ */
27
+ export function paginate<T>(
28
+ rows: T[],
29
+ params: ListParams<unknown>,
30
+ searchFields: (keyof T)[],
31
+ ): Paginated<T> {
32
+ let result = rows
33
+ const q = params.search?.trim().toLowerCase()
34
+ if (q) {
35
+ result = result.filter((row) => searchFields.some((f) => String(row[f]).toLowerCase().includes(q)))
36
+ }
37
+ if (params.sort) {
38
+ const { field, direction } = params.sort
39
+ const dir = direction === 'asc' ? 1 : -1
40
+ result = [...result].sort((a, b) => {
41
+ const av = a[field as keyof T]
42
+ const bv = b[field as keyof T]
43
+ if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
44
+ return String(av).localeCompare(String(bv)) * dir
45
+ })
46
+ }
47
+ const start = (params.page - 1) * params.pageSize
48
+ return {
49
+ items: result.slice(start, start + params.pageSize),
50
+ total: result.length,
51
+ page: params.page,
52
+ pageSize: params.pageSize,
53
+ }
54
+ }
55
+
56
+ /** A single mock record (e.g. company settings) persisted to localStorage (`fp:mock:<name>`). */
57
+ export function createMockDocument<T>(name: string, seed: () => T) {
58
+ const key = `mock:${name}`
59
+ // Stored fields win; fields added to the seed later still get defaults.
60
+ let value = Object.assign(seed() as object, storage.get<Partial<T>>(key)) as T
61
+
62
+ return {
63
+ get: () => value,
64
+ save(next: T) {
65
+ value = next
66
+ storage.set(key, value)
67
+ return value
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A mock "table" persisted to localStorage so created/edited records survive
74
+ * reloads, like a real backend would. Clear site data to reset to the seed.
75
+ */
76
+ export function createMockCollection<T extends { id: string }>(name: string, seed: () => T[]) {
77
+ const key = `mock:${name}`
78
+ let rows = storage.get<T[]>(key) ?? seed()
79
+
80
+ return {
81
+ all: () => rows,
82
+ find: (id: string) => rows.find((row) => row.id === id),
83
+ save(next: T[]) {
84
+ rows = next
85
+ storage.set(key, rows)
86
+ },
87
+ }
88
+ }
@@ -0,0 +1,67 @@
1
+ import { http } from './http'
2
+ import type { ListParams, Paginated } from './types'
3
+
4
+ export type CrudService<T, TInput, TFilters> = {
5
+ list(params: ListParams<TFilters>): Promise<Paginated<T>>
6
+ get(id: string): Promise<T>
7
+ create(input: TInput): Promise<T>
8
+ update(id: string, input: TInput): Promise<T>
9
+ remove(id: string): Promise<void>
10
+ }
11
+
12
+ type RawPage<T> = {
13
+ // Our shape
14
+ items?: T[]
15
+ total?: number
16
+ page?: number
17
+ pageSize?: number
18
+ // Laravel paginate(): flat, or under `meta` for API resources
19
+ data?: T[]
20
+ current_page?: number
21
+ per_page?: number
22
+ meta?: { total?: number; current_page?: number; per_page?: number }
23
+ // Spring Data Page (`number` is 0-based)
24
+ content?: T[]
25
+ totalElements?: number
26
+ number?: number
27
+ size?: number
28
+ }
29
+
30
+ /** Accepts our shape, Laravel pagination and Spring Data pages. Other shapes: map them in the service. */
31
+ export function toPaginated<T>(raw: RawPage<T>, params: ListParams<unknown>): Paginated<T> {
32
+ const items = raw.items ?? raw.data ?? raw.content ?? []
33
+ return {
34
+ items,
35
+ total: raw.total ?? raw.meta?.total ?? raw.totalElements ?? items.length,
36
+ page: raw.page ?? raw.current_page ?? raw.meta?.current_page ?? (raw.number !== undefined ? raw.number + 1 : params.page),
37
+ pageSize: raw.pageSize ?? raw.per_page ?? raw.meta?.per_page ?? raw.size ?? params.pageSize,
38
+ }
39
+ }
40
+
41
+ /** Query string of every list request; the contract is in BACKEND.md. */
42
+ export function toListQuery(params: ListParams<unknown>) {
43
+ return {
44
+ page: params.page,
45
+ pageSize: params.pageSize,
46
+ search: params.search,
47
+ sort: params.sort ? `${params.sort.field}.${params.sort.direction}` : undefined,
48
+ ...(params.filters as Record<string, string | undefined> | undefined),
49
+ }
50
+ }
51
+
52
+ /** Single records may arrive bare or wrapped in `{ data }` (Laravel API resources). */
53
+ const unwrap = <T>(raw: T | { data: T }): T =>
54
+ raw !== null && typeof raw === 'object' && 'data' in raw ? raw.data : raw
55
+
56
+ /** Standard REST resource: GET/POST `path`, GET/PUT/DELETE `path/:id`. */
57
+ export function createRestService<T, TInput, TFilters>(path: string): CrudService<T, TInput, TFilters> {
58
+ return {
59
+ list: async (params) => toPaginated(await http.get<RawPage<T>>(path, { query: toListQuery(params) }), params),
60
+ get: async (id) => unwrap(await http.get<T | { data: T }>(`${path}/${id}`)),
61
+ create: async (input) => unwrap(await http.post<T | { data: T }>(path, input)),
62
+ update: async (id, input) => unwrap(await http.put<T | { data: T }>(`${path}/${id}`, input)),
63
+ remove: async (id) => {
64
+ await http.delete(`${path}/${id}`)
65
+ },
66
+ }
67
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared shapes for list endpoints. Real backends (Spring Boot, Node) should
3
+ * map their responses into these in the service layer, so screens never change.
4
+ */
5
+ export type SortDirection = 'asc' | 'desc'
6
+
7
+ export type ListParams<TFilters = Record<string, string | undefined>> = {
8
+ /** 1-based page number. */
9
+ page: number
10
+ pageSize: number
11
+ search?: string
12
+ sort?: { field: string; direction: SortDirection }
13
+ filters?: TFilters
14
+ }
15
+
16
+ export type Paginated<T> = {
17
+ items: T[]
18
+ total: number
19
+ page: number
20
+ pageSize: number
21
+ }
@@ -0,0 +1,94 @@
1
+ import { demoSeed } from '@/core/api/demo'
2
+ import { createMockCollection, delay, paginate } from '@/core/api/mock'
3
+ import type { ListParams, Paginated } from '@/core/api/types'
4
+ import { authStore } from '@/core/auth/auth-store'
5
+
6
+ /** Action ids map to `company:audit.actions.<id>`. */
7
+ export const auditActions = [
8
+ 'generalUpdated',
9
+ 'brandingUpdated',
10
+ 'securityUpdated',
11
+ 'integrationUpdated',
12
+ 'webhookCreated',
13
+ 'webhookDeleted',
14
+ 'apiKeyCreated',
15
+ 'apiKeyRevoked',
16
+ 'userInvited',
17
+ 'inviteResent',
18
+ 'roleChanged',
19
+ 'userDeactivated',
20
+ 'userReactivated',
21
+ 'ownershipTransferred',
22
+ ] as const
23
+ export type AuditAction = (typeof auditActions)[number]
24
+
25
+ export type AuditEntry = {
26
+ id: string
27
+ at: string
28
+ actor: { id: string; name: string; email: string }
29
+ action: AuditAction
30
+ /** What was changed, e.g. a user's email or "SMTP". */
31
+ target?: string
32
+ detail?: Record<string, string>
33
+ ip: string
34
+ }
35
+
36
+ export type AuditFilters = {
37
+ actor?: string
38
+ action?: string
39
+ /** '24h' | '7d' | '30d'; empty = all time. */
40
+ range?: string
41
+ }
42
+
43
+ /**
44
+ * Who did what and when. A real backend writes audit entries server-side in
45
+ * the same transaction as the change; the mock services call record().
46
+ */
47
+ export interface AuditService {
48
+ list(params: ListParams<AuditFilters>): Promise<Paginated<AuditEntry>>
49
+ /** Distinct actors for the user filter. */
50
+ actors(): Promise<AuditEntry['actor'][]>
51
+ record(action: AuditAction, target?: string, detail?: Record<string, string>): void
52
+ }
53
+
54
+ const rangeMs: Record<string, number> = { '24h': 86_400_000, '7d': 7 * 86_400_000, '30d': 30 * 86_400_000 }
55
+
56
+ function createMockAuditService(): AuditService {
57
+ const db = createMockCollection('audit', () => demoSeed<() => AuditEntry[]>('audit')?.() ?? [])
58
+
59
+ return {
60
+ async list(params) {
61
+ await delay()
62
+ const { actor, action, range } = params.filters ?? {}
63
+ const since = range && rangeMs[range] ? Date.now() - rangeMs[range] : 0
64
+ const rows = db
65
+ .all()
66
+ .filter((e) => (!actor || e.actor.id === actor) && (!action || e.action === action) && new Date(e.at).getTime() >= since)
67
+ .map((e) => ({ ...e, actorName: e.actor.name }))
68
+ return paginate(rows, params, ['actorName', 'target'])
69
+ },
70
+
71
+ async actors() {
72
+ await delay(100, 200)
73
+ const map = new Map(db.all().map((e) => [e.actor.id, e.actor]))
74
+ return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
75
+ },
76
+
77
+ record(action, target, detail) {
78
+ const user = authStore.getSession()?.user
79
+ if (!user) return
80
+ const entry: AuditEntry = {
81
+ id: `a_${Date.now().toString(36)}`,
82
+ at: new Date().toISOString(),
83
+ actor: { id: user.id, name: `${user.firstName} ${user.lastName}`.trim() || user.email, email: user.email },
84
+ action,
85
+ target,
86
+ detail,
87
+ ip: '88.241.12.40',
88
+ }
89
+ db.save([entry, ...db.all()].slice(0, 500))
90
+ },
91
+ }
92
+ }
93
+
94
+ export const auditService: AuditService = createMockAuditService()
@@ -0,0 +1,19 @@
1
+ import { keepPreviousData, useQuery } from '@tanstack/react-query'
2
+
3
+ import type { ListParams } from '@/core/api/types'
4
+ import { auditService, type AuditFilters } from './audit.service'
5
+
6
+ /** Writes that record audit entries invalidate `auditKeys.all`. */
7
+ export const auditKeys = {
8
+ all: ['audit'] as const,
9
+ list: (params: ListParams<AuditFilters>) => [...auditKeys.all, 'list', params] as const,
10
+ actors: () => [...auditKeys.all, 'actors'] as const,
11
+ }
12
+
13
+ export function useAuditLog(params: ListParams<AuditFilters>) {
14
+ return useQuery({ queryKey: auditKeys.list(params), queryFn: () => auditService.list(params), placeholderData: keepPreviousData })
15
+ }
16
+
17
+ export function useAuditActors() {
18
+ return useQuery({ queryKey: auditKeys.actors(), queryFn: () => auditService.actors() })
19
+ }
@@ -0,0 +1,52 @@
1
+ import { storage } from '@/core/lib/storage'
2
+ import { isRole } from './roles'
3
+ import type { Session } from './types'
4
+
5
+ const KEY = 'session'
6
+ const listeners = new Set<() => void>()
7
+
8
+ function read(): Session | null {
9
+ const session = storage.get<Session>(KEY)
10
+ // Drop expired sessions and ones saved by an older app version.
11
+ if (session && (session.expiresAt < Date.now() || !isRole(session.user?.role) || typeof session.user.firstName !== 'string')) {
12
+ storage.remove(KEY)
13
+ return null
14
+ }
15
+ return session
16
+ }
17
+
18
+ let current = read()
19
+
20
+ function emit() {
21
+ for (const l of listeners) l()
22
+ }
23
+
24
+ /**
25
+ * The persisted session (localStorage). Framework-agnostic so the HTTP client
26
+ * can read the token and clear it on 401 without React.
27
+ */
28
+ export const authStore = {
29
+ getSession: () => current,
30
+ set(session: Session) {
31
+ current = session
32
+ storage.set(KEY, session)
33
+ emit()
34
+ },
35
+ clear() {
36
+ current = null
37
+ storage.remove(KEY)
38
+ emit()
39
+ },
40
+ subscribe(listener: () => void) {
41
+ listeners.add(listener)
42
+ return () => listeners.delete(listener)
43
+ },
44
+ }
45
+
46
+ // Keep tabs in sync (sign out in one tab signs out everywhere).
47
+ window.addEventListener('storage', (e) => {
48
+ if (e.key?.endsWith(KEY)) {
49
+ current = read()
50
+ emit()
51
+ }
52
+ })
@@ -0,0 +1,86 @@
1
+ import { ApiError, http } from '@/core/api/http'
2
+ import { delay } from '@/core/api/mock'
3
+ import { authMode, csrfUrl, isDemo } from '@/core/config/runtime'
4
+ import type { User } from '@/core/users/types'
5
+ import { findUserByEmail, userErrors, usersService } from '@/core/users/users.service'
6
+ import { authStore } from './auth-store'
7
+ import { localCredentials } from './local-credentials'
8
+ import type { AuthService, Session } from './types'
9
+
10
+ const SESSION_TTL = 1000 * 60 * 60 * 24 * 7 // 7 days
11
+ export function startLocalSession(user: User): Session {
12
+ const session: Session = {
13
+ user: { ...user, lastActiveAt: new Date().toISOString() },
14
+ token: `mock.${user.id}.${Date.now().toString(36)}`,
15
+ expiresAt: Date.now() + SESSION_TTL,
16
+ }
17
+ authStore.set(session)
18
+ return session
19
+ }
20
+
21
+ /**
22
+ * No backend yet. In the public demo any password works: a known email signs in as
23
+ * that user, an unknown one as the Owner. In a real install the password from the
24
+ * setup wizard is checked (see local-credentials: this browser only, not security).
25
+ */
26
+ const mockAuthService: AuthService = {
27
+ async login({ email, password }) {
28
+ await delay(400, 700)
29
+ if (isDemo) {
30
+ const users = await usersService.listActive()
31
+ const user = (await findUserByEmail(email)) ?? users.find((u) => u.role === 'owner')
32
+ if (!user) throw new ApiError(404, userErrors.inactive)
33
+ if (user.status !== 'active') throw new ApiError(403, userErrors.inactive)
34
+ return startLocalSession(user)
35
+ }
36
+ const user = await findUserByEmail(email)
37
+ if (!user || !(await localCredentials.verify(user.id, password))) throw new ApiError(401, 'invalid_credentials')
38
+ if (user.status !== 'active') throw new ApiError(403, userErrors.inactive)
39
+ return startLocalSession(user)
40
+ },
41
+
42
+ async logout() {
43
+ authStore.clear()
44
+ },
45
+
46
+ async switchRole(role) {
47
+ const users = await usersService.listActive()
48
+ const current = authStore.getSession()?.user
49
+ // Project-defined roles have no seeded demo user: keep the identity, change the role.
50
+ const user = users.find((u) => u.role === role) ?? (current && { ...current, role })
51
+ if (!user) throw new ApiError(404, userErrors.inactive)
52
+ return startLocalSession(user)
53
+ },
54
+ }
55
+
56
+ export type AuthResponse = { user: User; token?: string; expiresAt?: number }
57
+
58
+ /** Cookie mode: the server sets the XSRF cookie before the first write. */
59
+ export async function fetchCsrfCookie() {
60
+ if (authMode === 'cookie' && csrfUrl) await fetch(csrfUrl, { credentials: 'include' })
61
+ }
62
+
63
+ /** Real backend (bearer or cookie mode); the contract is in BACKEND.md. */
64
+ const restAuthService: AuthService = {
65
+ async login(credentials) {
66
+ await fetchCsrfCookie()
67
+ const { user, token, expiresAt } = await http.post<AuthResponse>('/auth/login', credentials)
68
+ const session: Session = { user, token, expiresAt: expiresAt ?? Date.now() + SESSION_TTL }
69
+ authStore.set(session)
70
+ return session
71
+ },
72
+
73
+ async logout() {
74
+ try {
75
+ await http.post('/auth/logout')
76
+ } finally {
77
+ authStore.clear()
78
+ }
79
+ },
80
+
81
+ async switchRole() {
82
+ throw new ApiError(400, 'not_available')
83
+ },
84
+ }
85
+
86
+ export const authService: AuthService = authMode === 'mock' ? mockAuthService : restAuthService
@@ -0,0 +1,39 @@
1
+ import { useCallback, type ReactNode } from 'react'
2
+
3
+ import { hasPermission, type Permission } from './permissions'
4
+ import { useSession } from './use-auth'
5
+
6
+ /**
7
+ * Permission checks for the current user. UI convenience only — the backend
8
+ * must enforce the same permissions (see permissions.ts).
9
+ */
10
+ // eslint-disable-next-line react/only-export-components
11
+ export function usePermissions() {
12
+ const role = useSession()?.user.role
13
+ const can = useCallback((permission: Permission) => hasPermission(role, permission), [role])
14
+ return { role, can }
15
+ }
16
+
17
+ // eslint-disable-next-line react/only-export-components
18
+ export function useCan(permission: Permission) {
19
+ return usePermissions().can(permission)
20
+ }
21
+
22
+ /**
23
+ * Hide or disable UI by permission.
24
+ * <Can permission="products.create"><ButtonLink …/></Can> hides
25
+ * <Can permission="products.delete">{(allowed) => <Button disabled={!allowed} />}</Can>
26
+ */
27
+ export function Can({
28
+ permission,
29
+ children,
30
+ fallback = null,
31
+ }: {
32
+ permission: Permission
33
+ children: ReactNode | ((allowed: boolean) => ReactNode)
34
+ fallback?: ReactNode
35
+ }) {
36
+ const allowed = useCan(permission)
37
+ if (typeof children === 'function') return children(allowed)
38
+ return allowed ? children : fallback
39
+ }