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,278 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { Controller, useForm, useWatch } from 'react-hook-form'
|
|
3
|
+
import { Trans, useTranslation } from 'react-i18next'
|
|
4
|
+
import { Check, ImageOff, RotateCcw } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|
12
|
+
import { Switch } from '@/components/ui/switch'
|
|
13
|
+
import { useCan } from '@/core/auth/can'
|
|
14
|
+
import { useCompanySettings, useUpdateBranding } from '@/core/company/hooks'
|
|
15
|
+
import type { CompanyBranding } from '@/core/company/types'
|
|
16
|
+
import { FormLayout, FormSection } from '@/core/components/form-page'
|
|
17
|
+
import { ImageUpload } from '@/core/components/image-upload'
|
|
18
|
+
import { Page } from '@/core/components/page'
|
|
19
|
+
import { ErrorState, LoadingState } from '@/core/components/states'
|
|
20
|
+
import { brandPresets, type BrandPresetId, type BrandSetting } from '@/core/theme/brand'
|
|
21
|
+
import { useBrand } from '@/core/theme/brand-context'
|
|
22
|
+
import { cn } from '@/lib/utils'
|
|
23
|
+
import { ReadOnlyNotice } from './components/read-only-notice'
|
|
24
|
+
|
|
25
|
+
const presetIds = Object.keys(brandPresets) as BrandPresetId[]
|
|
26
|
+
const LIGHT_TEXT = 'oklch(1 0 0)' // fp-design-ignore: custom brand color math
|
|
27
|
+
const DARK_TEXT = 'oklch(0.17 0.004 84.6)' // fp-design-ignore: custom brand color math
|
|
28
|
+
|
|
29
|
+
/** White text on dark colors, near-black on light ones (relative luminance of a #rrggbb color). */
|
|
30
|
+
function foregroundFor(hex: string) {
|
|
31
|
+
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
32
|
+
const c = parseInt(hex.slice(i, i + 2), 16) / 255
|
|
33
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
34
|
+
}) as [number, number, number]
|
|
35
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? DARK_TEXT : LIGHT_TEXT
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function BrandingPage() {
|
|
39
|
+
const { t } = useTranslation('company')
|
|
40
|
+
const query = useCompanySettings()
|
|
41
|
+
return (
|
|
42
|
+
<Page title={t('branding.title')} description={t('branding.description')}>
|
|
43
|
+
{query.isPending ? (
|
|
44
|
+
<LoadingState />
|
|
45
|
+
) : query.error ? (
|
|
46
|
+
<ErrorState error={query.error} onRetry={() => query.refetch()} />
|
|
47
|
+
) : (
|
|
48
|
+
<BrandingForm branding={query.data.branding} />
|
|
49
|
+
)}
|
|
50
|
+
</Page>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function BrandingForm({ branding }: { branding: CompanyBranding }) {
|
|
55
|
+
const { t } = useTranslation(['company', 'common'])
|
|
56
|
+
const canEdit = useCan('settings.company.edit')
|
|
57
|
+
const update = useUpdateBranding()
|
|
58
|
+
const { setBrand, previewBrand, defaultBrand } = useBrand()
|
|
59
|
+
const form = useForm<CompanyBranding>({ defaultValues: branding })
|
|
60
|
+
const { control, handleSubmit, formState, reset, setValue } = form
|
|
61
|
+
const brand = useWatch({ control, name: 'brand' })
|
|
62
|
+
|
|
63
|
+
// Live preview: the whole app (sidebar, buttons, focus rings) follows the selection until saved or left.
|
|
64
|
+
useEffect(() => previewBrand(brand), [brand, previewBrand])
|
|
65
|
+
useEffect(() => () => previewBrand(null), [previewBrand])
|
|
66
|
+
|
|
67
|
+
const onSubmit = handleSubmit(async (values) => {
|
|
68
|
+
const saved = await update.mutateAsync(values)
|
|
69
|
+
setBrand(saved.branding.brand)
|
|
70
|
+
reset(saved.branding)
|
|
71
|
+
toast.success(t('branding.saved'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const onImageError = (err: 'tooLarge' | 'invalidType') => toast.error(t(`branding.imageError.${err}`))
|
|
75
|
+
const brandId = typeof brand === 'string' ? brand : 'custom'
|
|
76
|
+
const customHex = typeof brand === 'object' && brand.color.startsWith('#') ? brand.color : '#4f46e5' // fp-design-ignore: native color input default
|
|
77
|
+
const isDefault = JSON.stringify(brand) === JSON.stringify(defaultBrand)
|
|
78
|
+
const defaultName = typeof defaultBrand === 'string' ? t(`branding.presets.${defaultBrand}`) : t('branding.color.custom')
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{!canEdit && <ReadOnlyNotice />}
|
|
83
|
+
<FormLayout
|
|
84
|
+
onSubmit={onSubmit}
|
|
85
|
+
saveBar="dirty"
|
|
86
|
+
isDirty={formState.isDirty}
|
|
87
|
+
isSubmitting={formState.isSubmitting}
|
|
88
|
+
onCancel={() => reset()}
|
|
89
|
+
readOnly={!canEdit}
|
|
90
|
+
>
|
|
91
|
+
<FormSection title={t('branding.logos.title')} description={t('branding.logos.description')}>
|
|
92
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
93
|
+
{(['logoLight', 'logoDark'] as const).map((name) => (
|
|
94
|
+
<div key={name} className="flex flex-col gap-2">
|
|
95
|
+
<span className="text-xs font-medium">{t(name === 'logoLight' ? 'branding.logos.light' : 'branding.logos.dark')}</span>
|
|
96
|
+
<Controller
|
|
97
|
+
control={control}
|
|
98
|
+
name={name}
|
|
99
|
+
render={({ field }) => (
|
|
100
|
+
<ImageUpload
|
|
101
|
+
value={field.value}
|
|
102
|
+
onChange={field.onChange}
|
|
103
|
+
onError={onImageError}
|
|
104
|
+
disabled={!canEdit}
|
|
105
|
+
label={t(name === 'logoLight' ? 'branding.logos.uploadLight' : 'branding.logos.uploadDark')}
|
|
106
|
+
className="flex-col items-start"
|
|
107
|
+
preview={(src) => (
|
|
108
|
+
// `.light` / `.dark` scope the tile to that theme regardless of the app theme.
|
|
109
|
+
<div
|
|
110
|
+
className={cn(
|
|
111
|
+
name === 'logoLight' ? 'light' : 'dark',
|
|
112
|
+
'flex h-20 w-full items-center justify-center rounded-xl border bg-background p-3 text-muted-foreground',
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{src ? (
|
|
116
|
+
<img src={src} alt="" className="max-h-full max-w-full object-contain" />
|
|
117
|
+
) : (
|
|
118
|
+
<span className="flex items-center gap-1.5 text-xs">
|
|
119
|
+
<ImageOff className="size-4" />
|
|
120
|
+
{t('branding.logos.empty')}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</FormSection>
|
|
132
|
+
|
|
133
|
+
<FormSection title={t('branding.favicon.title')} description={t('branding.favicon.description')}>
|
|
134
|
+
<Controller
|
|
135
|
+
control={control}
|
|
136
|
+
name="favicon"
|
|
137
|
+
render={({ field }) => (
|
|
138
|
+
<ImageUpload
|
|
139
|
+
value={field.value}
|
|
140
|
+
onChange={field.onChange}
|
|
141
|
+
onError={onImageError}
|
|
142
|
+
disabled={!canEdit}
|
|
143
|
+
label={t('branding.favicon.upload')}
|
|
144
|
+
preview={(src) => (
|
|
145
|
+
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl border bg-fill-1">
|
|
146
|
+
<img src={src ?? `${import.meta.env.BASE_URL}favicon.svg`} alt="" className="size-6 object-contain" />
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
/>
|
|
152
|
+
</FormSection>
|
|
153
|
+
|
|
154
|
+
<FormSection title={t('branding.color.title')} description={t('branding.color.description')}>
|
|
155
|
+
<RadioGroup
|
|
156
|
+
value={brandId}
|
|
157
|
+
onValueChange={(v) =>
|
|
158
|
+
setValue(
|
|
159
|
+
'brand',
|
|
160
|
+
v === 'custom' ? { color: customHex, foreground: foregroundFor(customHex) } : (v as BrandPresetId),
|
|
161
|
+
{ shouldDirty: true },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
className="flex flex-wrap gap-3"
|
|
165
|
+
aria-label={t('branding.color.title')}
|
|
166
|
+
>
|
|
167
|
+
{presetIds.map((id) => (
|
|
168
|
+
<label key={id} className="flex w-14 flex-col items-center gap-1.5" title={t(`branding.presets.${id}`)}>
|
|
169
|
+
<RadioGroupItem
|
|
170
|
+
value={id}
|
|
171
|
+
aria-label={t(`branding.presets.${id}`)}
|
|
172
|
+
style={{ background: brandPresets[id].color }}
|
|
173
|
+
className="size-8 ring-offset-2 ring-offset-card data-checked:ring-2 data-checked:ring-(--brand) *:[span]:hidden"
|
|
174
|
+
/>
|
|
175
|
+
<span className={cn('text-2xs', brandId === id ? 'text-foreground' : 'text-muted-foreground')}>
|
|
176
|
+
{t(`branding.presets.${id}`)}
|
|
177
|
+
</span>
|
|
178
|
+
</label>
|
|
179
|
+
))}
|
|
180
|
+
<label className="flex w-14 flex-col items-center gap-1.5">
|
|
181
|
+
<RadioGroupItem
|
|
182
|
+
value="custom"
|
|
183
|
+
aria-label={t('branding.color.custom')}
|
|
184
|
+
style={{ background: brandId === 'custom' ? customHex : undefined }}
|
|
185
|
+
className="size-8 bg-fill-3 ring-offset-2 ring-offset-card data-checked:ring-2 data-checked:ring-(--brand) *:[span]:hidden"
|
|
186
|
+
/>
|
|
187
|
+
<span className={cn('text-2xs', brandId === 'custom' ? 'text-foreground' : 'text-muted-foreground')}>
|
|
188
|
+
{t('branding.color.custom')}
|
|
189
|
+
</span>
|
|
190
|
+
</label>
|
|
191
|
+
</RadioGroup>
|
|
192
|
+
|
|
193
|
+
{brandId === 'custom' && (
|
|
194
|
+
<div className="flex items-center gap-2">
|
|
195
|
+
<input
|
|
196
|
+
type="color"
|
|
197
|
+
value={customHex}
|
|
198
|
+
aria-label={t('branding.color.customLabel')}
|
|
199
|
+
onChange={(e) =>
|
|
200
|
+
setValue('brand', { color: e.target.value, foreground: foregroundFor(e.target.value) }, { shouldDirty: true })
|
|
201
|
+
}
|
|
202
|
+
className="size-8 cursor-pointer rounded-lg border bg-transparent p-0.5"
|
|
203
|
+
/>
|
|
204
|
+
<Input value={customHex.toUpperCase()} readOnly aria-label={t('branding.color.customLabel')} className="w-28 tabular-nums" />
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
<BrandPreview />
|
|
209
|
+
|
|
210
|
+
<div>
|
|
211
|
+
<Button
|
|
212
|
+
type="button"
|
|
213
|
+
variant="ghost"
|
|
214
|
+
size="sm"
|
|
215
|
+
disabled={isDefault}
|
|
216
|
+
onClick={() => setValue('brand', defaultBrand as BrandSetting, { shouldDirty: true })}
|
|
217
|
+
>
|
|
218
|
+
<RotateCcw data-icon="inline-start" />
|
|
219
|
+
{t('branding.color.reset', { name: defaultName })}
|
|
220
|
+
</Button>
|
|
221
|
+
</div>
|
|
222
|
+
</FormSection>
|
|
223
|
+
</FormLayout>
|
|
224
|
+
</>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Live sample of every place the brand token shows up. */
|
|
229
|
+
function BrandPreview() {
|
|
230
|
+
const { t } = useTranslation('company')
|
|
231
|
+
return (
|
|
232
|
+
<div className="grid gap-4 rounded-xl border bg-canvas p-4 sm:grid-cols-2" aria-label={t('branding.preview.title')}>
|
|
233
|
+
<div className="flex flex-col gap-3">
|
|
234
|
+
<span className="label-caps">{t('branding.preview.controls')}</span>
|
|
235
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
236
|
+
<Button type="button" tabIndex={-1}>
|
|
237
|
+
{t('branding.preview.primary')}
|
|
238
|
+
</Button>
|
|
239
|
+
<Button type="button" variant="outline" tabIndex={-1}>
|
|
240
|
+
{t('branding.preview.secondary')}
|
|
241
|
+
</Button>
|
|
242
|
+
<Badge>{t('branding.preview.new')}</Badge>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="flex items-center gap-4 text-sm">
|
|
245
|
+
<span className="flex items-center gap-2">
|
|
246
|
+
<Switch defaultChecked tabIndex={-1} aria-label={t('branding.preview.enabled')} /> {t('branding.preview.enabled')}
|
|
247
|
+
</span>
|
|
248
|
+
<span className="flex items-center gap-2">
|
|
249
|
+
<Checkbox defaultChecked tabIndex={-1} aria-label={t('branding.preview.selected')} /> {t('branding.preview.selected')}
|
|
250
|
+
</span>
|
|
251
|
+
</div>
|
|
252
|
+
<Input
|
|
253
|
+
placeholder={t('branding.preview.focusRing')}
|
|
254
|
+
aria-label={t('branding.preview.focusRing')}
|
|
255
|
+
className="max-w-xs bg-background"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="flex flex-col gap-3">
|
|
259
|
+
<span className="label-caps">{t('branding.preview.navigation')}</span>
|
|
260
|
+
<div className="flex flex-col gap-0.5 text-[13px]">
|
|
261
|
+
<div className="flex h-8 items-center gap-2 rounded-lg bg-raised px-2 font-medium shadow-(--shadow-raised) ring-1 ring-border">
|
|
262
|
+
<Check className="size-4 text-primary" /> {t('branding.preview.activeItem')}
|
|
263
|
+
</div>
|
|
264
|
+
<div className="flex h-8 items-center gap-2 rounded-lg px-2 text-muted-foreground">
|
|
265
|
+
<Check className="size-4" /> {t('branding.preview.inactiveItem')}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
<p className="text-sm">
|
|
269
|
+
<Trans
|
|
270
|
+
t={t}
|
|
271
|
+
i18nKey="branding.preview.link"
|
|
272
|
+
components={{ a: <span className="font-medium text-primary underline underline-offset-4" /> }}
|
|
273
|
+
/>
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
2
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
9
|
+
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
12
|
+
import { Spinner } from '@/components/ui/spinner'
|
|
13
|
+
import { ApiError } from '@/core/api/http'
|
|
14
|
+
import { useCompanySettings } from '@/core/company/hooks'
|
|
15
|
+
import { FormFieldError } from '@/core/components/form-error'
|
|
16
|
+
import { vmsg } from '@/core/i18n/keys'
|
|
17
|
+
import { useInviteUser } from '@/core/users/hooks'
|
|
18
|
+
import { assignableRoles } from '@/core/users/types'
|
|
19
|
+
import { userErrors } from '@/core/users/users.service'
|
|
20
|
+
|
|
21
|
+
const schema = z.object({
|
|
22
|
+
email: z.string().trim().min(1, vmsg('validation.required')).pipe(z.email(vmsg('validation.email'))),
|
|
23
|
+
role: z.enum(assignableRoles),
|
|
24
|
+
})
|
|
25
|
+
type Values = z.infer<typeof schema>
|
|
26
|
+
|
|
27
|
+
export function InviteDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
28
|
+
const { t } = useTranslation(['company', 'common'])
|
|
29
|
+
const invite = useInviteUser()
|
|
30
|
+
const companyName = useCompanySettings().data?.general.name ?? ''
|
|
31
|
+
const { register, control, handleSubmit, formState, reset, setError } = useForm<Values>({
|
|
32
|
+
resolver: zodResolver(schema),
|
|
33
|
+
defaultValues: { email: '', role: 'editor' },
|
|
34
|
+
})
|
|
35
|
+
const { errors } = formState
|
|
36
|
+
const roleItems = assignableRoles.map((r) => ({ value: r, label: t(`common:roles.${r as 'owner'}`) }))
|
|
37
|
+
|
|
38
|
+
function close() {
|
|
39
|
+
onOpenChange(false)
|
|
40
|
+
reset()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const onSubmit = handleSubmit(async (values) => {
|
|
44
|
+
try {
|
|
45
|
+
await invite.mutateAsync(values)
|
|
46
|
+
toast.success(t('users.toasts.invited', { email: values.email }))
|
|
47
|
+
close()
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e instanceof ApiError && e.message === userErrors.duplicateEmail) {
|
|
50
|
+
setError('email', { message: vmsg('company:users.inviteDialog.duplicate') })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
toast.error(t('users.toasts.failed'))
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
|
59
|
+
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
|
60
|
+
<DialogHeader>
|
|
61
|
+
<DialogTitle>{t('users.inviteDialog.title')}</DialogTitle>
|
|
62
|
+
<DialogDescription>{t('users.inviteDialog.description', { company: companyName })}</DialogDescription>
|
|
63
|
+
</DialogHeader>
|
|
64
|
+
<form id="invite-form" onSubmit={onSubmit} noValidate>
|
|
65
|
+
<FieldGroup className="gap-4">
|
|
66
|
+
<Field data-invalid={!!errors.email}>
|
|
67
|
+
<FieldLabel htmlFor="invite-email">{t('users.inviteDialog.email')}</FieldLabel>
|
|
68
|
+
<Input id="invite-email" type="email" autoFocus autoComplete="off" aria-invalid={!!errors.email} {...register('email')} />
|
|
69
|
+
<FormFieldError error={errors.email} />
|
|
70
|
+
</Field>
|
|
71
|
+
<Field>
|
|
72
|
+
<FieldLabel htmlFor="invite-role">{t('users.inviteDialog.role')}</FieldLabel>
|
|
73
|
+
<Controller
|
|
74
|
+
control={control}
|
|
75
|
+
name="role"
|
|
76
|
+
render={({ field }) => (
|
|
77
|
+
<Select items={roleItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
|
|
78
|
+
<SelectTrigger id="invite-role" className="w-full">
|
|
79
|
+
<SelectValue />
|
|
80
|
+
</SelectTrigger>
|
|
81
|
+
<SelectContent>
|
|
82
|
+
{roleItems.map((r) => (
|
|
83
|
+
<SelectItem key={r.value} value={r.value}>
|
|
84
|
+
{r.label}
|
|
85
|
+
</SelectItem>
|
|
86
|
+
))}
|
|
87
|
+
</SelectContent>
|
|
88
|
+
</Select>
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
91
|
+
</Field>
|
|
92
|
+
</FieldGroup>
|
|
93
|
+
</form>
|
|
94
|
+
<DialogFooter>
|
|
95
|
+
<Button variant="ghost" onClick={close}>
|
|
96
|
+
{t('common:actions.cancel')}
|
|
97
|
+
</Button>
|
|
98
|
+
<Button type="submit" form="invite-form" disabled={formState.isSubmitting}>
|
|
99
|
+
{formState.isSubmitting && <Spinner data-icon="inline-start" />}
|
|
100
|
+
{t('users.inviteDialog.submit')}
|
|
101
|
+
</Button>
|
|
102
|
+
</DialogFooter>
|
|
103
|
+
</DialogContent>
|
|
104
|
+
</Dialog>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { Lock } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
/** Shown above company settings the current role can view but not change. */
|
|
5
|
+
export function ReadOnlyNotice() {
|
|
6
|
+
const { t } = useTranslation('company')
|
|
7
|
+
return (
|
|
8
|
+
<p className="flex items-center gap-2 rounded-xl border bg-fill-1 px-4 py-3 text-xs text-muted-foreground">
|
|
9
|
+
<Lock className="size-3.5 shrink-0" />
|
|
10
|
+
{t('readOnly')}
|
|
11
|
+
</p>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { useNavigate } from 'react-router'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Field, FieldLabel } from '@/components/ui/field'
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
9
|
+
import { useSession } from '@/core/auth/use-auth'
|
|
10
|
+
import { useCompanySettings, useDeleteCompany } from '@/core/company/hooks'
|
|
11
|
+
import { useConfirm } from '@/core/components/confirm-dialog'
|
|
12
|
+
import { Page } from '@/core/components/page'
|
|
13
|
+
import { useActiveUsers, useTransferOwnership } from '@/core/users/hooks'
|
|
14
|
+
import { fullName } from '@/core/users/types'
|
|
15
|
+
|
|
16
|
+
/** Owner only (route permission `company.delete`). Each action needs a typed confirmation. */
|
|
17
|
+
export default function DangerZonePage() {
|
|
18
|
+
const { t } = useTranslation('company')
|
|
19
|
+
return (
|
|
20
|
+
<Page title={t('danger.title')} description={t('danger.description')}>
|
|
21
|
+
<TransferOwnership />
|
|
22
|
+
<DeleteCompany />
|
|
23
|
+
</Page>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function DangerCard({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
|
28
|
+
return (
|
|
29
|
+
<section className="flex flex-col gap-4 rounded-xl border bg-card p-5 shadow-(--shadow-card) md:p-6">
|
|
30
|
+
<div>
|
|
31
|
+
<h2 className="text-sm font-medium">{title}</h2>
|
|
32
|
+
<p className="mt-1 max-w-prose text-xs text-muted-foreground">{description}</p>
|
|
33
|
+
</div>
|
|
34
|
+
{children}
|
|
35
|
+
</section>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TransferOwnership() {
|
|
40
|
+
const { t } = useTranslation(['company', 'common'])
|
|
41
|
+
const me = useSession()?.user
|
|
42
|
+
const users = useActiveUsers()
|
|
43
|
+
const company = useCompanySettings().data?.general.name ?? ''
|
|
44
|
+
const transfer = useTransferOwnership()
|
|
45
|
+
const confirm = useConfirm()
|
|
46
|
+
const navigate = useNavigate()
|
|
47
|
+
const [target, setTarget] = useState<string | null>(null)
|
|
48
|
+
|
|
49
|
+
// Admins first, then the rest alphabetically.
|
|
50
|
+
const candidates = (users.data ?? [])
|
|
51
|
+
.filter((u) => u.id !== me?.id && fullName(u))
|
|
52
|
+
.sort((a, b) => Number(b.role === 'admin') - Number(a.role === 'admin') || fullName(a).localeCompare(fullName(b)))
|
|
53
|
+
const items = candidates.map((u) => ({ value: u.id, label: `${fullName(u)} · ${t(`common:roles.${u.role as 'owner'}`)}` }))
|
|
54
|
+
const selected = candidates.find((u) => u.id === target)
|
|
55
|
+
|
|
56
|
+
async function onTransfer() {
|
|
57
|
+
if (!selected) return
|
|
58
|
+
const name = fullName(selected)
|
|
59
|
+
const ok = await confirm({
|
|
60
|
+
title: t('danger.transfer.confirmTitle', { name }),
|
|
61
|
+
description: t('danger.transfer.confirmDescription', { name, company }),
|
|
62
|
+
confirmLabel: t('danger.transfer.button'),
|
|
63
|
+
destructive: true,
|
|
64
|
+
typeToConfirm: company,
|
|
65
|
+
})
|
|
66
|
+
if (!ok) return
|
|
67
|
+
await transfer.mutateAsync(selected.id)
|
|
68
|
+
toast.success(t('danger.transfer.done', { name }))
|
|
69
|
+
navigate('/app/settings/company/users', { replace: true })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<DangerCard title={t('danger.transfer.title')} description={t('danger.transfer.description')}>
|
|
74
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
75
|
+
<Field className="sm:max-w-xs">
|
|
76
|
+
<FieldLabel htmlFor="new-owner">{t('danger.transfer.select')}</FieldLabel>
|
|
77
|
+
<Select items={items} value={target} onValueChange={(v) => setTarget(v as string | null)}>
|
|
78
|
+
<SelectTrigger id="new-owner" className="w-full" disabled={items.length === 0}>
|
|
79
|
+
<SelectValue placeholder={t('danger.transfer.placeholder')} />
|
|
80
|
+
</SelectTrigger>
|
|
81
|
+
<SelectContent>
|
|
82
|
+
{items.map((i) => (
|
|
83
|
+
<SelectItem key={i.value} value={i.value}>
|
|
84
|
+
{i.label}
|
|
85
|
+
</SelectItem>
|
|
86
|
+
))}
|
|
87
|
+
</SelectContent>
|
|
88
|
+
</Select>
|
|
89
|
+
</Field>
|
|
90
|
+
<Button variant="destructive" disabled={!selected || transfer.isPending} onClick={() => void onTransfer()}>
|
|
91
|
+
{t('danger.transfer.button')}
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
{users.data && items.length === 0 && <p className="text-xs text-muted-foreground">{t('danger.transfer.noCandidates')}</p>}
|
|
95
|
+
</DangerCard>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function DeleteCompany() {
|
|
100
|
+
const { t } = useTranslation(['company', 'common'])
|
|
101
|
+
const company = useCompanySettings().data?.general.name ?? ''
|
|
102
|
+
const confirm = useConfirm()
|
|
103
|
+
const remove = useDeleteCompany()
|
|
104
|
+
|
|
105
|
+
async function onDelete() {
|
|
106
|
+
const ok = await confirm({
|
|
107
|
+
title: t('danger.delete.confirmTitle', { company }),
|
|
108
|
+
description: t('danger.delete.confirmDescription'),
|
|
109
|
+
confirmLabel: t('danger.delete.button'),
|
|
110
|
+
destructive: true,
|
|
111
|
+
typeToConfirm: company,
|
|
112
|
+
})
|
|
113
|
+
if (!ok) return
|
|
114
|
+
toast.success(t('danger.delete.done'))
|
|
115
|
+
await remove.mutateAsync(company)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<DangerCard title={t('danger.delete.title')} description={t('danger.delete.description', { company })}>
|
|
120
|
+
<div>
|
|
121
|
+
<Button variant="destructive" disabled={!company || remove.isPending} onClick={() => void onDelete()}>
|
|
122
|
+
{t('danger.delete.button')}
|
|
123
|
+
</Button>
|
|
124
|
+
</div>
|
|
125
|
+
</DangerCard>
|
|
126
|
+
)
|
|
127
|
+
}
|