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.
Files changed (216) hide show
  1. package/README.md +24 -3
  2. package/bin/create-freakpunch.mjs +227 -0
  3. package/files/README.md +19 -0
  4. package/files/app.config.ts +50 -0
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -6
  8. package/template/.dockerignore +17 -0
  9. package/template/.oxlintrc.json +9 -0
  10. package/template/AGENTS.md +6 -0
  11. package/template/BACKEND.md +108 -0
  12. package/template/CLAUDE.md +297 -0
  13. package/template/Dockerfile +19 -0
  14. package/template/_gitignore +26 -0
  15. package/template/components.json +27 -0
  16. package/template/design/COMPONENTS.md +145 -0
  17. package/template/design/DESIGN.md +212 -0
  18. package/template/design/PATTERNS.md +110 -0
  19. package/template/index.html +25 -0
  20. package/template/nginx.conf +32 -0
  21. package/template/package-lock.json +6484 -0
  22. package/template/package.json +51 -0
  23. package/template/public/favicon.svg +1 -0
  24. package/template/public/robots.txt +2 -0
  25. package/template/src/app.config.ts +50 -0
  26. package/template/src/app.roles.ts +24 -0
  27. package/template/src/components/ui/alert-dialog.tsx +181 -0
  28. package/template/src/components/ui/avatar.tsx +116 -0
  29. package/template/src/components/ui/badge.tsx +57 -0
  30. package/template/src/components/ui/breadcrumb.tsx +130 -0
  31. package/template/src/components/ui/button.tsx +58 -0
  32. package/template/src/components/ui/calendar.tsx +277 -0
  33. package/template/src/components/ui/card.tsx +99 -0
  34. package/template/src/components/ui/chart.tsx +370 -0
  35. package/template/src/components/ui/checkbox.tsx +28 -0
  36. package/template/src/components/ui/combobox.tsx +329 -0
  37. package/template/src/components/ui/command.tsx +191 -0
  38. package/template/src/components/ui/dialog.tsx +162 -0
  39. package/template/src/components/ui/dropdown-menu.tsx +271 -0
  40. package/template/src/components/ui/empty.tsx +103 -0
  41. package/template/src/components/ui/field.tsx +236 -0
  42. package/template/src/components/ui/input-group.tsx +263 -0
  43. package/template/src/components/ui/input.tsx +45 -0
  44. package/template/src/components/ui/kbd.tsx +31 -0
  45. package/template/src/components/ui/label.tsx +17 -0
  46. package/template/src/components/ui/pagination.tsx +134 -0
  47. package/template/src/components/ui/popover.tsx +87 -0
  48. package/template/src/components/ui/radio-group.tsx +35 -0
  49. package/template/src/components/ui/select.tsx +204 -0
  50. package/template/src/components/ui/separator.tsx +25 -0
  51. package/template/src/components/ui/sheet.tsx +139 -0
  52. package/template/src/components/ui/sidebar.tsx +741 -0
  53. package/template/src/components/ui/skeleton.tsx +13 -0
  54. package/template/src/components/ui/sonner.tsx +78 -0
  55. package/template/src/components/ui/spinner.tsx +42 -0
  56. package/template/src/components/ui/switch.tsx +29 -0
  57. package/template/src/components/ui/table.tsx +121 -0
  58. package/template/src/components/ui/tabs.tsx +191 -0
  59. package/template/src/components/ui/textarea.tsx +17 -0
  60. package/template/src/components/ui/toggle-group.tsx +94 -0
  61. package/template/src/components/ui/toggle.tsx +48 -0
  62. package/template/src/components/ui/tooltip.tsx +91 -0
  63. package/template/src/core/api/demo.ts +8 -0
  64. package/template/src/core/api/http.ts +76 -0
  65. package/template/src/core/api/mock.ts +88 -0
  66. package/template/src/core/api/rest.ts +67 -0
  67. package/template/src/core/api/types.ts +21 -0
  68. package/template/src/core/audit/audit.service.ts +94 -0
  69. package/template/src/core/audit/hooks.ts +19 -0
  70. package/template/src/core/auth/auth-store.ts +52 -0
  71. package/template/src/core/auth/auth.service.ts +86 -0
  72. package/template/src/core/auth/can.tsx +39 -0
  73. package/template/src/core/auth/dev-role-switcher.tsx +64 -0
  74. package/template/src/core/auth/local-credentials.ts +46 -0
  75. package/template/src/core/auth/permissions.ts +115 -0
  76. package/template/src/core/auth/require-auth.tsx +16 -0
  77. package/template/src/core/auth/require-permission.tsx +24 -0
  78. package/template/src/core/auth/roles.ts +9 -0
  79. package/template/src/core/auth/types.ts +20 -0
  80. package/template/src/core/auth/use-auth.ts +19 -0
  81. package/template/src/core/company/company.service.ts +94 -0
  82. package/template/src/core/company/hooks.ts +40 -0
  83. package/template/src/core/company/password-policy.ts +40 -0
  84. package/template/src/core/company/types.ts +46 -0
  85. package/template/src/core/company/use-app-name.ts +7 -0
  86. package/template/src/core/components/button-link.tsx +14 -0
  87. package/template/src/core/components/confirm-dialog.tsx +116 -0
  88. package/template/src/core/components/data-table/data-table.tsx +297 -0
  89. package/template/src/core/components/data-table/features.ts +24 -0
  90. package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
  91. package/template/src/core/components/data-table/toolbar.tsx +100 -0
  92. package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
  93. package/template/src/core/components/date-picker.tsx +71 -0
  94. package/template/src/core/components/file-attachments.tsx +84 -0
  95. package/template/src/core/components/form-error.tsx +9 -0
  96. package/template/src/core/components/form-page.tsx +93 -0
  97. package/template/src/core/components/image-upload.tsx +86 -0
  98. package/template/src/core/components/page.tsx +75 -0
  99. package/template/src/core/components/phone-input.tsx +58 -0
  100. package/template/src/core/components/search-select.tsx +57 -0
  101. package/template/src/core/components/segmented.tsx +41 -0
  102. package/template/src/core/components/states.tsx +97 -0
  103. package/template/src/core/components/status-badge.tsx +26 -0
  104. package/template/src/core/components/user-avatar.tsx +23 -0
  105. package/template/src/core/config/runtime.ts +16 -0
  106. package/template/src/core/config/types.ts +37 -0
  107. package/template/src/core/i18n/i18n.ts +69 -0
  108. package/template/src/core/i18n/i18next.d.ts +12 -0
  109. package/template/src/core/i18n/keys.ts +37 -0
  110. package/template/src/core/i18n/language-switcher.tsx +45 -0
  111. package/template/src/core/i18n/types.ts +17 -0
  112. package/template/src/core/i18n/use-format.ts +31 -0
  113. package/template/src/core/layout/app-shell.tsx +50 -0
  114. package/template/src/core/layout/app-sidebar.tsx +285 -0
  115. package/template/src/core/layout/command-menu.tsx +117 -0
  116. package/template/src/core/layout/top-bar.tsx +100 -0
  117. package/template/src/core/lib/csv.ts +27 -0
  118. package/template/src/core/lib/format.ts +87 -0
  119. package/template/src/core/lib/phone.ts +50 -0
  120. package/template/src/core/lib/storage.ts +27 -0
  121. package/template/src/core/modules/lazy-page.ts +9 -0
  122. package/template/src/core/modules/navigation.ts +39 -0
  123. package/template/src/core/modules/types.ts +55 -0
  124. package/template/src/core/notifications/hooks.ts +84 -0
  125. package/template/src/core/notifications/notification-center.tsx +251 -0
  126. package/template/src/core/notifications/notifications.service.ts +132 -0
  127. package/template/src/core/notifications/types.ts +70 -0
  128. package/template/src/core/preferences/preferences-store.ts +70 -0
  129. package/template/src/core/setup/setup-gate.tsx +20 -0
  130. package/template/src/core/setup/setup.service.ts +101 -0
  131. package/template/src/core/theme/brand-context.tsx +53 -0
  132. package/template/src/core/theme/brand.ts +29 -0
  133. package/template/src/core/theme/theme-context.tsx +62 -0
  134. package/template/src/core/theme/theme-options.ts +8 -0
  135. package/template/src/core/theme/theme-toggle.tsx +45 -0
  136. package/template/src/core/users/hooks.ts +51 -0
  137. package/template/src/core/users/types.ts +37 -0
  138. package/template/src/core/users/users.service.ts +167 -0
  139. package/template/src/hooks/use-mobile.ts +19 -0
  140. package/template/src/index.css +321 -0
  141. package/template/src/lib/utils.ts +1 -0
  142. package/template/src/locales/en/account.ts +180 -0
  143. package/template/src/locales/en/auth.ts +18 -0
  144. package/template/src/locales/en/common.ts +151 -0
  145. package/template/src/locales/en/company.ts +380 -0
  146. package/template/src/locales/en/dashboard.ts +46 -0
  147. package/template/src/locales/en/errors.ts +18 -0
  148. package/template/src/locales/en/notifications.ts +43 -0
  149. package/template/src/locales/en/settings.ts +25 -0
  150. package/template/src/locales/en/setup.ts +45 -0
  151. package/template/src/locales/index.ts +58 -0
  152. package/template/src/locales/tr/account.ts +184 -0
  153. package/template/src/locales/tr/auth.ts +22 -0
  154. package/template/src/locales/tr/common.ts +154 -0
  155. package/template/src/locales/tr/company.ts +384 -0
  156. package/template/src/locales/tr/dashboard.ts +50 -0
  157. package/template/src/locales/tr/errors.ts +22 -0
  158. package/template/src/locales/tr/notifications.ts +47 -0
  159. package/template/src/locales/tr/settings.ts +29 -0
  160. package/template/src/locales/tr/setup.ts +49 -0
  161. package/template/src/main.tsx +16 -0
  162. package/template/src/modules/locales.ts +10 -0
  163. package/template/src/modules/registry.ts +17 -0
  164. package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
  165. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  166. package/template/src/pages/login-page.tsx +152 -0
  167. package/template/src/pages/no-access-page.tsx +11 -0
  168. package/template/src/pages/not-found-page.tsx +61 -0
  169. package/template/src/pages/settings/account/account.service.ts +128 -0
  170. package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
  171. package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
  172. package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
  173. package/template/src/pages/settings/account/hooks.ts +73 -0
  174. package/template/src/pages/settings/account/notifications-page.tsx +103 -0
  175. package/template/src/pages/settings/account/preferences-page.tsx +157 -0
  176. package/template/src/pages/settings/account/profile-page.tsx +242 -0
  177. package/template/src/pages/settings/account/security-page.tsx +364 -0
  178. package/template/src/pages/settings/account/types.ts +37 -0
  179. package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
  180. package/template/src/pages/settings/company/branding-page.tsx +278 -0
  181. package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
  182. package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
  183. package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
  184. package/template/src/pages/settings/company/general-page.tsx +234 -0
  185. package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
  186. package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
  187. package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
  188. package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
  189. package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
  190. package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
  191. package/template/src/pages/settings/company/modules-page.tsx +65 -0
  192. package/template/src/pages/settings/company/roles-page.tsx +116 -0
  193. package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
  194. package/template/src/pages/settings/company/users-page.tsx +262 -0
  195. package/template/src/pages/settings/settings-layout.tsx +57 -0
  196. package/template/src/pages/settings/settings-nav.ts +69 -0
  197. package/template/src/pages/settings/settings-routes.tsx +49 -0
  198. package/template/src/pages/setup-page.tsx +387 -0
  199. package/template/src/providers.tsx +68 -0
  200. package/template/src/router.tsx +59 -0
  201. package/template/tools/check-design.mjs +73 -0
  202. package/template/tools/new-module.mjs +90 -0
  203. package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
  204. package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
  205. package/template/tools/templates/module/hooks.ts.tpl +57 -0
  206. package/template/tools/templates/module/index.ts.tpl +44 -0
  207. package/template/tools/templates/module/locales/en.ts.tpl +51 -0
  208. package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
  209. package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
  210. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
  211. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
  212. package/template/tools/templates/module/types.ts.tpl +32 -0
  213. package/template/tsconfig.app.json +32 -0
  214. package/template/tsconfig.json +12 -0
  215. package/template/tsconfig.node.json +23 -0
  216. package/template/vite.config.ts +47 -0
