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,251 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Link } from 'react-router'
|
|
4
|
+
import { Bell, BellOff, CheckCheck, CircleAlert, CircleCheck, Info, Settings, TriangleAlert, type LucideIcon } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@/components/ui/popover'
|
|
8
|
+
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
|
9
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
10
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
11
|
+
import { ButtonLink } from '@/core/components/button-link'
|
|
12
|
+
import { EmptyState, ErrorState } from '@/core/components/states'
|
|
13
|
+
import { useFormat } from '@/core/i18n/use-format'
|
|
14
|
+
import type { Formatters } from '@/core/lib/format'
|
|
15
|
+
import { useIsMobile } from '@/hooks/use-mobile'
|
|
16
|
+
import { cn } from '@/lib/utils'
|
|
17
|
+
import { isUnread, useMarkAllNotificationsRead, useMarkNotificationRead, useNotifications } from './hooks'
|
|
18
|
+
import type { AppNotification, NotificationType } from './types'
|
|
19
|
+
|
|
20
|
+
const typeIcons: Record<NotificationType, { icon: LucideIcon; className: string }> = {
|
|
21
|
+
info: { icon: Info, className: 'text-info' },
|
|
22
|
+
success: { icon: CircleCheck, className: 'text-success' },
|
|
23
|
+
warning: { icon: TriangleAlert, className: 'text-warning' },
|
|
24
|
+
error: { icon: CircleAlert, className: 'text-danger' },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Tab = 'all' | 'unread'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Top-bar bell with an unread badge. Opens a popover on desktop and a bottom
|
|
31
|
+
* sheet on mobile; both render the same `NotificationPanel`.
|
|
32
|
+
*/
|
|
33
|
+
export function NotificationBell() {
|
|
34
|
+
const { t } = useTranslation('notifications')
|
|
35
|
+
const isMobile = useIsMobile()
|
|
36
|
+
const [open, setOpen] = useState(false)
|
|
37
|
+
const unread = useNotifications().data?.filter(isUnread).length ?? 0
|
|
38
|
+
|
|
39
|
+
const trigger = (
|
|
40
|
+
<Button
|
|
41
|
+
variant="ghost"
|
|
42
|
+
size="icon-sm"
|
|
43
|
+
aria-label={unread > 0 ? t('bellUnread', { count: unread }) : t('bell')}
|
|
44
|
+
className="relative"
|
|
45
|
+
>
|
|
46
|
+
<Bell />
|
|
47
|
+
{unread > 0 && (
|
|
48
|
+
<span
|
|
49
|
+
aria-hidden
|
|
50
|
+
className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-semibold text-primary-foreground tabular-nums ring-2 ring-background"
|
|
51
|
+
>
|
|
52
|
+
{unread > 9 ? '9+' : unread}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</Button>
|
|
56
|
+
)
|
|
57
|
+
const close = () => setOpen(false)
|
|
58
|
+
|
|
59
|
+
if (isMobile) {
|
|
60
|
+
return (
|
|
61
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
62
|
+
<SheetTrigger render={trigger} />
|
|
63
|
+
<SheetContent
|
|
64
|
+
side="bottom"
|
|
65
|
+
showCloseButton={false}
|
|
66
|
+
className="max-h-[85svh] gap-0 rounded-t-(--radius-panel)"
|
|
67
|
+
>
|
|
68
|
+
<NotificationPanel title={<SheetTitle className="text-sm font-semibold">{t('title')}</SheetTitle>} onDone={close} />
|
|
69
|
+
</SheetContent>
|
|
70
|
+
</Sheet>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
76
|
+
<PopoverTrigger render={trigger} />
|
|
77
|
+
<PopoverContent align="end" sideOffset={8} className="w-96 gap-0 overflow-hidden p-0">
|
|
78
|
+
<NotificationPanel title={<PopoverTitle className="text-sm font-semibold">{t('title')}</PopoverTitle>} onDone={close} />
|
|
79
|
+
</PopoverContent>
|
|
80
|
+
</Popover>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function NotificationPanel({ title, onDone }: { title: ReactNode; onDone: () => void }) {
|
|
85
|
+
const { t } = useTranslation('notifications')
|
|
86
|
+
const [tab, setTab] = useState<Tab>('all')
|
|
87
|
+
const query = useNotifications()
|
|
88
|
+
const markAll = useMarkAllNotificationsRead()
|
|
89
|
+
const unread = query.data?.filter(isUnread).length ?? 0
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
93
|
+
<div className="flex items-center justify-between gap-2 px-4 pt-3 pb-2">
|
|
94
|
+
{title}
|
|
95
|
+
<Button
|
|
96
|
+
variant="ghost"
|
|
97
|
+
size="sm"
|
|
98
|
+
className="-mr-2 text-muted-foreground"
|
|
99
|
+
disabled={unread === 0}
|
|
100
|
+
onClick={() => markAll.mutate()}
|
|
101
|
+
>
|
|
102
|
+
<CheckCheck data-icon="inline-start" />
|
|
103
|
+
{t('markAllRead')}
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="border-b px-4 pb-3">
|
|
107
|
+
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
|
|
108
|
+
<TabsList className="w-full">
|
|
109
|
+
<TabsTrigger value="all" className="flex-1">{t('tabs.all')}</TabsTrigger>
|
|
110
|
+
<TabsTrigger value="unread" className="flex-1">
|
|
111
|
+
{t('tabs.unread')}
|
|
112
|
+
{unread > 0 && <span className="text-muted-foreground tabular-nums">{unread}</span>}
|
|
113
|
+
</TabsTrigger>
|
|
114
|
+
</TabsList>
|
|
115
|
+
</Tabs>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain md:max-h-[min(28rem,60svh)]">
|
|
119
|
+
{query.isPending ? (
|
|
120
|
+
<NotificationSkeleton />
|
|
121
|
+
) : query.error ? (
|
|
122
|
+
<ErrorState title={t('loadFailed')} error={query.error} onRetry={() => query.refetch()} className="py-10" />
|
|
123
|
+
) : (
|
|
124
|
+
<NotificationList items={tab === 'unread' ? query.data.filter(isUnread) : query.data} tab={tab} onDone={onDone} />
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="border-t p-1.5">
|
|
129
|
+
<ButtonLink to="/app/settings/notifications" variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={onDone}>
|
|
130
|
+
<Settings data-icon="inline-start" />
|
|
131
|
+
{t('settings')}
|
|
132
|
+
</ButtonLink>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function NotificationList({ items, tab, onDone }: { items: AppNotification[]; tab: Tab; onDone: () => void }) {
|
|
139
|
+
const { t } = useTranslation('notifications')
|
|
140
|
+
const fmt = useFormat()
|
|
141
|
+
// The panel mounts when opened, so "today" is fresh each time it opens.
|
|
142
|
+
const [now] = useState(() => new Date())
|
|
143
|
+
|
|
144
|
+
if (items.length === 0) {
|
|
145
|
+
return (
|
|
146
|
+
<EmptyState
|
|
147
|
+
icon={tab === 'unread' ? <CheckCheck /> : <BellOff />}
|
|
148
|
+
title={t(`empty.${tab}.title`)}
|
|
149
|
+
description={t(`empty.${tab}.description`)}
|
|
150
|
+
className="py-10"
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const today = fmt.date(now)
|
|
156
|
+
const groups = [
|
|
157
|
+
{ id: 'today', items: items.filter((n) => fmt.date(n.createdAt) === today) },
|
|
158
|
+
{ id: 'earlier', items: items.filter((n) => fmt.date(n.createdAt) !== today) },
|
|
159
|
+
] as const
|
|
160
|
+
|
|
161
|
+
return groups.map(
|
|
162
|
+
(group) =>
|
|
163
|
+
group.items.length > 0 && (
|
|
164
|
+
<section key={group.id} aria-labelledby={`notifications-${group.id}`} className="pb-1">
|
|
165
|
+
<h3 id={`notifications-${group.id}`} className="label-caps px-4 pt-3 pb-1">
|
|
166
|
+
{t(`groups.${group.id}`)}
|
|
167
|
+
</h3>
|
|
168
|
+
<ul className="px-1.5">
|
|
169
|
+
{group.items.map((n) => (
|
|
170
|
+
<li key={n.id}>
|
|
171
|
+
<NotificationItem notification={n} fmt={fmt} onDone={onDone} />
|
|
172
|
+
</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
</section>
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function NotificationItem({ notification: n, fmt, onDone }: { notification: AppNotification; fmt: Formatters; onDone: () => void }) {
|
|
181
|
+
const { t } = useTranslation(['notifications', 'common'])
|
|
182
|
+
const markRead = useMarkNotificationRead()
|
|
183
|
+
const unread = isUnread(n)
|
|
184
|
+
const { icon: Icon, className: iconClass } = typeIcons[n.type]
|
|
185
|
+
|
|
186
|
+
// Params are raw values from the API; localize the ones that need it.
|
|
187
|
+
const values: Record<string, string | number> = { ...n.params }
|
|
188
|
+
if (typeof n.params.role === 'string') values.role = t(`common:roles.${n.params.role as 'owner'}`)
|
|
189
|
+
if (typeof n.params.revenue === 'number') values.revenue = fmt.currencyWhole(n.params.revenue)
|
|
190
|
+
if (typeof n.params.orders === 'number') values.orders = fmt.number(n.params.orders)
|
|
191
|
+
|
|
192
|
+
const onClick = () => {
|
|
193
|
+
if (unread) markRead.mutate(n.id)
|
|
194
|
+
if (n.link) onDone()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const content = (
|
|
198
|
+
<>
|
|
199
|
+
<span className={cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-fill-1', iconClass)}>
|
|
200
|
+
<Icon className="size-4" />
|
|
201
|
+
<span className="sr-only">{t(`types.${n.type}`)}</span>
|
|
202
|
+
</span>
|
|
203
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
204
|
+
<span className={cn('text-sm', unread ? 'font-medium text-foreground' : 'text-foreground/80')}>
|
|
205
|
+
{t(`templates.${n.template}.title`)}
|
|
206
|
+
</span>
|
|
207
|
+
<span className="line-clamp-2 text-xs text-muted-foreground">{t(`templates.${n.template}.body`, values)}</span>
|
|
208
|
+
<time dateTime={n.createdAt} title={fmt.dateTime(n.createdAt)} className="text-2xs text-muted-foreground tabular-nums">
|
|
209
|
+
{fmt.relative(n.createdAt)}
|
|
210
|
+
</time>
|
|
211
|
+
</span>
|
|
212
|
+
<span className="flex w-2 shrink-0 justify-center pt-2">
|
|
213
|
+
{unread && (
|
|
214
|
+
<>
|
|
215
|
+
<span aria-hidden className="size-2 rounded-full bg-primary" />
|
|
216
|
+
<span className="sr-only">{t('unread')}</span>
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
</span>
|
|
220
|
+
</>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const className =
|
|
224
|
+
'flex w-full items-start gap-3 rounded-lg px-2.5 py-2.5 text-left outline-none hover:bg-fill-1 focus-visible:bg-fill-1 focus-visible:ring-2 focus-visible:ring-ring'
|
|
225
|
+
|
|
226
|
+
return n.link ? (
|
|
227
|
+
<Link to={n.link} onClick={onClick} className={className}>
|
|
228
|
+
{content}
|
|
229
|
+
</Link>
|
|
230
|
+
) : (
|
|
231
|
+
<button type="button" onClick={onClick} className={className}>
|
|
232
|
+
{content}
|
|
233
|
+
</button>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function NotificationSkeleton() {
|
|
238
|
+
return (
|
|
239
|
+
<div role="status" className="flex flex-col gap-1 p-3">
|
|
240
|
+
{Array.from({ length: 4 }, (_, i) => (
|
|
241
|
+
<div key={i} className="flex items-start gap-3 px-1 py-2">
|
|
242
|
+
<Skeleton className="size-8 rounded-lg" />
|
|
243
|
+
<div className="flex flex-1 flex-col gap-1.5">
|
|
244
|
+
<Skeleton className="h-3.5 w-2/5" />
|
|
245
|
+
<Skeleton className="h-3 w-4/5" />
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ApiError } from '@/core/api/http'
|
|
2
|
+
import { demoSeed } from '@/core/api/demo'
|
|
3
|
+
import { createMockDocument, delay } from '@/core/api/mock'
|
|
4
|
+
import { authStore } from '@/core/auth/auth-store'
|
|
5
|
+
import type { Role } from '@/core/auth/roles'
|
|
6
|
+
import {
|
|
7
|
+
defaultNotificationSettings,
|
|
8
|
+
type AppNotification,
|
|
9
|
+
type NotificationSettings,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The signed-in user's notifications and notification settings. A real
|
|
14
|
+
* backend implements this with `http`, e.g.
|
|
15
|
+
*
|
|
16
|
+
* list: GET /me/notifications (server drops events the user turned off)
|
|
17
|
+
* markRead: POST /me/notifications/{id}/read
|
|
18
|
+
* markAllRead: POST /me/notifications/read-all
|
|
19
|
+
* settings: GET /me/notification-settings
|
|
20
|
+
* updateSettings: PUT /me/notification-settings
|
|
21
|
+
*
|
|
22
|
+
* and may add `subscribe` for push (SSE / WebSocket): the hooks then refetch
|
|
23
|
+
* on every pushed event instead of relying on polling alone.
|
|
24
|
+
*/
|
|
25
|
+
export interface NotificationsService {
|
|
26
|
+
list(): Promise<AppNotification[]>
|
|
27
|
+
markRead(id: string): Promise<void>
|
|
28
|
+
markAllRead(): Promise<void>
|
|
29
|
+
settings(): Promise<NotificationSettings>
|
|
30
|
+
updateSettings(settings: NotificationSettings): Promise<NotificationSettings>
|
|
31
|
+
/** Optional push channel. Calls `onChange` when the feed changes; returns an unsubscribe. */
|
|
32
|
+
subscribe?(onChange: () => void): () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type NotificationsDoc = {
|
|
36
|
+
items: AppNotification[]
|
|
37
|
+
settings: NotificationSettings
|
|
38
|
+
/** When the mock last "received" a notification; drives the occasional new one. */
|
|
39
|
+
lastArrivalAt: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Draft = Omit<AppNotification, 'id' | 'createdAt' | 'readAt'>
|
|
43
|
+
|
|
44
|
+
const minute = 60_000
|
|
45
|
+
|
|
46
|
+
/** Sample feed; without it the list starts empty and only a real backend adds to it. */
|
|
47
|
+
const demo = demoSeed<{
|
|
48
|
+
seedFor(role: Role): [Draft, number, boolean][]
|
|
49
|
+
arrivalsFor(role: Role): (() => Draft)[]
|
|
50
|
+
}>('notifications')
|
|
51
|
+
function createMockNotificationsService(): NotificationsService {
|
|
52
|
+
const docs = new Map<string, ReturnType<typeof createMockDocument<NotificationsDoc>>>()
|
|
53
|
+
|
|
54
|
+
const current = () => {
|
|
55
|
+
const user = authStore.getSession()?.user
|
|
56
|
+
if (!user) throw new ApiError(401, 'unauthorized')
|
|
57
|
+
if (!docs.has(user.id)) {
|
|
58
|
+
docs.set(
|
|
59
|
+
user.id,
|
|
60
|
+
createMockDocument(`notifications:${user.id}`, () => {
|
|
61
|
+
const now = Date.now()
|
|
62
|
+
const items = (demo?.seedFor(user.role) ?? []).map(([draft, ago, read], i): AppNotification => {
|
|
63
|
+
const createdAt = new Date(now - ago * minute).toISOString()
|
|
64
|
+
return { ...draft, id: `n_${user.id}_${i + 1}`, createdAt, readAt: read ? createdAt : undefined }
|
|
65
|
+
})
|
|
66
|
+
return { items, settings: defaultNotificationSettings(), lastArrivalAt: now }
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return { doc: docs.get(user.id)!, role: user.role }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const save = (patch: Partial<NotificationsDoc>) => {
|
|
74
|
+
const { doc } = current()
|
|
75
|
+
return doc.save({ ...doc.get(), ...patch })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Settings saved before an event type existed still get its default.
|
|
79
|
+
const settingsOf = (doc: NotificationsDoc): NotificationSettings => ({ ...defaultNotificationSettings(), ...doc.settings })
|
|
80
|
+
|
|
81
|
+
/** Every ~minute or two a new notification "arrives", so polling has something to pick up. */
|
|
82
|
+
function maybeArrive() {
|
|
83
|
+
const { doc, role } = current()
|
|
84
|
+
const data = doc.get()
|
|
85
|
+
if (Date.now() - data.lastArrivalAt < 45_000 || Math.random() > 0.4) return
|
|
86
|
+
const settings = settingsOf(data)
|
|
87
|
+
const pool = (demo?.arrivalsFor(role) ?? [])
|
|
88
|
+
.map((make) => make())
|
|
89
|
+
.filter((d) => settings[d.event].inApp)
|
|
90
|
+
const draft = pool[Math.floor(Math.random() * pool.length)]
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
if (!draft) return save({ lastArrivalAt: now })
|
|
93
|
+
const item: AppNotification = { ...draft, id: `n_${now.toString(36)}`, createdAt: new Date(now).toISOString() }
|
|
94
|
+
save({ items: [item, ...data.items].slice(0, 50), lastArrivalAt: now })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
async list() {
|
|
99
|
+
await delay(150, 300)
|
|
100
|
+
maybeArrive()
|
|
101
|
+
const data = current().doc.get()
|
|
102
|
+
const settings = settingsOf(data)
|
|
103
|
+
return data.items
|
|
104
|
+
.filter((n) => settings[n.event]?.inApp !== false)
|
|
105
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async markRead(id) {
|
|
109
|
+
await delay(100, 200)
|
|
110
|
+
const now = new Date().toISOString()
|
|
111
|
+
save({ items: current().doc.get().items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: now } : n)) })
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async markAllRead() {
|
|
115
|
+
await delay(150, 300)
|
|
116
|
+
const now = new Date().toISOString()
|
|
117
|
+
save({ items: current().doc.get().items.map((n) => (n.readAt ? n : { ...n, readAt: now })) })
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async settings() {
|
|
121
|
+
await delay(150, 300)
|
|
122
|
+
return settingsOf(current().doc.get())
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async updateSettings(settings) {
|
|
126
|
+
await delay()
|
|
127
|
+
return save({ settings }).settings
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const notificationsService: NotificationsService = createMockNotificationsService()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isDemo } from '@/core/config/runtime'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Notification event types. One catalog drives both the Settings → Notifications
|
|
5
|
+
* toggles (email / in-app per event) and the in-app notification feed: a
|
|
6
|
+
* notification always belongs to one event, so turning the event off hides it.
|
|
7
|
+
*/
|
|
8
|
+
export const notificationEvents = [
|
|
9
|
+
{ id: 'newSignIn', group: 'account' },
|
|
10
|
+
{ id: 'passwordChanged', group: 'account' },
|
|
11
|
+
{ id: 'userJoined', group: 'team' },
|
|
12
|
+
{ id: 'roleChanged', group: 'team' },
|
|
13
|
+
{ id: 'settingsChanged', group: 'company' },
|
|
14
|
+
{ id: 'lowStock', group: 'catalog' },
|
|
15
|
+
{ id: 'productChanged', group: 'catalog' },
|
|
16
|
+
{ id: 'weeklySummary', group: 'updates' },
|
|
17
|
+
{ id: 'announcements', group: 'updates' },
|
|
18
|
+
] as const
|
|
19
|
+
|
|
20
|
+
/** What Settings → Notifications lists; catalog events belong to the demo's sample module. */
|
|
21
|
+
export const visibleNotificationEvents = notificationEvents.filter((e) => isDemo || e.group !== 'catalog')
|
|
22
|
+
|
|
23
|
+
export type NotificationEventId = (typeof notificationEvents)[number]['id']
|
|
24
|
+
export type NotificationGroup = (typeof notificationEvents)[number]['group']
|
|
25
|
+
export type NotificationChannels = { email: boolean; inApp: boolean }
|
|
26
|
+
export type NotificationSettings = Record<NotificationEventId, NotificationChannels>
|
|
27
|
+
|
|
28
|
+
export function defaultNotificationSettings(): NotificationSettings {
|
|
29
|
+
return Object.fromEntries(
|
|
30
|
+
notificationEvents.map((e) => [e.id, { email: e.group === 'account', inApp: e.id !== 'announcements' }]),
|
|
31
|
+
) as NotificationSettings
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Visual tone of a notification: picks the icon and its semantic color. */
|
|
35
|
+
export const notificationTypes = ['info', 'success', 'warning', 'error'] as const
|
|
36
|
+
export type NotificationType = (typeof notificationTypes)[number]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Message templates. The API sends a template id plus params (never
|
|
40
|
+
* pre-rendered text), so the client renders it in the active language from
|
|
41
|
+
* `notifications:templates.<template>.title|body`.
|
|
42
|
+
*/
|
|
43
|
+
export const notificationTemplates = [
|
|
44
|
+
'userInvited',
|
|
45
|
+
'userJoined',
|
|
46
|
+
'roleChangedOther',
|
|
47
|
+
'roleChangedYou',
|
|
48
|
+
'lowStock',
|
|
49
|
+
'productUpdated',
|
|
50
|
+
'settingsChanged',
|
|
51
|
+
'brandingChanged',
|
|
52
|
+
'securityAlert',
|
|
53
|
+
'passwordChanged',
|
|
54
|
+
'weeklySummary',
|
|
55
|
+
'announcement',
|
|
56
|
+
] as const
|
|
57
|
+
export type NotificationTemplate = (typeof notificationTemplates)[number]
|
|
58
|
+
|
|
59
|
+
export type AppNotification = {
|
|
60
|
+
id: string
|
|
61
|
+
event: NotificationEventId
|
|
62
|
+
type: NotificationType
|
|
63
|
+
template: NotificationTemplate
|
|
64
|
+
/** Interpolation values for the template (names, counts, roles…). */
|
|
65
|
+
params: Record<string, string | number>
|
|
66
|
+
/** In-app path opened when the notification is clicked. */
|
|
67
|
+
link?: string
|
|
68
|
+
createdAt: string
|
|
69
|
+
readAt?: string
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
import { storage } from '@/core/lib/storage'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-user display preferences (Settings → Preferences). Theme and language
|
|
7
|
+
* have their own stores (theme-context, i18n); these are the rest.
|
|
8
|
+
* Stored in this browser (`fp:preferences`); a backend would keep them on
|
|
9
|
+
* the user record and return them with the session.
|
|
10
|
+
*/
|
|
11
|
+
export const dateFormats = ['auto', 'dd.MM.yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd'] as const
|
|
12
|
+
export type DateFormat = (typeof dateFormats)[number]
|
|
13
|
+
|
|
14
|
+
export const sidebarDefaults = ['remember', 'expanded', 'collapsed'] as const
|
|
15
|
+
export type SidebarDefault = (typeof sidebarDefaults)[number]
|
|
16
|
+
|
|
17
|
+
export type Preferences = {
|
|
18
|
+
/** IANA zone or 'auto' (the browser's). */
|
|
19
|
+
timeZone: string
|
|
20
|
+
dateFormat: DateFormat
|
|
21
|
+
sidebarDefault: SidebarDefault
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEY = 'preferences'
|
|
25
|
+
const defaults: Preferences = { timeZone: 'auto', dateFormat: 'auto', sidebarDefault: 'remember' }
|
|
26
|
+
const listeners = new Set<() => void>()
|
|
27
|
+
let current: Preferences = { ...defaults, ...storage.get<Partial<Preferences>>(KEY) }
|
|
28
|
+
|
|
29
|
+
export const preferencesStore = {
|
|
30
|
+
get: () => current,
|
|
31
|
+
set(patch: Partial<Preferences>) {
|
|
32
|
+
current = { ...current, ...patch }
|
|
33
|
+
storage.set(KEY, current)
|
|
34
|
+
for (const l of listeners) l()
|
|
35
|
+
},
|
|
36
|
+
subscribe(listener: () => void) {
|
|
37
|
+
listeners.add(listener)
|
|
38
|
+
return () => listeners.delete(listener)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function usePreferences() {
|
|
43
|
+
return useSyncExternalStore(preferencesStore.subscribe, preferencesStore.get)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Curated IANA zones for pickers (labels come from Intl). */
|
|
47
|
+
export const timeZones = [
|
|
48
|
+
'Europe/Istanbul',
|
|
49
|
+
'Europe/London',
|
|
50
|
+
'Europe/Berlin',
|
|
51
|
+
'Europe/Paris',
|
|
52
|
+
'Europe/Madrid',
|
|
53
|
+
'Europe/Amsterdam',
|
|
54
|
+
'Europe/Moscow',
|
|
55
|
+
'Asia/Dubai',
|
|
56
|
+
'Asia/Riyadh',
|
|
57
|
+
'Asia/Kolkata',
|
|
58
|
+
'Asia/Singapore',
|
|
59
|
+
'Asia/Shanghai',
|
|
60
|
+
'Asia/Tokyo',
|
|
61
|
+
'Australia/Sydney',
|
|
62
|
+
'America/New_York',
|
|
63
|
+
'America/Chicago',
|
|
64
|
+
'America/Denver',
|
|
65
|
+
'America/Los_Angeles',
|
|
66
|
+
'America/Sao_Paulo',
|
|
67
|
+
'UTC',
|
|
68
|
+
] as const
|
|
69
|
+
|
|
70
|
+
export const browserTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { Navigate, Outlet, useLocation } from 'react-router'
|
|
3
|
+
|
|
4
|
+
import { setupService } from './setup.service'
|
|
5
|
+
|
|
6
|
+
/** Sends a brand-new install to the setup wizard, and keeps finished installs out of it. */
|
|
7
|
+
export function SetupGate() {
|
|
8
|
+
const { pathname } = useLocation()
|
|
9
|
+
const { data: needsSetup, isPending } = useQuery({
|
|
10
|
+
queryKey: ['setup', 'status'],
|
|
11
|
+
queryFn: () => setupService.needsSetup(),
|
|
12
|
+
staleTime: Infinity,
|
|
13
|
+
retry: false,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
if (isPending) return <div className="min-h-svh bg-canvas" />
|
|
17
|
+
if (needsSetup && pathname !== '/setup') return <Navigate to="/setup" replace />
|
|
18
|
+
if (!needsSetup && pathname === '/setup') return <Navigate to="/app" replace />
|
|
19
|
+
return <Outlet />
|
|
20
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ApiError, http } from '@/core/api/http'
|
|
2
|
+
import { delay } from '@/core/api/mock'
|
|
3
|
+
import { authStore } from '@/core/auth/auth-store'
|
|
4
|
+
import { fetchCsrfCookie, startLocalSession, type AuthResponse } from '@/core/auth/auth.service'
|
|
5
|
+
import { localCredentials } from '@/core/auth/local-credentials'
|
|
6
|
+
import { createCompanySettings } from '@/core/company/company.service'
|
|
7
|
+
import type { CompanyGeneral, Currency } from '@/core/company/types'
|
|
8
|
+
import { authMode, isDemo } from '@/core/config/runtime'
|
|
9
|
+
import { storage } from '@/core/lib/storage'
|
|
10
|
+
import type { Language } from '@/core/i18n/types'
|
|
11
|
+
import type { BrandSetting } from '@/core/theme/brand'
|
|
12
|
+
import type { User } from '@/core/users/types'
|
|
13
|
+
|
|
14
|
+
export type SetupInput = {
|
|
15
|
+
company: { name: string; language: Language; timeZone: string; currency: Currency }
|
|
16
|
+
admin: { firstName: string; lastName: string; email: string; password: string }
|
|
17
|
+
branding: { brand: BrandSetting; logo?: string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* First run: create the company and its Owner. With a backend these are
|
|
22
|
+
* GET /setup/status and POST /setup (see BACKEND.md), and the server must
|
|
23
|
+
* refuse POST /setup once an Owner exists.
|
|
24
|
+
*/
|
|
25
|
+
export interface SetupService {
|
|
26
|
+
needsSetup(): Promise<boolean>
|
|
27
|
+
complete(input: SetupInput): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Company name, logo and brand stay in the company service until it is connected to a backend too. */
|
|
31
|
+
function saveCompany({ company, admin, branding }: SetupInput) {
|
|
32
|
+
const general: Partial<CompanyGeneral> = {
|
|
33
|
+
name: company.name,
|
|
34
|
+
contactEmail: admin.email,
|
|
35
|
+
defaultLanguage: company.language,
|
|
36
|
+
timeZone: company.timeZone,
|
|
37
|
+
currency: company.currency,
|
|
38
|
+
}
|
|
39
|
+
storage.set('mock:company', createCompanySettings(general, { brand: branding.brand, logoLight: branding.logo, logoDark: branding.logo }))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clears anything left in this browser, such as data from another project on the same address. */
|
|
43
|
+
function clearLocalData() {
|
|
44
|
+
for (const key of Object.keys(localStorage)) {
|
|
45
|
+
if (key.startsWith('fp:mock:')) storage.remove(key.slice(3))
|
|
46
|
+
}
|
|
47
|
+
authStore.clear()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const localSetupService: SetupService = {
|
|
51
|
+
async needsSetup() {
|
|
52
|
+
return !localCredentials.any()
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async complete(input) {
|
|
56
|
+
await delay(300, 500)
|
|
57
|
+
if (localCredentials.any()) throw new ApiError(409, 'already_set_up')
|
|
58
|
+
clearLocalData()
|
|
59
|
+
const { firstName, lastName, email, password } = input.admin
|
|
60
|
+
const now = new Date().toISOString()
|
|
61
|
+
const owner: User = {
|
|
62
|
+
id: 'u_owner',
|
|
63
|
+
firstName,
|
|
64
|
+
lastName,
|
|
65
|
+
email: email.trim().toLowerCase(),
|
|
66
|
+
role: 'owner',
|
|
67
|
+
status: 'active',
|
|
68
|
+
createdAt: now,
|
|
69
|
+
lastActiveAt: now,
|
|
70
|
+
}
|
|
71
|
+
storage.set('mock:users', [owner])
|
|
72
|
+
saveCompany(input)
|
|
73
|
+
await localCredentials.set(owner.id, password)
|
|
74
|
+
startLocalSession(owner)
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const restSetupService: SetupService = {
|
|
79
|
+
async needsSetup() {
|
|
80
|
+
// A backend that creates its admin another way (seeder, CLI) simply doesn't have this endpoint.
|
|
81
|
+
try {
|
|
82
|
+
return (await http.get<{ needsSetup: boolean }>('/setup/status')).needsSetup
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async complete(input) {
|
|
89
|
+
await fetchCsrfCookie()
|
|
90
|
+
const { user, token, expiresAt } = await http.post<AuthResponse>('/setup', input)
|
|
91
|
+
saveCompany(input)
|
|
92
|
+
authStore.set({ user, token, expiresAt: expiresAt ?? Date.now() + 7 * 86_400_000 })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const demoSetupService: SetupService = {
|
|
97
|
+
needsSetup: async () => false,
|
|
98
|
+
complete: async () => {},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const setupService: SetupService = isDemo ? demoSetupService : authMode === 'mock' ? localSetupService : restSetupService
|