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,55 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Blocks } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { useAuth } from '@/core/auth/use-auth'
|
|
6
|
+
import { isDemo } from '@/core/config/runtime'
|
|
7
|
+
import { Page } from '@/core/components/page'
|
|
8
|
+
import { EmptyState, LoadingState } from '@/core/components/states'
|
|
9
|
+
import { enabledModules } from '@/modules/registry'
|
|
10
|
+
import { QuickActionsCard } from './quick-actions'
|
|
11
|
+
|
|
12
|
+
// A glob, not an import: the public demo's sample dashboard isn't part of installed copies.
|
|
13
|
+
const demoLoader = Object.values(
|
|
14
|
+
import.meta.glob<{ DemoDashboardPage: React.ComponentType }>('/src/demo/dashboard-page.tsx'),
|
|
15
|
+
)[0]
|
|
16
|
+
const DemoDashboardPage =
|
|
17
|
+
isDemo && demoLoader ? lazy(async () => ({ default: (await demoLoader()).DemoDashboardPage })) : null
|
|
18
|
+
|
|
19
|
+
const moduleWidgets = enabledModules.flatMap((m) => m.dashboardWidgets ?? [])
|
|
20
|
+
|
|
21
|
+
function greetingKey() {
|
|
22
|
+
const h = new Date().getHours()
|
|
23
|
+
return h < 12 ? 'greeting.morning' : h < 18 ? 'greeting.afternoon' : 'greeting.evening'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function DashboardPage() {
|
|
27
|
+
const { user } = useAuth()
|
|
28
|
+
const { t } = useTranslation(['dashboard', 'common'])
|
|
29
|
+
|
|
30
|
+
if (DemoDashboardPage) {
|
|
31
|
+
return (
|
|
32
|
+
<Suspense fallback={<LoadingState />}>
|
|
33
|
+
<DemoDashboardPage />
|
|
34
|
+
</Suspense>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Page
|
|
40
|
+
title={t(greetingKey(), { name: user?.firstName || t('greeting.fallbackName') })}
|
|
41
|
+
documentTitle={t('common:nav.dashboard')}
|
|
42
|
+
description={t('readyDescription')}
|
|
43
|
+
>
|
|
44
|
+
{moduleWidgets.map((Widget, i) => (
|
|
45
|
+
<Widget key={i} />
|
|
46
|
+
))}
|
|
47
|
+
{enabledModules.length === 0 && (
|
|
48
|
+
<div className="rounded-xl border bg-card shadow-(--shadow-card)">
|
|
49
|
+
<EmptyState icon={<Blocks />} title={t('noModules.title')} description={t('noModules.description')} />
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
<QuickActionsCard />
|
|
53
|
+
</Page>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Link } from 'react-router'
|
|
2
|
+
import { ArrowRight } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { usePermissions } from '@/core/auth/can'
|
|
5
|
+
import { Panel } from '@/core/components/page'
|
|
6
|
+
import { useAnyT } from '@/core/i18n/keys'
|
|
7
|
+
import { quickActions } from '@/core/modules/navigation'
|
|
8
|
+
|
|
9
|
+
export function QuickActionsCard() {
|
|
10
|
+
const t = useAnyT()
|
|
11
|
+
const { can } = usePermissions()
|
|
12
|
+
return (
|
|
13
|
+
<Panel label={t('dashboard:panels.quickActions')} bodyClassName="p-2">
|
|
14
|
+
<ul className="flex flex-col gap-0.5">
|
|
15
|
+
{quickActions
|
|
16
|
+
.filter((a) => !a.permission || can(a.permission))
|
|
17
|
+
.map(({ path, label, description, icon: Icon }) => (
|
|
18
|
+
<li key={path}>
|
|
19
|
+
<Link
|
|
20
|
+
to={path}
|
|
21
|
+
className="group flex items-center gap-3 rounded-lg p-2 outline-none hover:bg-fill-1 focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
22
|
+
>
|
|
23
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-raised text-muted-foreground shadow-(--shadow-raised) group-hover:text-primary">
|
|
24
|
+
<Icon className="size-4" />
|
|
25
|
+
</span>
|
|
26
|
+
<span className="min-w-0 flex-1">
|
|
27
|
+
<span className="block text-sm font-medium">{t(label)}</span>
|
|
28
|
+
{description && <span className="block truncate text-xs text-muted-foreground">{t(description)}</span>}
|
|
29
|
+
</span>
|
|
30
|
+
<ArrowRight className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
|
31
|
+
</Link>
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
</Panel>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { useForm } from 'react-hook-form'
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { Navigate, useNavigate, useSearchParams } from 'react-router'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
|
|
9
|
+
import { Button } from '@/components/ui/button'
|
|
10
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
11
|
+
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
|
|
12
|
+
import { Input } from '@/components/ui/input'
|
|
13
|
+
import { Spinner } from '@/components/ui/spinner'
|
|
14
|
+
import { demoSeed } from '@/core/api/demo'
|
|
15
|
+
import { ApiError } from '@/core/api/http'
|
|
16
|
+
import { useAuth } from '@/core/auth/use-auth'
|
|
17
|
+
import { useCompanySettings } from '@/core/company/hooks'
|
|
18
|
+
import { useAppName } from '@/core/company/use-app-name'
|
|
19
|
+
import { isDemo } from '@/core/config/runtime'
|
|
20
|
+
import { FormFieldError } from '@/core/components/form-error'
|
|
21
|
+
import { LanguageSwitcher } from '@/core/i18n/language-switcher'
|
|
22
|
+
import { vmsg } from '@/core/i18n/keys'
|
|
23
|
+
import { ThemeToggle } from '@/core/theme/theme-toggle'
|
|
24
|
+
|
|
25
|
+
const schema = z.object({
|
|
26
|
+
email: z.email(vmsg('validation.email')),
|
|
27
|
+
password: z.string().min(1, vmsg('auth:signIn.passwordRequired')),
|
|
28
|
+
})
|
|
29
|
+
type Values = z.infer<typeof schema>
|
|
30
|
+
|
|
31
|
+
/** Only allow same-app redirects (no open redirect via ?redirect=https://…). */
|
|
32
|
+
function safeRedirect(value: string | null) {
|
|
33
|
+
return value && value.startsWith('/app') ? value : '/app'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function LoginPage() {
|
|
37
|
+
const { isAuthenticated, login } = useAuth()
|
|
38
|
+
const [params] = useSearchParams()
|
|
39
|
+
const navigate = useNavigate()
|
|
40
|
+
const redirect = safeRedirect(params.get('redirect'))
|
|
41
|
+
const { t } = useTranslation('auth')
|
|
42
|
+
|
|
43
|
+
const { register, handleSubmit, formState } = useForm<Values>({
|
|
44
|
+
resolver: zodResolver(schema),
|
|
45
|
+
defaultValues: { email: demoSeed<string>('loginEmail') ?? '', password: '' },
|
|
46
|
+
})
|
|
47
|
+
const { errors, isSubmitting } = formState
|
|
48
|
+
|
|
49
|
+
const appName = useAppName()
|
|
50
|
+
const title = t('signIn.title')
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
document.title = `${title} · ${appName}`
|
|
53
|
+
}, [title, appName])
|
|
54
|
+
|
|
55
|
+
if (isAuthenticated && !isSubmitting) return <Navigate to={redirect} replace />
|
|
56
|
+
|
|
57
|
+
const onSubmit = handleSubmit(async (values) => {
|
|
58
|
+
try {
|
|
59
|
+
const session = await login(values)
|
|
60
|
+
toast.success(t('signIn.welcome', { name: session.user.firstName }))
|
|
61
|
+
navigate(redirect, { replace: true })
|
|
62
|
+
} catch (e) {
|
|
63
|
+
const status = e instanceof ApiError ? e.status : undefined
|
|
64
|
+
const description =
|
|
65
|
+
status === 401 ? t('signIn.invalid') : status === 403 ? t('signIn.inactive') : e instanceof Error ? e.message : undefined
|
|
66
|
+
toast.error(t('signIn.failed'), { description })
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
// Equal 1fr rows above and below keep the card exactly centered; the demo note sits in the bottom row.
|
|
72
|
+
<div className="relative grid min-h-svh grid-rows-[1fr_auto_1fr] justify-items-center bg-canvas px-4 py-10 md:px-10">
|
|
73
|
+
<div className="absolute top-3 right-3 flex items-center gap-1.5 sm:top-4 sm:right-4">
|
|
74
|
+
<LanguageSwitcher />
|
|
75
|
+
<ThemeToggle />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<Card className="row-start-2 w-full max-w-sm gap-0 rounded-(--radius-panel) py-0 shadow-(--shadow-panel) md:max-w-4xl">
|
|
79
|
+
<CardContent className="grid p-0 md:grid-cols-2">
|
|
80
|
+
<form onSubmit={onSubmit} noValidate className="p-6 sm:p-8 md:p-10">
|
|
81
|
+
<FieldGroup className="gap-4">
|
|
82
|
+
<div className="mb-2 flex flex-col items-center gap-2 text-center">
|
|
83
|
+
<h1 className="text-2xl font-semibold tracking-tight">{t('signIn.heading')}</h1>
|
|
84
|
+
<p className="text-sm text-balance text-muted-foreground">
|
|
85
|
+
{t('signIn.description', { name: appName })}
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<Field data-invalid={!!errors.email}>
|
|
89
|
+
<FieldLabel htmlFor="email">{t('signIn.email')}</FieldLabel>
|
|
90
|
+
<Input
|
|
91
|
+
id="email"
|
|
92
|
+
type="email"
|
|
93
|
+
autoComplete="email"
|
|
94
|
+
autoFocus={!isDemo}
|
|
95
|
+
size="lg"
|
|
96
|
+
aria-invalid={!!errors.email}
|
|
97
|
+
{...register('email')}
|
|
98
|
+
/>
|
|
99
|
+
<FormFieldError error={errors.email} />
|
|
100
|
+
</Field>
|
|
101
|
+
<Field data-invalid={!!errors.password}>
|
|
102
|
+
<FieldLabel htmlFor="password">{t('signIn.password')}</FieldLabel>
|
|
103
|
+
<Input
|
|
104
|
+
id="password"
|
|
105
|
+
type="password"
|
|
106
|
+
autoComplete="current-password"
|
|
107
|
+
autoFocus={isDemo}
|
|
108
|
+
size="lg"
|
|
109
|
+
aria-invalid={!!errors.password}
|
|
110
|
+
{...register('password')}
|
|
111
|
+
/>
|
|
112
|
+
<FormFieldError error={errors.password} />
|
|
113
|
+
</Field>
|
|
114
|
+
<Button type="submit" size="lg" className="mt-2 w-full" disabled={isSubmitting}>
|
|
115
|
+
{isSubmitting && <Spinner data-icon="inline-start" />}
|
|
116
|
+
{t('signIn.submit')}
|
|
117
|
+
</Button>
|
|
118
|
+
<FieldDescription className="text-center">{t('signIn.invitationOnly')}</FieldDescription>
|
|
119
|
+
</FieldGroup>
|
|
120
|
+
</form>
|
|
121
|
+
<BrandPanel />
|
|
122
|
+
</CardContent>
|
|
123
|
+
</Card>
|
|
124
|
+
|
|
125
|
+
{isDemo && (
|
|
126
|
+
<p className="row-start-3 max-w-sm self-end pt-4 text-center text-xs text-balance text-muted-foreground md:max-w-md">
|
|
127
|
+
{t('signIn.demo', { domain: demoSeed<string>('loginDomain') })}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Image side of the split card: a neutral, token-only placeholder (no stock photo). Hidden on mobile. */
|
|
135
|
+
function BrandPanel() {
|
|
136
|
+
const appName = useAppName()
|
|
137
|
+
const logo = useCompanySettings().data?.branding.logoLight
|
|
138
|
+
return (
|
|
139
|
+
<div aria-hidden className="relative hidden items-center justify-center overflow-hidden border-l bg-fill-1 md:flex">
|
|
140
|
+
<div className="absolute inset-0 bg-[radial-gradient(var(--border)_1px,transparent_1px)] bg-size-[16px_16px] mask-[radial-gradient(ellipse_at_center,black_30%,transparent_75%)]" />
|
|
141
|
+
<div className="relative flex size-24 items-center justify-center rounded-(--radius-panel) bg-raised shadow-(--shadow-raised) ring-1 ring-border">
|
|
142
|
+
{logo ? (
|
|
143
|
+
<img src={logo} alt="" className="max-h-16 max-w-16 object-contain" />
|
|
144
|
+
) : (
|
|
145
|
+
<span className="flex size-12 items-center justify-center rounded-xl bg-foreground text-xl font-semibold text-background">
|
|
146
|
+
{appName[0]?.toUpperCase()}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
|
|
3
|
+
import { Message } from './not-found-page'
|
|
4
|
+
|
|
5
|
+
/** Shown (inside the app panel) when a route guard denies access. */
|
|
6
|
+
export default function NoAccessPage() {
|
|
7
|
+
const { t } = useTranslation('errors')
|
|
8
|
+
return (
|
|
9
|
+
<Message code={t('noAccess.code')} title={t('noAccess.title')} description={t('noAccess.description')} inset />
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { isRouteErrorResponse, useRouteError } from 'react-router'
|
|
4
|
+
import { ArrowLeft } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
import { ButtonLink } from '@/core/components/button-link'
|
|
7
|
+
import { cn } from '@/lib/utils'
|
|
8
|
+
|
|
9
|
+
/** Centered full-page message: 404, unexpected error, no access. */
|
|
10
|
+
export function Message({
|
|
11
|
+
code,
|
|
12
|
+
title,
|
|
13
|
+
description,
|
|
14
|
+
inset,
|
|
15
|
+
}: {
|
|
16
|
+
code: string
|
|
17
|
+
title: string
|
|
18
|
+
description: string
|
|
19
|
+
inset?: boolean
|
|
20
|
+
}) {
|
|
21
|
+
const { t } = useTranslation()
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
document.title = title
|
|
25
|
+
}, [title])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn('flex flex-col items-center justify-center px-6 text-center', inset ? 'py-24' : 'min-h-svh bg-canvas')}>
|
|
29
|
+
<span className="label-caps">{code}</span>
|
|
30
|
+
<h1 className="mt-3 text-3xl font-semibold tracking-tight">{title}</h1>
|
|
31
|
+
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
32
|
+
<ButtonLink to={inset ? '/app' : '/'} variant="outline" className="mt-8">
|
|
33
|
+
<ArrowLeft data-icon="inline-start" />
|
|
34
|
+
{inset ? t('actions.backToDashboard') : t('actions.backHome')}
|
|
35
|
+
</ButtonLink>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 404. `inset` renders it inside the app panel instead of full screen. */
|
|
41
|
+
export default function NotFoundPage({ inset }: { inset?: boolean }) {
|
|
42
|
+
const { t } = useTranslation('errors')
|
|
43
|
+
return (
|
|
44
|
+
<Message code={t('notFound.code')} title={t('notFound.title')} description={t('notFound.description')} inset={inset} />
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Route errorElement: 404s from loaders and unexpected render errors. */
|
|
49
|
+
export function RouteError({ inset }: { inset?: boolean }) {
|
|
50
|
+
const error = useRouteError()
|
|
51
|
+
const { t } = useTranslation('errors')
|
|
52
|
+
if (isRouteErrorResponse(error) && error.status === 404) return <NotFoundPage inset={inset} />
|
|
53
|
+
return (
|
|
54
|
+
<Message
|
|
55
|
+
code={t('unexpected.code')}
|
|
56
|
+
title={t('unexpected.title')}
|
|
57
|
+
description={error instanceof Error ? error.message : t('unexpected.description')}
|
|
58
|
+
inset={inset}
|
|
59
|
+
/>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { ApiError } from '@/core/api/http'
|
|
2
|
+
import { demoSeed } from '@/core/api/demo'
|
|
3
|
+
import { createMockDocument, delay } from '@/core/api/mock'
|
|
4
|
+
import { appConfig } from '@/app.config'
|
|
5
|
+
import { authStore } from '@/core/auth/auth-store'
|
|
6
|
+
import { localCredentials } from '@/core/auth/local-credentials'
|
|
7
|
+
import { isDemo } from '@/core/config/runtime'
|
|
8
|
+
import {
|
|
9
|
+
type ActiveSession,
|
|
10
|
+
type LoginEvent,
|
|
11
|
+
type PasswordChange,
|
|
12
|
+
type SecurityOverview,
|
|
13
|
+
type TwoFactorSetup,
|
|
14
|
+
} from './types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The signed-in user's own security settings (notification settings live in
|
|
18
|
+
* `core/notifications`). A real
|
|
19
|
+
* backend implements this with `http` (e.g. PUT /me/password,
|
|
20
|
+
* POST /me/2fa/setup, GET /me/sessions); screens stay the same.
|
|
21
|
+
*/
|
|
22
|
+
export interface AccountService {
|
|
23
|
+
security(): Promise<SecurityOverview>
|
|
24
|
+
changePassword(input: PasswordChange): Promise<void>
|
|
25
|
+
startTwoFactorSetup(): Promise<TwoFactorSetup>
|
|
26
|
+
confirmTwoFactor(code: string): Promise<{ recoveryCodes: string[] }>
|
|
27
|
+
disableTwoFactor(): Promise<void>
|
|
28
|
+
sessions(): Promise<ActiveSession[]>
|
|
29
|
+
revokeSession(id: string): Promise<void>
|
|
30
|
+
revokeOtherSessions(): Promise<void>
|
|
31
|
+
loginHistory(): Promise<LoginEvent[]>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Error codes in ApiError.message; screens map them to translations. */
|
|
35
|
+
export const accountErrors = { wrongPassword: 'wrong_password', invalidCode: 'invalid_code' } as const
|
|
36
|
+
|
|
37
|
+
export type AccountDoc = {
|
|
38
|
+
security: SecurityOverview
|
|
39
|
+
sessions: ActiveSession[]
|
|
40
|
+
history: LoginEvent[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const blankAccount = (): AccountDoc => ({
|
|
44
|
+
security: { passwordChangedAt: new Date().toISOString(), twoFactorEnabled: false },
|
|
45
|
+
sessions: [],
|
|
46
|
+
history: [],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
function createMockAccountService(): AccountService {
|
|
50
|
+
const docs = new Map<string, ReturnType<typeof createMockDocument<AccountDoc>>>()
|
|
51
|
+
const doc = () => {
|
|
52
|
+
const userId = authStore.getSession()?.user.id
|
|
53
|
+
if (!userId) throw new ApiError(401, 'unauthorized')
|
|
54
|
+
if (!docs.has(userId)) docs.set(userId, createMockDocument(`account:${userId}`, () => demoSeed<(id: string) => AccountDoc>('account')?.(userId) ?? blankAccount()))
|
|
55
|
+
return docs.get(userId)!
|
|
56
|
+
}
|
|
57
|
+
const update = (patch: Partial<AccountDoc>) => doc().save({ ...doc().get(), ...patch })
|
|
58
|
+
let pendingSecret: string | null = null
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
async security() {
|
|
62
|
+
await delay(150, 300)
|
|
63
|
+
return doc().get().security
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async changePassword({ current, next }) {
|
|
67
|
+
await delay(500, 800)
|
|
68
|
+
if (isDemo) {
|
|
69
|
+
// Demo rule so the error path can be tried: "wrong" is never the current password.
|
|
70
|
+
if (current === 'wrong') throw new ApiError(400, accountErrors.wrongPassword)
|
|
71
|
+
} else {
|
|
72
|
+
const userId = authStore.getSession()?.user.id ?? ''
|
|
73
|
+
if (!(await localCredentials.verify(userId, current))) throw new ApiError(400, accountErrors.wrongPassword)
|
|
74
|
+
await localCredentials.set(userId, next)
|
|
75
|
+
}
|
|
76
|
+
update({ security: { ...doc().get().security, passwordChangedAt: new Date().toISOString() } })
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async startTwoFactorSetup() {
|
|
80
|
+
await delay(200, 400)
|
|
81
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
82
|
+
pendingSecret = Array.from({ length: 32 }, () => alphabet[Math.floor(Math.random() * 32)]).join('')
|
|
83
|
+
const email = authStore.getSession()?.user.email ?? ''
|
|
84
|
+
return {
|
|
85
|
+
secret: pendingSecret.match(/.{4}/g)!.join(' '),
|
|
86
|
+
otpauthUrl: `otpauth://totp/${encodeURIComponent(appConfig.name)}:${encodeURIComponent(email)}?secret=${pendingSecret}&issuer=${encodeURIComponent(appConfig.name)}`,
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async confirmTwoFactor(code) {
|
|
91
|
+
await delay(400, 700)
|
|
92
|
+
if (!pendingSecret || !/^\d{6}$/.test(code)) throw new ApiError(400, accountErrors.invalidCode)
|
|
93
|
+
pendingSecret = null
|
|
94
|
+
update({
|
|
95
|
+
security: { ...doc().get().security, twoFactorEnabled: true, twoFactorEnabledAt: new Date().toISOString() },
|
|
96
|
+
})
|
|
97
|
+
const chunk = () => Math.random().toString(36).slice(2, 7)
|
|
98
|
+
return { recoveryCodes: Array.from({ length: 8 }, () => `${chunk()}-${chunk()}`) }
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async disableTwoFactor() {
|
|
102
|
+
await delay()
|
|
103
|
+
update({ security: { ...doc().get().security, twoFactorEnabled: false, twoFactorEnabledAt: undefined } })
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async sessions() {
|
|
107
|
+
await delay(150, 350)
|
|
108
|
+
return doc().get().sessions
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async revokeSession(id) {
|
|
112
|
+
await delay()
|
|
113
|
+
update({ sessions: doc().get().sessions.filter((s) => s.id !== id || s.current) })
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async revokeOtherSessions() {
|
|
117
|
+
await delay()
|
|
118
|
+
update({ sessions: doc().get().sessions.filter((s) => s.current) })
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async loginHistory() {
|
|
122
|
+
await delay(150, 350)
|
|
123
|
+
return doc().get().history
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const accountService: AccountService = createMockAccountService()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import { seededRandom } from '@/core/api/mock'
|
|
4
|
+
|
|
5
|
+
const SIZE = 25
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A QR-looking placeholder derived from `value` (deterministic). A real
|
|
9
|
+
* backend returns the otpauth URL and a QR library or the API renders it.
|
|
10
|
+
*/
|
|
11
|
+
export function MockQr({ value, label, className }: { value: string; label: string; className?: string }) {
|
|
12
|
+
const cells = useMemo(() => {
|
|
13
|
+
let seed = 0
|
|
14
|
+
for (const ch of value) seed = (seed * 31 + ch.charCodeAt(0)) % 2147483647
|
|
15
|
+
const rand = seededRandom(seed || 1)
|
|
16
|
+
const finder = (x: number, y: number) =>
|
|
17
|
+
[
|
|
18
|
+
[0, 0],
|
|
19
|
+
[SIZE - 7, 0],
|
|
20
|
+
[0, SIZE - 7],
|
|
21
|
+
].some(([fx, fy]) => x >= fx! && x < fx! + 7 && y >= fy! && y < fy! + 7)
|
|
22
|
+
const out: [number, number][] = []
|
|
23
|
+
for (let y = 0; y < SIZE; y++) {
|
|
24
|
+
for (let x = 0; x < SIZE; x++) {
|
|
25
|
+
if (finder(x, y)) continue
|
|
26
|
+
if (rand() > 0.52) out.push([x, y])
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}, [value])
|
|
31
|
+
|
|
32
|
+
const finderAt = (x: number, y: number) => (
|
|
33
|
+
<g key={`${x}-${y}`}>
|
|
34
|
+
<rect x={x} y={y} width={7} height={7} fill="currentColor" />
|
|
35
|
+
<rect x={x + 1} y={y + 1} width={5} height={5} className="fill-card" />
|
|
36
|
+
<rect x={x + 2} y={y + 2} width={3} height={3} fill="currentColor" />
|
|
37
|
+
</g>
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<svg viewBox={`-1 -1 ${SIZE + 2} ${SIZE + 2}`} role="img" aria-label={label} className={className} shapeRendering="crispEdges">
|
|
42
|
+
{cells.map(([x, y]) => (
|
|
43
|
+
<rect key={`${x}:${y}`} x={x} y={y} width={1} height={1} fill="currentColor" />
|
|
44
|
+
))}
|
|
45
|
+
{finderAt(0, 0)}
|
|
46
|
+
{finderAt(SIZE - 7, 0)}
|
|
47
|
+
{finderAt(0, SIZE - 7)}
|
|
48
|
+
</svg>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { Check, Minus } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { checkPassword, passwordStrength, type PasswordPolicy } from '@/core/company/password-policy'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
const barColor = ['bg-danger', 'bg-danger', 'bg-warning', 'bg-success', 'bg-success'] as const
|
|
8
|
+
const levels = ['tooWeak', 'weak', 'fair', 'good', 'strong'] as const
|
|
9
|
+
|
|
10
|
+
/** Four-segment strength bar + the company's password rules, checked live. */
|
|
11
|
+
export function PasswordStrength({ password, policy }: { password: string; policy: PasswordPolicy }) {
|
|
12
|
+
const { t } = useTranslation('account')
|
|
13
|
+
const score = passwordStrength(password)
|
|
14
|
+
const { rules } = checkPassword(policy, password)
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col gap-2.5">
|
|
18
|
+
<div className="flex items-center gap-3">
|
|
19
|
+
<div className="flex flex-1 gap-1" aria-hidden>
|
|
20
|
+
{[1, 2, 3, 4].map((i) => (
|
|
21
|
+
<span key={i} className={cn('h-1 flex-1 rounded-full', password && score >= i ? barColor[score] : 'bg-fill-3')} />
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
<span className="w-20 text-right text-xs text-muted-foreground" aria-live="polite">
|
|
25
|
+
{password ? t(`security.password.strength.${levels[score]}`) : t('security.password.strength.label')}
|
|
26
|
+
</span>
|
|
27
|
+
</div>
|
|
28
|
+
<ul className="grid gap-1 text-xs sm:grid-cols-2">
|
|
29
|
+
{rules.map(({ rule, met }) => (
|
|
30
|
+
<li key={rule} className={cn('flex items-center gap-1.5', met ? 'text-foreground' : 'text-muted-foreground')}>
|
|
31
|
+
{met ? <Check className="size-3.5 text-success" /> : <Minus className="size-3.5" />}
|
|
32
|
+
{t(`security.password.rules.${rule}`, { count: policy.minLength })}
|
|
33
|
+
</li>
|
|
34
|
+
))}
|
|
35
|
+
</ul>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Copy } from 'lucide-react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
8
|
+
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
|
9
|
+
import { Input } from '@/components/ui/input'
|
|
10
|
+
import { Spinner } from '@/components/ui/spinner'
|
|
11
|
+
import { LoadingState } from '@/core/components/states'
|
|
12
|
+
import { useConfirmTwoFactor, useTwoFactorSetup } from '../hooks'
|
|
13
|
+
import { MockQr } from './mock-qr'
|
|
14
|
+
|
|
15
|
+
/** Two steps: scan + verify a code, then show recovery codes once. */
|
|
16
|
+
export function TwoFactorDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
17
|
+
const { t } = useTranslation(['account', 'common'])
|
|
18
|
+
const setup = useTwoFactorSetup()
|
|
19
|
+
const confirm = useConfirmTwoFactor()
|
|
20
|
+
const [code, setCode] = useState('')
|
|
21
|
+
const [invalid, setInvalid] = useState(false)
|
|
22
|
+
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null)
|
|
23
|
+
|
|
24
|
+
const { mutate: start, reset: resetSetup } = setup
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (open) start()
|
|
27
|
+
}, [open, start])
|
|
28
|
+
|
|
29
|
+
function close() {
|
|
30
|
+
onOpenChange(false)
|
|
31
|
+
setCode('')
|
|
32
|
+
setInvalid(false)
|
|
33
|
+
setRecoveryCodes(null)
|
|
34
|
+
resetSetup()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function verify() {
|
|
38
|
+
if (!/^\d{6}$/.test(code)) return setInvalid(true)
|
|
39
|
+
try {
|
|
40
|
+
const result = await confirm.mutateAsync(code)
|
|
41
|
+
setRecoveryCodes(result.recoveryCodes)
|
|
42
|
+
toast.success(t('security.twoFactor.dialog.enabledToast'))
|
|
43
|
+
} catch {
|
|
44
|
+
setInvalid(true)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
|
50
|
+
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
|
51
|
+
<DialogHeader>
|
|
52
|
+
<DialogTitle>
|
|
53
|
+
{recoveryCodes ? t('security.twoFactor.dialog.recoveryTitle') : t('security.twoFactor.dialog.title')}
|
|
54
|
+
</DialogTitle>
|
|
55
|
+
<DialogDescription>
|
|
56
|
+
{recoveryCodes ? t('security.twoFactor.dialog.recoveryDescription') : t('security.twoFactor.dialog.scanStep')}
|
|
57
|
+
</DialogDescription>
|
|
58
|
+
</DialogHeader>
|
|
59
|
+
|
|
60
|
+
{recoveryCodes ? (
|
|
61
|
+
<ul className="grid grid-cols-2 gap-2 rounded-xl border bg-fill-1 p-3 text-sm tabular-nums">
|
|
62
|
+
{recoveryCodes.map((c) => (
|
|
63
|
+
<li key={c}>{c}</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
) : !setup.data ? (
|
|
67
|
+
<LoadingState className="py-10" />
|
|
68
|
+
) : (
|
|
69
|
+
<form
|
|
70
|
+
id="two-factor-form"
|
|
71
|
+
className="flex flex-col gap-4"
|
|
72
|
+
onSubmit={(e) => {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
void verify()
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
|
78
|
+
<div className="rounded-xl border bg-card p-2 text-foreground shadow-(--shadow-card)">
|
|
79
|
+
<MockQr value={setup.data.otpauthUrl} label={t('security.twoFactor.dialog.qrLabel')} className="size-36" />
|
|
80
|
+
</div>
|
|
81
|
+
<div className="min-w-0 text-xs text-muted-foreground">
|
|
82
|
+
<p>{t('security.twoFactor.dialog.manual')}</p>
|
|
83
|
+
<p className="mt-1.5 rounded-lg bg-fill-2 px-2 py-1.5 tabular-nums text-[11px] break-all text-foreground">
|
|
84
|
+
{setup.data.secret}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<Field data-invalid={invalid}>
|
|
89
|
+
<FieldLabel htmlFor="totp">{t('security.twoFactor.dialog.codeLabel')}</FieldLabel>
|
|
90
|
+
<Input
|
|
91
|
+
id="totp"
|
|
92
|
+
inputMode="numeric"
|
|
93
|
+
autoComplete="one-time-code"
|
|
94
|
+
maxLength={6}
|
|
95
|
+
autoFocus
|
|
96
|
+
value={code}
|
|
97
|
+
aria-invalid={invalid}
|
|
98
|
+
className="tabular-nums tracking-[0.3em]"
|
|
99
|
+
onChange={(e) => {
|
|
100
|
+
setCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
101
|
+
setInvalid(false)
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
<FieldDescription>{t('security.twoFactor.dialog.codeHint')}</FieldDescription>
|
|
105
|
+
{invalid && <FieldError>{t('security.twoFactor.dialog.codeInvalid')}</FieldError>}
|
|
106
|
+
</Field>
|
|
107
|
+
</form>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<DialogFooter>
|
|
111
|
+
{recoveryCodes ? (
|
|
112
|
+
<>
|
|
113
|
+
<Button
|
|
114
|
+
variant="outline"
|
|
115
|
+
onClick={() => {
|
|
116
|
+
void navigator.clipboard?.writeText(recoveryCodes.join('\n'))
|
|
117
|
+
toast.success(t('common:actions.copied'))
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<Copy data-icon="inline-start" />
|
|
121
|
+
{t('security.twoFactor.dialog.copyCodes')}
|
|
122
|
+
</Button>
|
|
123
|
+
<Button onClick={close}>{t('security.twoFactor.dialog.done')}</Button>
|
|
124
|
+
</>
|
|
125
|
+
) : (
|
|
126
|
+
<>
|
|
127
|
+
<Button variant="ghost" onClick={close}>
|
|
128
|
+
{t('common:actions.cancel')}
|
|
129
|
+
</Button>
|
|
130
|
+
<Button type="submit" form="two-factor-form" disabled={!setup.data || confirm.isPending}>
|
|
131
|
+
{confirm.isPending && <Spinner data-icon="inline-start" />}
|
|
132
|
+
{t('security.twoFactor.dialog.verify')}
|
|
133
|
+
</Button>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</DialogFooter>
|
|
137
|
+
</DialogContent>
|
|
138
|
+
</Dialog>
|
|
139
|
+
)
|
|
140
|
+
}
|