@@ -0,0 +1,364 @@
1
+ import { useMemo, useState, type ReactNode } from 'react'
2
+ import { useForm, useWatch } from 'react-hook-form'
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { Eye, EyeOff, Monitor, Smartphone } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import { z } from 'zod'
8
+
9
+ import { Badge } from '@/components/ui/badge'
10
+ import { Button } from '@/components/ui/button'
11
+ import { Field, FieldLabel } from '@/components/ui/field'
12
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
13
+ import { Skeleton } from '@/components/ui/skeleton'
14
+ import { Spinner } from '@/components/ui/spinner'
15
+ import { Table, TableBody, TableCell, TableHeader, TableRow } from '@/components/ui/table'
16
+ import { TableHeaderCell } from '@/core/components/data-table/table-header-cell'
17
+ import { ApiError } from '@/core/api/http'
18
+ import { useSecurityPolicy } from '@/core/company/hooks'
19
+ import { checkPassword, type PasswordPolicy } from '@/core/company/password-policy'
20
+ import { useConfirm } from '@/core/components/confirm-dialog'
21
+ import { FormFieldError } from '@/core/components/form-error'
22
+ import { FormSection } from '@/core/components/form-page'
23
+ import { Page, Panel } from '@/core/components/page'
24
+ import { ErrorState } from '@/core/components/states'
25
+ import { StatusBadge } from '@/core/components/status-badge'
26
+ import { vmsg } from '@/core/i18n/keys'
27
+ import { useFormat } from '@/core/i18n/use-format'
28
+ import { accountErrors } from './account.service'
29
+ import { PasswordStrength } from './components/password-strength'
30
+ import { TwoFactorDialog } from './components/two-factor-dialog'
31
+ import {
32
+ useChangePassword,
33
+ useDisableTwoFactor,
34
+ useLoginHistory,
35
+ useRevokeSession,
36
+ useSecurityOverview,
37
+ useSessions,
38
+ } from './hooks'
39
+
40
+ export default function SecurityPage() {
41
+ const { t } = useTranslation('account')
42
+ return (
43
+ <Page title={t('security.title')} description={t('security.description')}>
44
+ <PasswordCard />
45
+ <TwoFactorCard />
46
+ <SessionsCard />
47
+ <HistoryCard />
48
+ </Page>
49
+ )
50
+ }
51
+
52
+ function createPasswordSchema(policy: PasswordPolicy) {
53
+ return z
54
+ .object({
55
+ current: z.string().min(1, vmsg('validation.required')),
56
+ next: z.string().min(1, vmsg('validation.required')),
57
+ confirm: z.string().min(1, vmsg('validation.required')),
58
+ })
59
+ .superRefine((v, ctx) => {
60
+ if (v.next && !checkPassword(policy, v.next).valid) {
61
+ ctx.addIssue({ code: 'custom', path: ['next'], message: vmsg('account:security.password.notMet') })
62
+ } else if (v.next && v.next === v.current) {
63
+ ctx.addIssue({ code: 'custom', path: ['next'], message: vmsg('account:security.password.sameAsCurrent') })
64
+ }
65
+ if (v.confirm && v.confirm !== v.next) {
66
+ ctx.addIssue({ code: 'custom', path: ['confirm'], message: vmsg('account:security.password.mismatch') })
67
+ }
68
+ })
69
+ }
70
+
71
+ type PasswordValues = { current: string; next: string; confirm: string }
72
+
73
+ function PasswordCard() {
74
+ const { t } = useTranslation(['account', 'common'])
75
+ const fmt = useFormat()
76
+ const policy = useSecurityPolicy().password
77
+ const overview = useSecurityOverview()
78
+ const change = useChangePassword()
79
+ const schema = useMemo(() => createPasswordSchema(policy), [policy])
80
+ const form = useForm<PasswordValues>({ resolver: zodResolver(schema), defaultValues: { current: '', next: '', confirm: '' } })
81
+ const { register, handleSubmit, formState, reset, setError, control } = form
82
+ const next = useWatch({ control, name: 'next' })
83
+ const { errors } = formState
84
+
85
+ const onSubmit = handleSubmit(async (values) => {
86
+ try {
87
+ await change.mutateAsync({ current: values.current, next: values.next })
88
+ reset()
89
+ toast.success(t('security.password.updated'))
90
+ } catch (e) {
91
+ if (e instanceof ApiError && e.message === accountErrors.wrongPassword) {
92
+ setError('current', { message: vmsg('account:security.password.wrongCurrent') })
93
+ return
94
+ }
95
+ toast.error(e instanceof Error ? e.message : String(e))
96
+ }
97
+ })
98
+
99
+ return (
100
+ <form onSubmit={onSubmit} noValidate className="rounded-xl border bg-card shadow-(--shadow-card)">
101
+ <FormSection
102
+ title={t('security.password.title')}
103
+ description={
104
+ overview.data
105
+ ? t('security.password.description', { when: fmt.relative(overview.data.passwordChangedAt) })
106
+ : undefined
107
+ }
108
+ >
109
+ <Field data-invalid={!!errors.current}>
110
+ <FieldLabel htmlFor="current">{t('security.password.current')}</FieldLabel>
111
+ <PasswordInput id="current" autoComplete="current-password" invalid={!!errors.current} {...register('current')} />
112
+ <FormFieldError error={errors.current} />
113
+ </Field>
114
+ <Field data-invalid={!!errors.next}>
115
+ <FieldLabel htmlFor="next">{t('security.password.new')}</FieldLabel>
116
+ <PasswordInput id="next" autoComplete="new-password" invalid={!!errors.next} {...register('next')} />
117
+ <PasswordStrength password={next} policy={policy} />
118
+ <FormFieldError error={errors.next} />
119
+ </Field>
120
+ <Field data-invalid={!!errors.confirm}>
121
+ <FieldLabel htmlFor="confirm">{t('security.password.confirm')}</FieldLabel>
122
+ <PasswordInput id="confirm" autoComplete="new-password" invalid={!!errors.confirm} {...register('confirm')} />
123
+ <FormFieldError error={errors.confirm} />
124
+ </Field>
125
+ </FormSection>
126
+ <div className="flex justify-end border-t px-5 py-3 md:px-6">
127
+ <Button type="submit" disabled={formState.isSubmitting || !formState.isDirty}>
128
+ {formState.isSubmitting && <Spinner data-icon="inline-start" />}
129
+ {t('security.password.submit')}
130
+ </Button>
131
+ </div>
132
+ </form>
133
+ )
134
+ }
135
+
136
+ function PasswordInput({ invalid, ...props }: React.ComponentProps<'input'> & { invalid?: boolean }) {
137
+ const { t } = useTranslation('account')
138
+ const [visible, setVisible] = useState(false)
139
+ return (
140
+ <InputGroup>
141
+ <InputGroupInput type={visible ? 'text' : 'password'} aria-invalid={invalid} {...props} />
142
+ <InputGroupAddon align="inline-end">
143
+ <InputGroupButton
144
+ size="icon-xs"
145
+ aria-label={visible ? t('security.password.hide') : t('security.password.show')}
146
+ aria-pressed={visible}
147
+ onClick={() => setVisible((v) => !v)}
148
+ >
149
+ {visible ? <EyeOff /> : <Eye />}
150
+ </InputGroupButton>
151
+ </InputGroupAddon>
152
+ </InputGroup>
153
+ )
154
+ }
155
+
156
+ /** A titled row inside a card: text on the left, controls on the right. */
157
+ function SettingRow({ title, description, children }: { title: ReactNode; description?: ReactNode; children: ReactNode }) {
158
+ return (
159
+ <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between md:p-6">
160
+ <div className="min-w-0">
161
+ <h2 className="flex items-center gap-2 text-sm font-medium">{title}</h2>
162
+ {description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
163
+ </div>
164
+ <div className="flex shrink-0 items-center gap-2">{children}</div>
165
+ </div>
166
+ )
167
+ }
168
+
169
+ function TwoFactorCard() {
170
+ const { t } = useTranslation(['account', 'common'])
171
+ const fmt = useFormat()
172
+ const overview = useSecurityOverview()
173
+ const disable = useDisableTwoFactor()
174
+ const confirm = useConfirm()
175
+ const { requireTwoFactor } = useSecurityPolicy()
176
+ const [open, setOpen] = useState(false)
177
+ const enabled = overview.data?.twoFactorEnabled ?? false
178
+
179
+ async function onDisable() {
180
+ const ok = await confirm({
181
+ title: t('security.twoFactor.disableConfirmTitle'),
182
+ description: t('security.twoFactor.disableConfirmDescription'),
183
+ confirmLabel: t('security.twoFactor.disable'),
184
+ destructive: true,
185
+ })
186
+ if (!ok) return
187
+ await disable.mutateAsync()
188
+ toast.success(t('security.twoFactor.disabledToast'))
189
+ }
190
+
191
+ return (
192
+ <section className="rounded-xl border bg-card shadow-(--shadow-card)">
193
+ <SettingRow
194
+ title={
195
+ <>
196
+ {t('security.twoFactor.title')}
197
+ {overview.data && (
198
+ <StatusBadge tone={enabled ? 'success' : 'neutral'}>
199
+ {enabled ? t('security.twoFactor.enabled') : t('security.twoFactor.disabled')}
200
+ </StatusBadge>
201
+ )}
202
+ </>
203
+ }
204
+ description={
205
+ enabled && overview.data?.twoFactorEnabledAt
206
+ ? t('security.twoFactor.enabledSince', { when: fmt.date(overview.data.twoFactorEnabledAt) })
207
+ : requireTwoFactor
208
+ ? t('security.twoFactor.required')
209
+ : t('security.twoFactor.description')
210
+ }
211
+ >
212
+ {overview.isPending ? (
213
+ <Skeleton className="h-8 w-20" />
214
+ ) : enabled ? (
215
+ <Button
216
+ variant="destructive"
217
+ onClick={onDisable}
218
+ disabled={disable.isPending || requireTwoFactor}
219
+ title={requireTwoFactor ? t('security.twoFactor.cannotDisable') : undefined}
220
+ >
221
+ {t('security.twoFactor.disable')}
222
+ </Button>
223
+ ) : (
224
+ <Button onClick={() => setOpen(true)}>{t('security.twoFactor.enable')}</Button>
225
+ )}
226
+ </SettingRow>
227
+ <TwoFactorDialog open={open} onOpenChange={setOpen} />
228
+ </section>
229
+ )
230
+ }
231
+
232
+ function SessionsCard() {
233
+ const { t } = useTranslation(['account', 'common'])
234
+ const fmt = useFormat()
235
+ const sessions = useSessions()
236
+ const revoke = useRevokeSession()
237
+ const confirm = useConfirm()
238
+ const others = sessions.data?.filter((s) => !s.current) ?? []
239
+
240
+ async function revokeOthers() {
241
+ const ok = await confirm({
242
+ title: t('security.sessions.signOutOthersConfirmTitle'),
243
+ description: t('security.sessions.signOutOthersConfirmDescription'),
244
+ confirmLabel: t('security.sessions.signOutOthers'),
245
+ destructive: true,
246
+ })
247
+ if (!ok) return
248
+ await revoke.mutateAsync('others')
249
+ toast.success(t('security.sessions.signedOutOthers'))
250
+ }
251
+
252
+ return (
253
+ <Panel
254
+ label={t('security.sessions.title')}
255
+ actions={
256
+ others.length > 0 && (
257
+ <Button variant="ghost" size="sm" onClick={revokeOthers} disabled={revoke.isPending}>
258
+ {t('security.sessions.signOutOthers')}
259
+ </Button>
260
+ )
261
+ }
262
+ >
263
+ {sessions.error ? (
264
+ <ErrorState error={sessions.error} onRetry={() => sessions.refetch()} />
265
+ ) : (
266
+ <ul className="divide-y">
267
+ {sessions.isPending
268
+ ? Array.from({ length: 3 }, (_, i) => (
269
+ <li key={i} className="px-4 py-3">
270
+ <Skeleton className="h-9 w-full" />
271
+ </li>
272
+ ))
273
+ : sessions.data.map((s) => {
274
+ const device = t('security.sessions.device', { browser: s.browser, os: s.os })
275
+ const Icon = s.kind === 'mobile' ? Smartphone : Monitor
276
+ return (
277
+ <li key={s.id} className="flex items-center gap-3 px-4 py-3">
278
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-fill-2 text-muted-foreground">
279
+ <Icon className="size-4" />
280
+ </span>
281
+ <div className="min-w-0 flex-1">
282
+ <p className="flex flex-wrap items-center gap-2 text-sm font-medium">
283
+ {device}
284
+ {s.current && <Badge variant="secondary">{t('security.sessions.current')}</Badge>}
285
+ </p>
286
+ <p className="truncate text-xs text-muted-foreground">
287
+ {s.location} · <span className="tabular-nums">{s.ip}</span> ·{' '}
288
+ {t('security.sessions.lastActive', { when: fmt.relative(s.lastActiveAt) })}
289
+ </p>
290
+ </div>
291
+ {!s.current && (
292
+ <Button
293
+ variant="ghost"
294
+ size="sm"
295
+ aria-label={t('security.sessions.revokeLabel', { device })}
296
+ disabled={revoke.isPending}
297
+ onClick={async () => {
298
+ await revoke.mutateAsync(s.id)
299
+ toast.success(t('security.sessions.revoked', { device }))
300
+ }}
301
+ >
302
+ {t('security.sessions.revoke')}
303
+ </Button>
304
+ )}
305
+ </li>
306
+ )
307
+ })}
308
+ {sessions.data && others.length === 0 && (
309
+ <li className="px-4 py-3 text-xs text-muted-foreground">{t('security.sessions.none')}</li>
310
+ )}
311
+ </ul>
312
+ )}
313
+ </Panel>
314
+ )
315
+ }
316
+
317
+ function HistoryCard() {
318
+ const { t } = useTranslation('account')
319
+ const fmt = useFormat()
320
+ const history = useLoginHistory()
321
+
322
+ return (
323
+ <Panel label={t('security.history.title')}>
324
+ {history.error ? (
325
+ <ErrorState error={history.error} onRetry={() => history.refetch()} />
326
+ ) : (
327
+ <Table>
328
+ <TableHeader>
329
+ <TableRow className="hover:bg-transparent">
330
+ <TableHeaderCell>{t('security.history.columns.when')}</TableHeaderCell>
331
+ <TableHeaderCell>{t('security.history.columns.device')}</TableHeaderCell>
332
+ <TableHeaderCell className="hidden sm:table-cell">{t('security.history.columns.location')}</TableHeaderCell>
333
+ <TableHeaderCell className="hidden md:table-cell">{t('security.history.columns.ip')}</TableHeaderCell>
334
+ <TableHeaderCell className="text-right">{t('security.history.columns.result')}</TableHeaderCell>
335
+ </TableRow>
336
+ </TableHeader>
337
+ <TableBody>
338
+ {history.isPending
339
+ ? Array.from({ length: 4 }, (_, i) => (
340
+ <TableRow key={i} className="hover:bg-transparent">
341
+ <TableCell colSpan={5} className="px-4">
342
+ <Skeleton className="h-4 w-full" />
343
+ </TableCell>
344
+ </TableRow>
345
+ ))
346
+ : history.data.map((e) => (
347
+ <TableRow key={e.id}>
348
+ <TableCell className="h-11 pl-4 whitespace-nowrap tabular-nums">{fmt.dateTime(e.at)}</TableCell>
349
+ <TableCell>{t('security.sessions.device', { browser: e.browser, os: e.os })}</TableCell>
350
+ <TableCell className="hidden text-muted-foreground sm:table-cell">{e.location}</TableCell>
351
+ <TableCell className="hidden tabular-nums text-xs text-muted-foreground md:table-cell">{e.ip}</TableCell>
352
+ <TableCell className="pr-4 text-right">
353
+ <StatusBadge tone={e.success ? 'success' : 'danger'}>
354
+ {e.success ? t('security.history.success') : t('security.history.failed')}
355
+ </StatusBadge>
356
+ </TableCell>
357
+ </TableRow>
358
+ ))}
359
+ </TableBody>
360
+ </Table>
361
+ )}
362
+ </Panel>
363
+ )
364
+ }
@@ -0,0 +1,37 @@
1
+ export type SecurityOverview = {
2
+ passwordChangedAt: string
3
+ twoFactorEnabled: boolean
4
+ twoFactorEnabledAt?: string
5
+ }
6
+
7
+ export type TwoFactorSetup = {
8
+ /** Base32 secret shown for manual entry. */
9
+ secret: string
10
+ /** otpauth:// URL a real backend encodes into the QR code. */
11
+ otpauthUrl: string
12
+ }
13
+
14
+ export type DeviceKind = 'desktop' | 'mobile'
15
+
16
+ export type ActiveSession = {
17
+ id: string
18
+ browser: string
19
+ os: string
20
+ kind: DeviceKind
21
+ location: string
22
+ ip: string
23
+ lastActiveAt: string
24
+ current: boolean
25
+ }
26
+
27
+ export type LoginEvent = {
28
+ id: string
29
+ at: string
30
+ browser: string
31
+ os: string
32
+ location: string
33
+ ip: string
34
+ success: boolean
35
+ }
36
+
37
+ export type PasswordChange = { current: string; next: string }
@@ -0,0 +1,126 @@
1
+ import { useMemo } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { createColumnHelper } from '@tanstack/react-table'
4
+
5
+ import { useAuditActors, useAuditLog } from '@/core/audit/hooks'
6
+ import { auditActions, type AuditEntry } from '@/core/audit/audit.service'
7
+ import { isRole } from '@/core/auth/roles'
8
+ import { DataTable } from '@/core/components/data-table/data-table'
9
+ import type { DataTableFeatures } from '@/core/components/data-table/features'
10
+ import { ClearFiltersButton, FilterSelect } from '@/core/components/data-table/toolbar'
11
+ import { useListUrlState } from '@/core/components/data-table/use-list-url-state'
12
+ import { Page } from '@/core/components/page'
13
+ import { EmptyState } from '@/core/components/states'
14
+ import { useFormat } from '@/core/i18n/use-format'
15
+
16
+ const FILTER_KEYS = ['actor', 'action', 'range'] as const
17
+ const DEFAULT_SORT = { id: 'at', desc: true }
18
+ const ranges = ['24h', '7d', '30d'] as const
19
+ const helper = createColumnHelper<DataTableFeatures, AuditEntry>()
20
+
21
+ export default function AuditLogPage() {
22
+ const { t } = useTranslation(['company', 'common'])
23
+ const fmt = useFormat()
24
+ const list = useListUrlState({ filterKeys: FILTER_KEYS, defaultSort: DEFAULT_SORT, defaultPageSize: 20 })
25
+ const query = useAuditLog(list.listParams)
26
+ const actors = useAuditActors()
27
+
28
+ const columns = useMemo(() => {
29
+ const roleLabel = (r: string | undefined) => (isRole(r) ? t(`common:roles.${r as 'owner'}`) : (r ?? ''))
30
+ return helper.columns([
31
+ helper.accessor('at', {
32
+ header: t('audit.columns.when'),
33
+ meta: { className: 'whitespace-nowrap tabular-nums text-muted-foreground w-44' },
34
+ cell: ({ getValue }) => <time dateTime={getValue()}>{fmt.dateTime(getValue())}</time>,
35
+ }),
36
+ helper.accessor((e) => e.actor.name, {
37
+ id: 'actorName',
38
+ header: t('audit.columns.user'),
39
+ cell: ({ row: { original: e } }) => (
40
+ <div className="min-w-0">
41
+ <div className="truncate font-medium">{e.actor.name}</div>
42
+ <div className="truncate text-xs text-muted-foreground">{e.actor.email}</div>
43
+ </div>
44
+ ),
45
+ }),
46
+ helper.accessor('action', {
47
+ header: t('audit.columns.action'),
48
+ enableSorting: false,
49
+ cell: ({ row: { original: e } }) => (
50
+ <div className="min-w-0">
51
+ <div>{t(`audit.actions.${e.action}`)}</div>
52
+ <div className="truncate text-xs text-muted-foreground">
53
+ {e.target}
54
+ {e.action === 'roleChanged' && e.detail && (
55
+ <> · {t('audit.roleChange', { from: roleLabel(e.detail.from), to: roleLabel(e.detail.to) })}</>
56
+ )}
57
+ {e.action === 'userInvited' && e.detail?.role && <> · {roleLabel(e.detail.role)}</>}
58
+ </div>
59
+ </div>
60
+ ),
61
+ }),
62
+ helper.accessor('ip', {
63
+ header: t('audit.columns.ip'),
64
+ enableSorting: false,
65
+ meta: { hideOnMobile: true, className: 'tabular-nums text-xs text-muted-foreground' },
66
+ }),
67
+ ])
68
+ }, [t, fmt])
69
+
70
+ const actorOptions = (actors.data ?? []).map((a) => ({ value: a.id, label: a.name }))
71
+ const actionOptions = auditActions.map((a) => ({ value: a, label: t(`audit.actions.${a}`) }))
72
+ const rangeOptions = ranges.map((r) => ({ value: r, label: t(`audit.filters.last${r}`) }))
73
+
74
+ return (
75
+ <Page title={t('audit.title')} description={t('audit.description')}>
76
+ <DataTable
77
+ columns={columns}
78
+ data={query.data?.items}
79
+ total={query.data?.total ?? 0}
80
+ getRowId={(e) => e.id}
81
+ pagination={list.pagination}
82
+ onPaginationChange={list.onPaginationChange}
83
+ sorting={list.sorting}
84
+ onSortingChange={list.onSortingChange}
85
+ isLoading={query.isPending}
86
+ isFetching={query.isFetching}
87
+ error={query.error}
88
+ onRetry={() => query.refetch()}
89
+ noun={t('audit.noun')}
90
+ toolbar={
91
+ <>
92
+ <FilterSelect
93
+ label={t('audit.filters.user')}
94
+ allLabel={t('audit.filters.allUsers')}
95
+ value={list.filters.actor}
96
+ options={actorOptions}
97
+ onChange={(v) => list.setFilter('actor', v)}
98
+ />
99
+ <FilterSelect
100
+ label={t('audit.filters.action')}
101
+ allLabel={t('audit.filters.allActions')}
102
+ value={list.filters.action}
103
+ options={actionOptions}
104
+ onChange={(v) => list.setFilter('action', v)}
105
+ />
106
+ <FilterSelect
107
+ label={t('audit.filters.date')}
108
+ allLabel={t('audit.filters.allTime')}
109
+ value={list.filters.range}
110
+ options={rangeOptions}
111
+ onChange={(v) => list.setFilter('range', v)}
112
+ />
113
+ {list.hasActiveFilters && <ClearFiltersButton onClick={list.clearAll} />}
114
+ </>
115
+ }
116
+ empty={
117
+ <EmptyState
118
+ title={t('audit.emptyTitle')}
119
+ description={t('audit.emptyDescription')}
120
+ action={list.hasActiveFilters ? <ClearFiltersButton onClick={list.clearAll} /> : undefined}
121
+ />
122
+ }
123
+ />
124
+ </Page>
125
+ )
126
+ }