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.
- package/README.md +24 -3
- package/bin/create-freakpunch.mjs +227 -0
- package/files/README.md +19 -0
- package/files/app.config.ts +50 -0
- package/files/locales.ts +10 -0
- package/files/registry.ts +17 -0
- package/package.json +23 -6
- package/template/.dockerignore +17 -0
- package/template/.oxlintrc.json +9 -0
- package/template/AGENTS.md +6 -0
- package/template/BACKEND.md +108 -0
- package/template/CLAUDE.md +297 -0
- package/template/Dockerfile +19 -0
- package/template/_gitignore +26 -0
- package/template/components.json +27 -0
- package/template/design/COMPONENTS.md +145 -0
- package/template/design/DESIGN.md +212 -0
- package/template/design/PATTERNS.md +110 -0
- package/template/index.html +25 -0
- package/template/nginx.conf +32 -0
- package/template/package-lock.json +6484 -0
- package/template/package.json +51 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/robots.txt +2 -0
- package/template/src/app.config.ts +50 -0
- package/template/src/app.roles.ts +24 -0
- package/template/src/components/ui/alert-dialog.tsx +181 -0
- package/template/src/components/ui/avatar.tsx +116 -0
- package/template/src/components/ui/badge.tsx +57 -0
- package/template/src/components/ui/breadcrumb.tsx +130 -0
- package/template/src/components/ui/button.tsx +58 -0
- package/template/src/components/ui/calendar.tsx +277 -0
- package/template/src/components/ui/card.tsx +99 -0
- package/template/src/components/ui/chart.tsx +370 -0
- package/template/src/components/ui/checkbox.tsx +28 -0
- package/template/src/components/ui/combobox.tsx +329 -0
- package/template/src/components/ui/command.tsx +191 -0
- package/template/src/components/ui/dialog.tsx +162 -0
- package/template/src/components/ui/dropdown-menu.tsx +271 -0
- package/template/src/components/ui/empty.tsx +103 -0
- package/template/src/components/ui/field.tsx +236 -0
- package/template/src/components/ui/input-group.tsx +263 -0
- package/template/src/components/ui/input.tsx +45 -0
- package/template/src/components/ui/kbd.tsx +31 -0
- package/template/src/components/ui/label.tsx +17 -0
- package/template/src/components/ui/pagination.tsx +134 -0
- package/template/src/components/ui/popover.tsx +87 -0
- package/template/src/components/ui/radio-group.tsx +35 -0
- package/template/src/components/ui/select.tsx +204 -0
- package/template/src/components/ui/separator.tsx +25 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/components/ui/sidebar.tsx +741 -0
- package/template/src/components/ui/skeleton.tsx +13 -0
- package/template/src/components/ui/sonner.tsx +78 -0
- package/template/src/components/ui/spinner.tsx +42 -0
- package/template/src/components/ui/switch.tsx +29 -0
- package/template/src/components/ui/table.tsx +121 -0
- package/template/src/components/ui/tabs.tsx +191 -0
- package/template/src/components/ui/textarea.tsx +17 -0
- package/template/src/components/ui/toggle-group.tsx +94 -0
- package/template/src/components/ui/toggle.tsx +48 -0
- package/template/src/components/ui/tooltip.tsx +91 -0
- package/template/src/core/api/demo.ts +8 -0
- package/template/src/core/api/http.ts +76 -0
- package/template/src/core/api/mock.ts +88 -0
- package/template/src/core/api/rest.ts +67 -0
- package/template/src/core/api/types.ts +21 -0
- package/template/src/core/audit/audit.service.ts +94 -0
- package/template/src/core/audit/hooks.ts +19 -0
- package/template/src/core/auth/auth-store.ts +52 -0
- package/template/src/core/auth/auth.service.ts +86 -0
- package/template/src/core/auth/can.tsx +39 -0
- package/template/src/core/auth/dev-role-switcher.tsx +64 -0
- package/template/src/core/auth/local-credentials.ts +46 -0
- package/template/src/core/auth/permissions.ts +115 -0
- package/template/src/core/auth/require-auth.tsx +16 -0
- package/template/src/core/auth/require-permission.tsx +24 -0
- package/template/src/core/auth/roles.ts +9 -0
- package/template/src/core/auth/types.ts +20 -0
- package/template/src/core/auth/use-auth.ts +19 -0
- package/template/src/core/company/company.service.ts +94 -0
- package/template/src/core/company/hooks.ts +40 -0
- package/template/src/core/company/password-policy.ts +40 -0
- package/template/src/core/company/types.ts +46 -0
- package/template/src/core/company/use-app-name.ts +7 -0
- package/template/src/core/components/button-link.tsx +14 -0
- package/template/src/core/components/confirm-dialog.tsx +116 -0
- package/template/src/core/components/data-table/data-table.tsx +297 -0
- package/template/src/core/components/data-table/features.ts +24 -0
- package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
- package/template/src/core/components/data-table/toolbar.tsx +100 -0
- package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
- package/template/src/core/components/date-picker.tsx +71 -0
- package/template/src/core/components/file-attachments.tsx +84 -0
- package/template/src/core/components/form-error.tsx +9 -0
- package/template/src/core/components/form-page.tsx +93 -0
- package/template/src/core/components/image-upload.tsx +86 -0
- package/template/src/core/components/page.tsx +75 -0
- package/template/src/core/components/phone-input.tsx +58 -0
- package/template/src/core/components/search-select.tsx +57 -0
- package/template/src/core/components/segmented.tsx +41 -0
- package/template/src/core/components/states.tsx +97 -0
- package/template/src/core/components/status-badge.tsx +26 -0
- package/template/src/core/components/user-avatar.tsx +23 -0
- package/template/src/core/config/runtime.ts +16 -0
- package/template/src/core/config/types.ts +37 -0
- package/template/src/core/i18n/i18n.ts +69 -0
- package/template/src/core/i18n/i18next.d.ts +12 -0
- package/template/src/core/i18n/keys.ts +37 -0
- package/template/src/core/i18n/language-switcher.tsx +45 -0
- package/template/src/core/i18n/types.ts +17 -0
- package/template/src/core/i18n/use-format.ts +31 -0
- package/template/src/core/layout/app-shell.tsx +50 -0
- package/template/src/core/layout/app-sidebar.tsx +285 -0
- package/template/src/core/layout/command-menu.tsx +117 -0
- package/template/src/core/layout/top-bar.tsx +100 -0
- package/template/src/core/lib/csv.ts +27 -0
- package/template/src/core/lib/format.ts +87 -0
- package/template/src/core/lib/phone.ts +50 -0
- package/template/src/core/lib/storage.ts +27 -0
- package/template/src/core/modules/lazy-page.ts +9 -0
- package/template/src/core/modules/navigation.ts +39 -0
- package/template/src/core/modules/types.ts +55 -0
- package/template/src/core/notifications/hooks.ts +84 -0
- package/template/src/core/notifications/notification-center.tsx +251 -0
- package/template/src/core/notifications/notifications.service.ts +132 -0
- package/template/src/core/notifications/types.ts +70 -0
- package/template/src/core/preferences/preferences-store.ts +70 -0
- package/template/src/core/setup/setup-gate.tsx +20 -0
- package/template/src/core/setup/setup.service.ts +101 -0
- package/template/src/core/theme/brand-context.tsx +53 -0
- package/template/src/core/theme/brand.ts +29 -0
- package/template/src/core/theme/theme-context.tsx +62 -0
- package/template/src/core/theme/theme-options.ts +8 -0
- package/template/src/core/theme/theme-toggle.tsx +45 -0
- package/template/src/core/users/hooks.ts +51 -0
- package/template/src/core/users/types.ts +37 -0
- package/template/src/core/users/users.service.ts +167 -0
- package/template/src/hooks/use-mobile.ts +19 -0
- package/template/src/index.css +321 -0
- package/template/src/lib/utils.ts +1 -0
- package/template/src/locales/en/account.ts +180 -0
- package/template/src/locales/en/auth.ts +18 -0
- package/template/src/locales/en/common.ts +151 -0
- package/template/src/locales/en/company.ts +380 -0
- package/template/src/locales/en/dashboard.ts +46 -0
- package/template/src/locales/en/errors.ts +18 -0
- package/template/src/locales/en/notifications.ts +43 -0
- package/template/src/locales/en/settings.ts +25 -0
- package/template/src/locales/en/setup.ts +45 -0
- package/template/src/locales/index.ts +58 -0
- package/template/src/locales/tr/account.ts +184 -0
- package/template/src/locales/tr/auth.ts +22 -0
- package/template/src/locales/tr/common.ts +154 -0
- package/template/src/locales/tr/company.ts +384 -0
- package/template/src/locales/tr/dashboard.ts +50 -0
- package/template/src/locales/tr/errors.ts +22 -0
- package/template/src/locales/tr/notifications.ts +47 -0
- package/template/src/locales/tr/settings.ts +29 -0
- package/template/src/locales/tr/setup.ts +49 -0
- package/template/src/main.tsx +16 -0
- package/template/src/modules/locales.ts +10 -0
- package/template/src/modules/registry.ts +17 -0
- package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
- package/template/src/pages/dashboard/quick-actions.tsx +37 -0
- package/template/src/pages/login-page.tsx +152 -0
- package/template/src/pages/no-access-page.tsx +11 -0
- package/template/src/pages/not-found-page.tsx +61 -0
- package/template/src/pages/settings/account/account.service.ts +128 -0
- package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
- package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
- package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
- package/template/src/pages/settings/account/hooks.ts +73 -0
- package/template/src/pages/settings/account/notifications-page.tsx +103 -0
- package/template/src/pages/settings/account/preferences-page.tsx +157 -0
- package/template/src/pages/settings/account/profile-page.tsx +242 -0
- package/template/src/pages/settings/account/security-page.tsx +364 -0
- package/template/src/pages/settings/account/types.ts +37 -0
- package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
- package/template/src/pages/settings/company/branding-page.tsx +278 -0
- package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
- package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
- package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
- package/template/src/pages/settings/company/general-page.tsx +234 -0
- package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
- package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
- package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
- package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
- package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
- package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
- package/template/src/pages/settings/company/modules-page.tsx +65 -0
- package/template/src/pages/settings/company/roles-page.tsx +116 -0
- package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
- package/template/src/pages/settings/company/users-page.tsx +262 -0
- package/template/src/pages/settings/settings-layout.tsx +57 -0
- package/template/src/pages/settings/settings-nav.ts +69 -0
- package/template/src/pages/settings/settings-routes.tsx +49 -0
- package/template/src/pages/setup-page.tsx +387 -0
- package/template/src/providers.tsx +68 -0
- package/template/src/router.tsx +59 -0
- package/template/tools/check-design.mjs +73 -0
- package/template/tools/new-module.mjs +90 -0
- package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
- package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
- package/template/tools/templates/module/hooks.ts.tpl +57 -0
- package/template/tools/templates/module/index.ts.tpl +44 -0
- package/template/tools/templates/module/locales/en.ts.tpl +51 -0
- package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
- package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
- package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
- package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
- package/template/tools/templates/module/types.ts.tpl +32 -0
- package/template/tsconfig.app.json +32 -0
- package/template/tsconfig.json +12 -0
- package/template/tsconfig.node.json +23 -0
- 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
|
+
}
|