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