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,299 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Controller, useForm, useWatch, type Control, type FieldValues, type Path } from 'react-hook-form'
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { Send } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import { z } from 'zod'
8
+
9
+ import { Button } from '@/components/ui/button'
10
+ import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
11
+ import { Input } from '@/components/ui/input'
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
13
+ import { Spinner } from '@/components/ui/spinner'
14
+ import { FormFieldError } from '@/core/components/form-error'
15
+ import { FormLayout, FormSection } from '@/core/components/form-page'
16
+ import { StatusBadge } from '@/core/components/status-badge'
17
+ import { vmsg } from '@/core/i18n/keys'
18
+ import { useIntegrationMutation, useSendTestEmail } from './hooks'
19
+ import {
20
+ integrationsService,
21
+ type PaymentSettings,
22
+ type SmtpSettings,
23
+ type StorageSettings,
24
+ } from './integrations.service'
25
+
26
+ const required = z.string().trim().min(1, vmsg('validation.required'))
27
+
28
+ const smtpSchema = z.object({
29
+ host: required,
30
+ port: z
31
+ .number({ error: vmsg('validation.number') })
32
+ .int(vmsg('validation.integer'))
33
+ .min(1, vmsg('validation.required'))
34
+ .max(65535, vmsg('validation.max', { max: 65535 })),
35
+ encryption: z.enum(['none', 'ssl', 'tls']),
36
+ username: z.string().trim(),
37
+ password: z.string(),
38
+ fromName: required,
39
+ fromEmail: required.pipe(z.email(vmsg('validation.email'))),
40
+ })
41
+
42
+ const storageSchema = z
43
+ .object({
44
+ provider: z.enum(['local', 's3', 'r2', 'gcs']),
45
+ bucket: z.string().trim(),
46
+ region: z.string().trim(),
47
+ accessKeyId: z.string().trim(),
48
+ secretAccessKey: z.string(),
49
+ })
50
+ .superRefine((v, ctx) => {
51
+ if (v.provider === 'local') return
52
+ for (const key of ['bucket', 'accessKeyId'] as const) {
53
+ if (!v[key]) ctx.addIssue({ code: 'custom', path: [key], message: vmsg('validation.required') })
54
+ }
55
+ })
56
+
57
+ const paymentsSchema = z
58
+ .object({
59
+ provider: z.enum(['none', 'iyzico', 'stripe', 'paytr']),
60
+ mode: z.enum(['test', 'live']),
61
+ publicKey: z.string().trim(),
62
+ secretKey: z.string(),
63
+ })
64
+ .superRefine((v, ctx) => {
65
+ if (v.provider !== 'none' && !v.publicKey) ctx.addIssue({ code: 'custom', path: ['publicKey'], message: vmsg('validation.required') })
66
+ })
67
+
68
+ /** Card header row: title, connection status. */
69
+ function Header({ title, description, status }: { title: string; description: string; status: ReactNode }) {
70
+ return (
71
+ <div className="flex flex-col gap-2 p-5 sm:flex-row sm:items-start sm:justify-between md:p-6">
72
+ <div>
73
+ <h2 className="text-sm font-medium">{title}</h2>
74
+ <p className="mt-1 text-xs text-muted-foreground">{description}</p>
75
+ </div>
76
+ <div className="flex shrink-0 gap-2">{status}</div>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ function useSave<T>(fn: (v: T) => ReturnType<typeof integrationsService.get>, name: string, reset: (values: T) => void, pick: (i: Awaited<ReturnType<typeof integrationsService.get>>) => T) {
82
+ const { t } = useTranslation('company')
83
+ const mutation = useIntegrationMutation(fn)
84
+ return async (values: T) => {
85
+ const saved = await mutation.mutateAsync(values)
86
+ reset(pick(saved))
87
+ toast.success(t('integrations.saved', { name }))
88
+ }
89
+ }
90
+
91
+ export function SmtpForm({ value }: { value: SmtpSettings }) {
92
+ const { t } = useTranslation(['company', 'common'])
93
+ const form = useForm<SmtpSettings>({ resolver: zodResolver(smtpSchema), defaultValues: value })
94
+ const { register, control, handleSubmit, formState, reset } = form
95
+ const { errors } = formState
96
+ const save = useSave((v: SmtpSettings) => integrationsService.updateSmtp(v), t('integrations.tabs.smtp'), reset, (i) => i.smtp)
97
+ const test = useSendTestEmail()
98
+ const encryptionItems = (['none', 'ssl', 'tls'] as const).map((e) => ({ value: e, label: t(`integrations.smtp.encryptionOptions.${e}`) }))
99
+
100
+ return (
101
+ <FormLayout onSubmit={handleSubmit(save)} saveBar="dirty" isDirty={formState.isDirty} isSubmitting={formState.isSubmitting} onCancel={() => reset()}>
102
+ <Header
103
+ title={t('integrations.smtp.title')}
104
+ description={t('integrations.smtp.description')}
105
+ status={<StatusBadge tone={value.host ? 'success' : 'neutral'}>{value.host ? t('integrations.status.connected') : t('integrations.status.notConnected')}</StatusBadge>}
106
+ />
107
+ <FormSection title={t('integrations.smtp.server')}>
108
+ <div className="grid gap-4 sm:grid-cols-[1fr_6rem_9rem]">
109
+ <TextField label={t('integrations.smtp.host')} id="smtp-host" error={errors.host} inputProps={register('host')} />
110
+ <TextField
111
+ label={t('integrations.smtp.port')}
112
+ id="smtp-port"
113
+ error={errors.port}
114
+ inputProps={{ ...register('port', { valueAsNumber: true }), type: 'number', inputMode: 'numeric' }}
115
+ />
116
+ <SelectField control={control} name="encryption" id="smtp-encryption" label={t('integrations.smtp.encryption')} items={encryptionItems} />
117
+ </div>
118
+ <div className="grid gap-4 sm:grid-cols-2">
119
+ <TextField label={t('integrations.smtp.username')} id="smtp-username" error={errors.username} inputProps={{ ...register('username'), autoComplete: 'off' }} />
120
+ <SecretField label={t('integrations.smtp.password')} id="smtp-password" inputProps={register('password')} />
121
+ </div>
122
+ </FormSection>
123
+ <FormSection title={t('integrations.smtp.sender.title')} description={t('integrations.smtp.sender.description')}>
124
+ <div className="grid gap-4 sm:grid-cols-2">
125
+ <TextField label={t('integrations.smtp.fromName')} id="smtp-from-name" error={errors.fromName} inputProps={register('fromName')} />
126
+ <TextField label={t('integrations.smtp.fromEmail')} id="smtp-from-email" error={errors.fromEmail} inputProps={{ ...register('fromEmail'), type: 'email' }} />
127
+ </div>
128
+ <div>
129
+ <Button
130
+ type="button"
131
+ variant="outline"
132
+ size="sm"
133
+ disabled={test.isPending}
134
+ onClick={async () => {
135
+ await test.mutateAsync()
136
+ toast.success(t('integrations.smtp.testSent', { email: value.fromEmail }))
137
+ }}
138
+ >
139
+ {test.isPending ? <Spinner data-icon="inline-start" /> : <Send data-icon="inline-start" />}
140
+ {t('integrations.smtp.test')}
141
+ </Button>
142
+ </div>
143
+ </FormSection>
144
+ </FormLayout>
145
+ )
146
+ }
147
+
148
+ export function StorageForm({ value }: { value: StorageSettings }) {
149
+ const { t } = useTranslation('company')
150
+ const form = useForm<StorageSettings>({ resolver: zodResolver(storageSchema), defaultValues: value })
151
+ const { register, control, handleSubmit, formState, reset } = form
152
+ const { errors } = formState
153
+ const provider = useWatch({ control, name: 'provider' })
154
+ const save = useSave((v: StorageSettings) => integrationsService.updateStorage(v), t('integrations.tabs.storage'), reset, (i) => i.storage)
155
+ const providerItems = (['local', 's3', 'r2', 'gcs'] as const).map((p) => ({ value: p, label: t(`integrations.storage.providers.${p}`) }))
156
+
157
+ return (
158
+ <FormLayout onSubmit={handleSubmit(save)} saveBar="dirty" isDirty={formState.isDirty} isSubmitting={formState.isSubmitting} onCancel={() => reset()}>
159
+ <Header
160
+ title={t('integrations.storage.title')}
161
+ description={t('integrations.storage.description')}
162
+ status={<StatusBadge tone="success">{t(`integrations.storage.providers.${value.provider}`)}</StatusBadge>}
163
+ />
164
+ <FormSection title={t('integrations.storage.provider')}>
165
+ <SelectField control={control} name="provider" id="storage-provider" label={t('integrations.storage.provider')} items={providerItems} />
166
+ {provider === 'local' ? (
167
+ <p className="text-xs text-muted-foreground">{t('integrations.storage.localHint')}</p>
168
+ ) : (
169
+ <>
170
+ <div className="grid gap-4 sm:grid-cols-2">
171
+ <TextField label={t('integrations.storage.bucket')} id="storage-bucket" error={errors.bucket} inputProps={register('bucket')} />
172
+ <TextField label={t('integrations.storage.region')} id="storage-region" error={errors.region} inputProps={register('region')} />
173
+ </div>
174
+ <div className="grid gap-4 sm:grid-cols-2">
175
+ <TextField label={t('integrations.storage.accessKey')} id="storage-access" error={errors.accessKeyId} inputProps={{ ...register('accessKeyId'), autoComplete: 'off' }} />
176
+ <SecretField label={t('integrations.storage.secretKey')} id="storage-secret" inputProps={register('secretAccessKey')} />
177
+ </div>
178
+ </>
179
+ )}
180
+ </FormSection>
181
+ </FormLayout>
182
+ )
183
+ }
184
+
185
+ export function PaymentsForm({ value }: { value: PaymentSettings }) {
186
+ const { t } = useTranslation('company')
187
+ const form = useForm<PaymentSettings>({ resolver: zodResolver(paymentsSchema), defaultValues: value })
188
+ const { register, control, handleSubmit, formState, reset } = form
189
+ const { errors } = formState
190
+ const provider = useWatch({ control, name: 'provider' })
191
+ const save = useSave((v: PaymentSettings) => integrationsService.updatePayments(v), t('integrations.tabs.payments'), reset, (i) => i.payments)
192
+ const providerItems = (['none', 'iyzico', 'stripe', 'paytr'] as const).map((p) => ({ value: p, label: t(`integrations.payments.providers.${p}`) }))
193
+ const modeItems = (['test', 'live'] as const).map((m) => ({ value: m, label: t(`integrations.payments.modes.${m}`) }))
194
+ const connected = value.provider !== 'none'
195
+
196
+ return (
197
+ <FormLayout onSubmit={handleSubmit(save)} saveBar="dirty" isDirty={formState.isDirty} isSubmitting={formState.isSubmitting} onCancel={() => reset()}>
198
+ <Header
199
+ title={t('integrations.payments.title')}
200
+ description={t('integrations.payments.description')}
201
+ status={
202
+ <>
203
+ <StatusBadge tone={connected ? 'success' : 'neutral'}>
204
+ {connected ? t('integrations.status.connected') : t('integrations.status.notConnected')}
205
+ </StatusBadge>
206
+ {connected && (
207
+ <StatusBadge tone={value.mode === 'live' ? 'success' : 'warning'}>
208
+ {value.mode === 'live' ? t('integrations.status.liveMode') : t('integrations.status.testMode')}
209
+ </StatusBadge>
210
+ )}
211
+ </>
212
+ }
213
+ />
214
+ <FormSection title={t('integrations.payments.provider')}>
215
+ <div className="grid gap-4 sm:grid-cols-2">
216
+ <SelectField control={control} name="provider" id="pay-provider" label={t('integrations.payments.provider')} items={providerItems} />
217
+ {provider !== 'none' && (
218
+ <SelectField control={control} name="mode" id="pay-mode" label={t('integrations.payments.mode')} items={modeItems} />
219
+ )}
220
+ </div>
221
+ {provider !== 'none' && (
222
+ <div className="grid gap-4 sm:grid-cols-2">
223
+ <TextField label={t('integrations.payments.publicKey')} id="pay-public" error={errors.publicKey} inputProps={{ ...register('publicKey'), autoComplete: 'off' }} />
224
+ <SecretField label={t('integrations.payments.secretKey')} id="pay-secret" inputProps={register('secretKey')} />
225
+ </div>
226
+ )}
227
+ </FormSection>
228
+ </FormLayout>
229
+ )
230
+ }
231
+
232
+ function TextField({
233
+ id,
234
+ label,
235
+ error,
236
+ inputProps,
237
+ }: {
238
+ id: string
239
+ label: string
240
+ error?: { message?: string }
241
+ inputProps: React.ComponentProps<'input'>
242
+ }) {
243
+ return (
244
+ <Field data-invalid={!!error}>
245
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
246
+ <Input id={id} aria-invalid={!!error} {...inputProps} />
247
+ <FormFieldError error={error} />
248
+ </Field>
249
+ )
250
+ }
251
+
252
+ function SecretField({ id, label, inputProps }: { id: string; label: string; inputProps: React.ComponentProps<'input'> }) {
253
+ const { t } = useTranslation('company')
254
+ return (
255
+ <Field>
256
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
257
+ <Input id={id} type="password" autoComplete="new-password" {...inputProps} />
258
+ <FieldDescription>{t('integrations.secretHint')}</FieldDescription>
259
+ </Field>
260
+ )
261
+ }
262
+
263
+ function SelectField<T extends FieldValues>({
264
+ control,
265
+ name,
266
+ id,
267
+ label,
268
+ items,
269
+ }: {
270
+ control: Control<T>
271
+ name: Path<T>
272
+ id: string
273
+ label: string
274
+ items: { value: string; label: string }[]
275
+ }) {
276
+ return (
277
+ <Field>
278
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
279
+ <Controller
280
+ control={control}
281
+ name={name}
282
+ render={({ field }) => (
283
+ <Select items={items} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
284
+ <SelectTrigger id={id} className="w-full">
285
+ <SelectValue />
286
+ </SelectTrigger>
287
+ <SelectContent>
288
+ {items.map((i) => (
289
+ <SelectItem key={i.value} value={i.value}>
290
+ {i.label}
291
+ </SelectItem>
292
+ ))}
293
+ </SelectContent>
294
+ </Select>
295
+ )}
296
+ />
297
+ </Field>
298
+ )
299
+ }
@@ -0,0 +1,179 @@
1
+ import { useState } from 'react'
2
+ import { Controller, useForm } from 'react-hook-form'
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { Plus, Trash2, Webhook as WebhookIcon } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import { z } from 'zod'
8
+
9
+ import { Button } from '@/components/ui/button'
10
+ import { Checkbox } from '@/components/ui/checkbox'
11
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
12
+ import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field'
13
+ import { Input } from '@/components/ui/input'
14
+ import { Spinner } from '@/components/ui/spinner'
15
+ import { Switch } from '@/components/ui/switch'
16
+ import { useConfirm } from '@/core/components/confirm-dialog'
17
+ import { FormFieldError } from '@/core/components/form-error'
18
+ import { Panel } from '@/core/components/page'
19
+ import { EmptyState } from '@/core/components/states'
20
+ import { StatusDot } from '@/core/components/status-badge'
21
+ import { vmsg } from '@/core/i18n/keys'
22
+ import { useFormat } from '@/core/i18n/use-format'
23
+ import { useIntegrationMutation } from './hooks'
24
+ import { integrationsService, webhookEvents, type Webhook, type WebhookEvent } from './integrations.service'
25
+
26
+ export function WebhooksPanel({ webhooks }: { webhooks: Webhook[] }) {
27
+ const { t } = useTranslation(['company', 'common'])
28
+ const fmt = useFormat()
29
+ const confirm = useConfirm()
30
+ const [open, setOpen] = useState(false)
31
+ const toggle = useIntegrationMutation(({ id, active }: { id: string; active: boolean }) => integrationsService.setWebhookActive(id, active))
32
+ const remove = useIntegrationMutation((id: string) => integrationsService.deleteWebhook(id))
33
+
34
+ async function onDelete(webhook: Webhook) {
35
+ const ok = await confirm({
36
+ title: t('integrations.webhooks.deleteConfirm'),
37
+ description: t('integrations.webhooks.deleteDescription', { url: webhook.url }),
38
+ confirmLabel: t('common:actions.delete'),
39
+ destructive: true,
40
+ })
41
+ if (!ok) return
42
+ await remove.mutateAsync(webhook.id)
43
+ toast.success(t('integrations.webhooks.deleted'))
44
+ }
45
+
46
+ return (
47
+ <Panel
48
+ label={t('integrations.webhooks.title')}
49
+ actions={
50
+ <Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
51
+ <Plus data-icon="inline-start" />
52
+ {t('integrations.webhooks.add')}
53
+ </Button>
54
+ }
55
+ >
56
+ <p className="border-b px-4 py-2.5 text-xs text-muted-foreground">{t('integrations.webhooks.description')}</p>
57
+ {webhooks.length === 0 ? (
58
+ <EmptyState
59
+ icon={<WebhookIcon />}
60
+ title={t('integrations.webhooks.empty')}
61
+ description={t('integrations.webhooks.emptyDescription')}
62
+ className="py-10"
63
+ />
64
+ ) : (
65
+ <ul className="divide-y">
66
+ {webhooks.map((w) => (
67
+ <li key={w.id} className="flex items-center gap-3 px-4 py-3">
68
+ <StatusDot tone={!w.active ? 'neutral' : w.lastStatus === 'failed' ? 'danger' : 'success'} />
69
+ <div className="min-w-0 flex-1">
70
+ <p className="truncate tabular-nums text-xs">{w.url}</p>
71
+ <p className="truncate text-xs text-muted-foreground">
72
+ {t('integrations.webhooks.eventCount', { count: w.events.length })} ·{' '}
73
+ {w.lastDeliveryAt
74
+ ? t('integrations.webhooks.lastDelivery', { when: fmt.relative(w.lastDeliveryAt) })
75
+ : t('integrations.webhooks.neverDelivered')}
76
+ </p>
77
+ </div>
78
+ <Switch
79
+ checked={w.active}
80
+ aria-label={t('integrations.webhooks.toggle', { url: w.url })}
81
+ onCheckedChange={(active) => toggle.mutate({ id: w.id, active })}
82
+ />
83
+ <Button variant="ghost" size="icon-sm" aria-label={t('integrations.webhooks.deleteLabel', { url: w.url })} onClick={() => void onDelete(w)}>
84
+ <Trash2 />
85
+ </Button>
86
+ </li>
87
+ ))}
88
+ </ul>
89
+ )}
90
+ <WebhookDialog open={open} onOpenChange={setOpen} />
91
+ </Panel>
92
+ )
93
+ }
94
+
95
+ const schema = z.object({
96
+ url: z
97
+ .string()
98
+ .trim()
99
+ .regex(/^https:\/\/[^\s/$.?#].[^\s]*$/i, vmsg('company:integrations.webhooks.dialog.urlInvalid')),
100
+ events: z.array(z.enum(webhookEvents)).min(1, vmsg('company:integrations.webhooks.dialog.eventsRequired')),
101
+ })
102
+ type Values = { url: string; events: WebhookEvent[] }
103
+
104
+ function WebhookDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
105
+ const { t } = useTranslation(['company', 'common'])
106
+ const create = useIntegrationMutation((v: Values) => integrationsService.createWebhook(v))
107
+ const { register, control, handleSubmit, formState, reset } = useForm<Values>({
108
+ resolver: zodResolver(schema),
109
+ defaultValues: { url: '', events: ['product.created', 'product.updated'] },
110
+ })
111
+ const { errors } = formState
112
+
113
+ function close() {
114
+ onOpenChange(false)
115
+ reset()
116
+ }
117
+
118
+ const onSubmit = handleSubmit(async (values) => {
119
+ await create.mutateAsync(values)
120
+ toast.success(t('integrations.webhooks.created'))
121
+ close()
122
+ })
123
+
124
+ return (
125
+ <Dialog open={open} onOpenChange={(next) => !next && close()}>
126
+ <DialogContent showCloseButton={false} className="sm:max-w-md">
127
+ <DialogHeader>
128
+ <DialogTitle>{t('integrations.webhooks.dialog.title')}</DialogTitle>
129
+ <DialogDescription>{t('integrations.webhooks.dialog.description')}</DialogDescription>
130
+ </DialogHeader>
131
+ <form id="webhook-form" onSubmit={onSubmit} noValidate>
132
+ <FieldGroup className="gap-4">
133
+ <Field data-invalid={!!errors.url}>
134
+ <FieldLabel htmlFor="webhook-url">{t('integrations.webhooks.dialog.url')}</FieldLabel>
135
+ <Input id="webhook-url" type="url" inputMode="url" autoFocus placeholder="https://" className="tabular-nums" aria-invalid={!!errors.url} {...register('url')} />
136
+ <FormFieldError error={errors.url} />
137
+ </Field>
138
+ <FieldSet data-invalid={!!errors.events}>
139
+ <FieldLegend variant="label">{t('integrations.webhooks.dialog.events')}</FieldLegend>
140
+ <Controller
141
+ control={control}
142
+ name="events"
143
+ render={({ field }) => (
144
+ <div className="flex flex-col gap-2">
145
+ {webhookEvents.map((event) => (
146
+ <Field key={event} orientation="horizontal">
147
+ <Checkbox
148
+ id={`event-${event}`}
149
+ checked={field.value.includes(event)}
150
+ onCheckedChange={(checked) =>
151
+ field.onChange(checked ? [...field.value, event] : field.value.filter((e) => e !== event))
152
+ }
153
+ />
154
+ <FieldLabel htmlFor={`event-${event}`} className="font-normal">
155
+ {t(`integrations.webhooks.eventNames.${event}`)}
156
+ <span className="tabular-nums text-2xs text-muted-foreground">{event}</span>
157
+ </FieldLabel>
158
+ </Field>
159
+ ))}
160
+ </div>
161
+ )}
162
+ />
163
+ <FormFieldError error={errors.events} />
164
+ </FieldSet>
165
+ </FieldGroup>
166
+ </form>
167
+ <DialogFooter>
168
+ <Button variant="ghost" onClick={close}>
169
+ {t('common:actions.cancel')}
170
+ </Button>
171
+ <Button type="submit" form="webhook-form" disabled={formState.isSubmitting}>
172
+ {formState.isSubmitting && <Spinner data-icon="inline-start" />}
173
+ {t('integrations.webhooks.add')}
174
+ </Button>
175
+ </DialogFooter>
176
+ </DialogContent>
177
+ </Dialog>
178
+ )
179
+ }
@@ -0,0 +1,65 @@
1
+ import { Trans, useTranslation } from 'react-i18next'
2
+ import { Blocks, Info } from 'lucide-react'
3
+
4
+ import { Switch } from '@/components/ui/switch'
5
+ import { appConfig } from '@/app.config'
6
+ import { moduleActions } from '@/core/auth/permissions'
7
+ import { Page } from '@/core/components/page'
8
+ import { EmptyState } from '@/core/components/states'
9
+ import { StatusBadge } from '@/core/components/status-badge'
10
+ import { useAnyT } from '@/core/i18n/keys'
11
+ import type { AppModule } from '@/core/modules/types'
12
+ import { moduleRegistry, type ModuleId } from '@/modules/registry'
13
+
14
+ const moduleIds = Object.keys(moduleRegistry) as ModuleId[]
15
+
16
+ /** Read-only until a backend stores which modules are enabled (today: app.config.ts). */
17
+ export default function ModulesPage() {
18
+ const { t } = useTranslation('company')
19
+ const anyT = useAnyT()
20
+
21
+ return (
22
+ <Page title={t('modules.title')} description={t('modules.description')}>
23
+ <p className="flex gap-2 rounded-xl border bg-fill-1 px-4 py-3 text-xs text-muted-foreground">
24
+ <Info className="mt-px size-3.5 shrink-0" />
25
+ <span>
26
+ <Trans t={t} i18nKey="modules.note" components={{ code: <code className="tabular-nums text-[11px] text-foreground" /> }} />
27
+ </span>
28
+ </p>
29
+ {moduleIds.length === 0 && (
30
+ <div className="rounded-xl border bg-card shadow-(--shadow-card)">
31
+ <EmptyState icon={<Blocks />} title={t('modules.emptyTitle')} description={t('modules.emptyDescription')} />
32
+ </div>
33
+ )}
34
+ <ul className="divide-y rounded-xl border bg-card shadow-(--shadow-card) empty:hidden">
35
+ {moduleIds.map((id) => {
36
+ // Typed explicitly: with no modules registered yet the registry's type is empty.
37
+ const module: AppModule = moduleRegistry[id]
38
+ const nav = module.nav[0]
39
+ const Icon = nav?.icon ?? Blocks
40
+ const name = nav ? anyT(nav.label) : id
41
+ const enabled = Boolean(appConfig.modules[id])
42
+ return (
43
+ <li key={id} className="flex items-center gap-4 p-4 md:px-6">
44
+ <span className="flex size-10 shrink-0 items-center justify-center rounded-xl border bg-raised text-muted-foreground shadow-(--shadow-raised)">
45
+ <Icon className="size-4" />
46
+ </span>
47
+ <div className="min-w-0 flex-1">
48
+ <p className="flex flex-wrap items-center gap-2 text-sm font-medium">
49
+ {name}
50
+ <StatusBadge tone={enabled ? 'success' : 'neutral'}>
51
+ {enabled ? t('modules.enabled') : t('modules.disabled')}
52
+ </StatusBadge>
53
+ </p>
54
+ <p className="mt-0.5 tabular-nums text-2xs text-muted-foreground">
55
+ {id} · {t('modules.permissions', { count: moduleActions.length })}
56
+ </p>
57
+ </div>
58
+ <Switch checked={enabled} disabled aria-label={t('modules.toggleLabel', { name })} />
59
+ </li>
60
+ )
61
+ })}
62
+ </ul>
63
+ </Page>
64
+ )
65
+ }
@@ -0,0 +1,116 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Check, Minus } from 'lucide-react'
4
+
5
+ import { Table, TableBody, TableCell, TableHeader, TableRow } from '@/components/ui/table'
6
+ import { TableHeaderCell } from '@/core/components/data-table/table-header-cell'
7
+ import { hasPermission, matrixActions, permissionMatrix, roles, type Role } from '@/core/auth/permissions'
8
+ import { Page, Panel } from '@/core/components/page'
9
+ import type { AppModule } from '@/core/modules/types'
10
+ import { Segmented } from '@/core/components/segmented'
11
+ import { Skeleton } from '@/components/ui/skeleton'
12
+ import { useAnyT } from '@/core/i18n/keys'
13
+ import { useActiveUsers } from '@/core/users/hooks'
14
+ import { moduleRegistry } from '@/modules/registry'
15
+ import { cn } from '@/lib/utils'
16
+
17
+ /** "products.*", "settings.company.*": the shared prefix of a row's permission names. */
18
+ function permissionPrefix(row: (typeof permissionMatrix)[number]) {
19
+ const first = Object.values(row.actions)[0] ?? ''
20
+ return `${first.slice(0, first.lastIndexOf('.'))}.*`
21
+ }
22
+
23
+ export default function RolesPage() {
24
+ const { t } = useTranslation(['company', 'common'])
25
+ const anyT = useAnyT()
26
+ const users = useActiveUsers()
27
+ const [role, setRole] = useState<Role>('editor')
28
+
29
+ const rowLabel = (row: (typeof permissionMatrix)[number]) =>
30
+ row.module
31
+ ? anyT((moduleRegistry[row.module] as AppModule).nav[0]?.label ?? 'common:nav.dashboard')
32
+ : t(`roles.resources.${row.id as 'dashboard'}`)
33
+
34
+ return (
35
+ <Page title={t('roles.title')} description={t('roles.description')}>
36
+ <ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
37
+ {roles.map((r) => {
38
+ const count = users.data?.filter((u) => u.role === r).length
39
+ return (
40
+ <li key={r} className="flex flex-col gap-2 rounded-xl border bg-card p-4 shadow-(--shadow-card)">
41
+ <div className="flex items-center justify-between gap-2">
42
+ <h2 className="text-sm font-medium">{t(`common:roles.${r as 'owner'}`)}</h2>
43
+ {count === undefined ? (
44
+ <Skeleton className="h-4 w-14" />
45
+ ) : (
46
+ <span className="text-[11px] text-muted-foreground tabular-nums">{t('roles.members', { count })}</span>
47
+ )}
48
+ </div>
49
+ <p className="text-xs leading-relaxed text-muted-foreground">{t(`roles.descriptions.${r as 'owner'}`)}</p>
50
+ </li>
51
+ )
52
+ })}
53
+ </ul>
54
+
55
+ <Panel label={t('roles.matrix.title')}>
56
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
57
+ <p className="text-xs text-muted-foreground">{t('roles.matrix.description')}</p>
58
+ <Segmented
59
+ label={t('roles.matrix.role')}
60
+ value={role}
61
+ onChange={setRole}
62
+ options={roles.map((r) => ({ value: r, label: t(`common:roles.${r as 'owner'}`) }))}
63
+ />
64
+ </div>
65
+ <div className="overflow-x-auto">
66
+ <Table>
67
+ <TableHeader>
68
+ <TableRow className="hover:bg-transparent">
69
+ <TableHeaderCell>{t('roles.matrix.resource')}</TableHeaderCell>
70
+ {matrixActions.map((a) => (
71
+ <TableHeaderCell key={a} className="text-center">
72
+ {t(`roles.actions.${a}`)}
73
+ </TableHeaderCell>
74
+ ))}
75
+ </TableRow>
76
+ </TableHeader>
77
+ <TableBody>
78
+ {permissionMatrix.map((row) => (
79
+ <TableRow key={row.id}>
80
+ <TableCell className="h-12 pl-4">
81
+ <span className="block font-medium">{rowLabel(row)}</span>
82
+ <span className="block tabular-nums text-2xs text-muted-foreground">{permissionPrefix(row)}</span>
83
+ </TableCell>
84
+ {matrixActions.map((action) => {
85
+ const permission = row.actions[action]
86
+ const granted = permission ? hasPermission(role, permission) : undefined
87
+ return (
88
+ <TableCell key={action} className="text-center last:pr-4">
89
+ {permission === undefined ? (
90
+ <span className="sr-only">{t('roles.matrix.notApplicable')}</span>
91
+ ) : (
92
+ <span
93
+ title={permission}
94
+ className={cn(
95
+ 'inline-flex size-6 items-center justify-center rounded-md',
96
+ granted ? 'bg-fill-2 text-primary' : 'text-muted-foreground/60',
97
+ )}
98
+ >
99
+ {granted ? <Check className="size-3.5" /> : <Minus className="size-3.5" />}
100
+ <span className="sr-only">
101
+ {permission}: {granted ? t('roles.matrix.granted') : t('roles.matrix.denied')}
102
+ </span>
103
+ </span>
104
+ )}
105
+ </TableCell>
106
+ )
107
+ })}
108
+ </TableRow>
109
+ ))}
110
+ </TableBody>
111
+ </Table>
112
+ </div>
113
+ </Panel>
114
+ </Page>
115
+ )
116
+ }