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,234 @@
1
+ import { useMemo } 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 { toast } from 'sonner'
6
+ import { z } from 'zod'
7
+
8
+ import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
9
+ import { Input } from '@/components/ui/input'
10
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
11
+ import { Textarea } from '@/components/ui/textarea'
12
+ import { useCan } from '@/core/auth/can'
13
+ import { useCompanySettings, useUpdateGeneral } from '@/core/company/hooks'
14
+ import { currencies, type CompanyGeneral } from '@/core/company/types'
15
+ import { FormFieldError } from '@/core/components/form-error'
16
+ import { FormLayout, FormSection } from '@/core/components/form-page'
17
+ import { Page } from '@/core/components/page'
18
+ import { PhoneInput } from '@/core/components/phone-input'
19
+ import { ErrorState, LoadingState } from '@/core/components/states'
20
+ import { vmsg } from '@/core/i18n/keys'
21
+ import { languageNames, languages } from '@/core/i18n/types'
22
+ import { dialCode, isValidPhone, splitPhone, toE164 } from '@/core/lib/phone'
23
+ import { timeZones } from '@/core/preferences/preferences-store'
24
+ import { ReadOnlyNotice } from './components/read-only-notice'
25
+
26
+ const required = (max: number) =>
27
+ z
28
+ .string()
29
+ .trim()
30
+ .min(1, vmsg('validation.required'))
31
+ .max(max, vmsg('validation.maxChars', { max }))
32
+
33
+ const optional = (max: number) => z.string().trim().max(max, vmsg('validation.maxChars', { max }))
34
+
35
+ const schema = z
36
+ .object({
37
+ name: required(80),
38
+ legalName: optional(160),
39
+ taxOffice: optional(80),
40
+ taxNumber: z
41
+ .string()
42
+ .trim()
43
+ .regex(/^(\d{10})?$/, vmsg('company:general.tax.numberInvalid')),
44
+ address: optional(300),
45
+ contactEmail: z.string().trim().min(1, vmsg('validation.required')).pipe(z.email(vmsg('validation.email'))),
46
+ phoneCountry: z.string(),
47
+ phoneNumber: z.string().trim(),
48
+ defaultLanguage: z.enum(languages),
49
+ timeZone: z.string(),
50
+ currency: z.enum(currencies),
51
+ })
52
+ .superRefine((v, ctx) => {
53
+ if (v.phoneNumber && !isValidPhone(v.phoneCountry, v.phoneNumber)) {
54
+ ctx.addIssue({
55
+ code: 'custom',
56
+ path: ['phoneNumber'],
57
+ message: vmsg('account:profile.contact.phoneInvalid', { country: dialCode(v.phoneCountry) }),
58
+ })
59
+ }
60
+ })
61
+
62
+ type Values = z.infer<typeof schema>
63
+
64
+ function toValues({ contactPhone, ...general }: CompanyGeneral): Values {
65
+ const phone = splitPhone(contactPhone)
66
+ return { ...general, phoneCountry: phone.country, phoneNumber: phone.number }
67
+ }
68
+
69
+ export default function GeneralPage() {
70
+ const { t } = useTranslation('company')
71
+ const query = useCompanySettings()
72
+ return (
73
+ <Page title={t('general.title')} description={t('general.description')}>
74
+ {query.isPending ? (
75
+ <LoadingState />
76
+ ) : query.error ? (
77
+ <ErrorState error={query.error} onRetry={() => query.refetch()} />
78
+ ) : (
79
+ <GeneralForm general={query.data.general} />
80
+ )}
81
+ </Page>
82
+ )
83
+ }
84
+
85
+ function GeneralForm({ general }: { general: CompanyGeneral }) {
86
+ const { t, i18n } = useTranslation(['company', 'account', 'common'])
87
+ const canEdit = useCan('settings.company.edit')
88
+ const update = useUpdateGeneral()
89
+ const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: toValues(general) })
90
+ const { register, control, handleSubmit, formState, reset } = form
91
+ const { errors } = formState
92
+
93
+ const zoneItems = useMemo(() => timeZones.map((z) => ({ value: z, label: z.replace(/_/g, ' ') })), [])
94
+ const currencyNames = useMemo(() => new Intl.DisplayNames([i18n.language], { type: 'currency' }), [i18n.language])
95
+ const currencyItems = currencies.map((c) => ({ value: c, label: `${c} · ${currencyNames.of(c) ?? c}` }))
96
+ const languageItems = languages.map((l) => ({ value: l, label: languageNames[l] }))
97
+
98
+ const onSubmit = handleSubmit(async ({ phoneCountry, phoneNumber, ...values }) => {
99
+ const saved = await update.mutateAsync({
100
+ ...values,
101
+ contactPhone: phoneNumber ? toE164(phoneCountry, phoneNumber) : undefined,
102
+ })
103
+ reset(toValues(saved.general))
104
+ toast.success(t('general.saved'))
105
+ })
106
+
107
+ return (
108
+ <>
109
+ {!canEdit && <ReadOnlyNotice />}
110
+ <FormLayout
111
+ onSubmit={onSubmit}
112
+ saveBar="dirty"
113
+ isDirty={formState.isDirty}
114
+ isSubmitting={formState.isSubmitting}
115
+ onCancel={() => reset()}
116
+ readOnly={!canEdit}
117
+ >
118
+ <FormSection title={t('general.identity.title')} description={t('general.identity.description')}>
119
+ <Field data-invalid={!!errors.name}>
120
+ <FieldLabel htmlFor="name">{t('general.identity.name')}</FieldLabel>
121
+ <Input id="name" autoComplete="organization" aria-invalid={!!errors.name} {...register('name')} />
122
+ <FormFieldError error={errors.name} />
123
+ </Field>
124
+ <Field data-invalid={!!errors.legalName}>
125
+ <FieldLabel htmlFor="legalName">{t('general.identity.legalName')}</FieldLabel>
126
+ <Input id="legalName" aria-invalid={!!errors.legalName} {...register('legalName')} />
127
+ <FieldDescription>{t('general.identity.legalNameHint')}</FieldDescription>
128
+ <FormFieldError error={errors.legalName} />
129
+ </Field>
130
+ </FormSection>
131
+
132
+ <FormSection title={t('general.tax.title')} description={t('general.tax.description')}>
133
+ <div className="grid gap-4 sm:grid-cols-2">
134
+ <Field data-invalid={!!errors.taxOffice}>
135
+ <FieldLabel htmlFor="taxOffice">{t('general.tax.office')}</FieldLabel>
136
+ <Input id="taxOffice" aria-invalid={!!errors.taxOffice} {...register('taxOffice')} />
137
+ <FormFieldError error={errors.taxOffice} />
138
+ </Field>
139
+ <Field data-invalid={!!errors.taxNumber}>
140
+ <FieldLabel htmlFor="taxNumber">{t('general.tax.number')}</FieldLabel>
141
+ <Input
142
+ id="taxNumber"
143
+ inputMode="numeric"
144
+ maxLength={10}
145
+ className="tabular-nums"
146
+ aria-invalid={!!errors.taxNumber}
147
+ {...register('taxNumber')}
148
+ />
149
+ <FormFieldError error={errors.taxNumber} />
150
+ </Field>
151
+ </div>
152
+ </FormSection>
153
+
154
+ <FormSection title={t('general.address.title')} description={t('general.address.description')}>
155
+ <Field data-invalid={!!errors.address}>
156
+ <FieldLabel htmlFor="address">{t('general.address.label')}</FieldLabel>
157
+ <Textarea id="address" rows={3} autoComplete="street-address" aria-invalid={!!errors.address} {...register('address')} />
158
+ <FormFieldError error={errors.address} />
159
+ </Field>
160
+ </FormSection>
161
+
162
+ <FormSection title={t('general.contact.title')} description={t('general.contact.description')}>
163
+ <Field data-invalid={!!errors.contactEmail}>
164
+ <FieldLabel htmlFor="contactEmail">{t('general.contact.email')}</FieldLabel>
165
+ <Input id="contactEmail" type="email" aria-invalid={!!errors.contactEmail} {...register('contactEmail')} />
166
+ <FormFieldError error={errors.contactEmail} />
167
+ </Field>
168
+ <Field data-invalid={!!errors.phoneNumber}>
169
+ <FieldLabel htmlFor="phoneNumber">{t('general.contact.phone')}</FieldLabel>
170
+ <Controller
171
+ control={control}
172
+ name="phoneCountry"
173
+ render={({ field }) => (
174
+ <PhoneInput
175
+ id="phoneNumber"
176
+ country={field.value}
177
+ onCountryChange={field.onChange}
178
+ countryLabel={t('account:profile.contact.countryCode')}
179
+ invalid={!!errors.phoneNumber}
180
+ inputProps={register('phoneNumber')}
181
+ />
182
+ )}
183
+ />
184
+ <FormFieldError error={errors.phoneNumber} />
185
+ </Field>
186
+ </FormSection>
187
+
188
+ <FormSection title={t('general.locale.title')} description={t('general.locale.description')}>
189
+ <div className="grid gap-4 sm:grid-cols-3">
190
+ <SelectField control={control} name="defaultLanguage" label={t('general.locale.language')} items={languageItems} />
191
+ <SelectField control={control} name="timeZone" label={t('general.locale.timeZone')} items={zoneItems} />
192
+ <SelectField control={control} name="currency" label={t('general.locale.currency')} items={currencyItems} />
193
+ </div>
194
+ </FormSection>
195
+ </FormLayout>
196
+ </>
197
+ )
198
+ }
199
+
200
+ function SelectField({
201
+ control,
202
+ name,
203
+ label,
204
+ items,
205
+ }: {
206
+ control: ReturnType<typeof useForm<Values>>['control']
207
+ name: 'defaultLanguage' | 'timeZone' | 'currency'
208
+ label: string
209
+ items: { value: string; label: string }[]
210
+ }) {
211
+ return (
212
+ <Field>
213
+ <FieldLabel htmlFor={name}>{label}</FieldLabel>
214
+ <Controller
215
+ control={control}
216
+ name={name}
217
+ render={({ field }) => (
218
+ <Select items={items} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
219
+ <SelectTrigger id={name} className="w-full" onBlur={field.onBlur}>
220
+ <SelectValue />
221
+ </SelectTrigger>
222
+ <SelectContent>
223
+ {items.map((i) => (
224
+ <SelectItem key={i.value} value={i.value}>
225
+ {i.label}
226
+ </SelectItem>
227
+ ))}
228
+ </SelectContent>
229
+ </Select>
230
+ )}
231
+ />
232
+ </Field>
233
+ )
234
+ }
@@ -0,0 +1,170 @@
1
+ import { useState } from 'react'
2
+ import { useForm } from 'react-hook-form'
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { Copy, KeyRound, Plus } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import { z } from 'zod'
8
+
9
+ import { Button } from '@/components/ui/button'
10
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
11
+ import { Field, FieldLabel } from '@/components/ui/field'
12
+ import { Input } from '@/components/ui/input'
13
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
14
+ import { Spinner } from '@/components/ui/spinner'
15
+ import { useConfirm } from '@/core/components/confirm-dialog'
16
+ import { FormFieldError } from '@/core/components/form-error'
17
+ import { Panel } from '@/core/components/page'
18
+ import { EmptyState } from '@/core/components/states'
19
+ import { vmsg } from '@/core/i18n/keys'
20
+ import { useFormat } from '@/core/i18n/use-format'
21
+ import { useCreateApiKey, useIntegrationMutation } from './hooks'
22
+ import { integrationsService, type ApiKey } from './integrations.service'
23
+
24
+ export function ApiKeysPanel({ apiKeys }: { apiKeys: ApiKey[] }) {
25
+ const { t } = useTranslation(['company', 'common'])
26
+ const fmt = useFormat()
27
+ const confirm = useConfirm()
28
+ const [open, setOpen] = useState(false)
29
+ const revoke = useIntegrationMutation((id: string) => integrationsService.revokeApiKey(id))
30
+
31
+ async function onRevoke(key: ApiKey) {
32
+ const ok = await confirm({
33
+ title: t('integrations.apiKeys.revokeConfirm', { name: key.name }),
34
+ description: t('integrations.apiKeys.revokeDescription'),
35
+ confirmLabel: t('integrations.apiKeys.revoke'),
36
+ destructive: true,
37
+ })
38
+ if (!ok) return
39
+ await revoke.mutateAsync(key.id)
40
+ toast.success(t('integrations.apiKeys.revoked'))
41
+ }
42
+
43
+ return (
44
+ <Panel
45
+ label={t('integrations.apiKeys.title')}
46
+ actions={
47
+ <Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
48
+ <Plus data-icon="inline-start" />
49
+ {t('integrations.apiKeys.create')}
50
+ </Button>
51
+ }
52
+ >
53
+ <p className="border-b px-4 py-2.5 text-xs text-muted-foreground">{t('integrations.apiKeys.description')}</p>
54
+ {apiKeys.length === 0 ? (
55
+ <EmptyState icon={<KeyRound />} title={t('integrations.apiKeys.empty')} description={t('integrations.apiKeys.emptyDescription')} className="py-10" />
56
+ ) : (
57
+ <ul className="divide-y">
58
+ {apiKeys.map((k) => (
59
+ <li key={k.id} className="flex items-center gap-3 px-4 py-3">
60
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-fill-2 text-muted-foreground">
61
+ <KeyRound className="size-4" />
62
+ </span>
63
+ <div className="min-w-0 flex-1">
64
+ <p className="flex flex-wrap items-baseline gap-x-2 text-sm font-medium">
65
+ {k.name}
66
+ <span className="tabular-nums text-2xs font-normal text-muted-foreground">{k.prefix}…</span>
67
+ </p>
68
+ <p className="truncate text-xs text-muted-foreground">
69
+ {t('integrations.apiKeys.created', { when: fmt.date(k.createdAt) })} ·{' '}
70
+ {k.lastUsedAt
71
+ ? t('integrations.apiKeys.lastUsed', { when: fmt.relative(k.lastUsedAt) })
72
+ : t('integrations.apiKeys.neverUsed')}
73
+ </p>
74
+ </div>
75
+ <Button variant="destructive" size="sm" aria-label={t('integrations.apiKeys.revokeLabel', { name: k.name })} onClick={() => void onRevoke(k)}>
76
+ {t('integrations.apiKeys.revoke')}
77
+ </Button>
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ )}
82
+ <CreateKeyDialog open={open} onOpenChange={setOpen} />
83
+ </Panel>
84
+ )
85
+ }
86
+
87
+ const schema = z.object({
88
+ name: z
89
+ .string()
90
+ .trim()
91
+ .min(1, vmsg('validation.required'))
92
+ .max(60, vmsg('validation.maxChars', { max: 60 })),
93
+ })
94
+
95
+ function CreateKeyDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
96
+ const { t } = useTranslation(['company', 'common'])
97
+ const create = useCreateApiKey()
98
+ const [created, setCreated] = useState<string | null>(null)
99
+ const { register, handleSubmit, formState, reset } = useForm<{ name: string }>({ resolver: zodResolver(schema), defaultValues: { name: '' } })
100
+ const { errors } = formState
101
+
102
+ function close() {
103
+ onOpenChange(false)
104
+ setCreated(null)
105
+ reset()
106
+ }
107
+
108
+ const onSubmit = handleSubmit(async ({ name }) => {
109
+ const { key } = await create.mutateAsync(name)
110
+ setCreated(key)
111
+ })
112
+
113
+ const copy = () => {
114
+ void navigator.clipboard?.writeText(created ?? '')
115
+ toast.success(t('common:actions.copied'))
116
+ }
117
+
118
+ return (
119
+ <Dialog open={open} onOpenChange={(next) => !next && close()}>
120
+ <DialogContent showCloseButton={false} className="sm:max-w-md">
121
+ <DialogHeader>
122
+ <DialogTitle>{created ? t('integrations.apiKeys.dialog.createdTitle') : t('integrations.apiKeys.dialog.title')}</DialogTitle>
123
+ <DialogDescription>
124
+ {created ? t('integrations.apiKeys.dialog.createdDescription') : t('integrations.apiKeys.dialog.description')}
125
+ </DialogDescription>
126
+ </DialogHeader>
127
+ {created ? (
128
+ <InputGroup>
129
+ <InputGroupInput readOnly value={created} aria-label={t('integrations.apiKeys.dialog.keyLabel')} className="tabular-nums text-xs" onFocus={(e) => e.target.select()} />
130
+ <InputGroupAddon align="inline-end">
131
+ <InputGroupButton size="icon-xs" aria-label={t('common:actions.copy')} onClick={copy}>
132
+ <Copy />
133
+ </InputGroupButton>
134
+ </InputGroupAddon>
135
+ </InputGroup>
136
+ ) : (
137
+ <form id="api-key-form" onSubmit={onSubmit} noValidate>
138
+ <Field data-invalid={!!errors.name}>
139
+ <FieldLabel htmlFor="api-key-name">{t('integrations.apiKeys.dialog.name')}</FieldLabel>
140
+ <Input
141
+ id="api-key-name"
142
+ autoFocus
143
+ autoComplete="off"
144
+ placeholder={t('integrations.apiKeys.dialog.namePlaceholder')}
145
+ aria-invalid={!!errors.name}
146
+ {...register('name')}
147
+ />
148
+ <FormFieldError error={errors.name} />
149
+ </Field>
150
+ </form>
151
+ )}
152
+ <DialogFooter>
153
+ {created ? (
154
+ <Button onClick={close}>{t('common:actions.close')}</Button>
155
+ ) : (
156
+ <>
157
+ <Button variant="ghost" onClick={close}>
158
+ {t('common:actions.cancel')}
159
+ </Button>
160
+ <Button type="submit" form="api-key-form" disabled={formState.isSubmitting}>
161
+ {formState.isSubmitting && <Spinner data-icon="inline-start" />}
162
+ {t('integrations.apiKeys.dialog.submit')}
163
+ </Button>
164
+ </>
165
+ )}
166
+ </DialogFooter>
167
+ </DialogContent>
168
+ </Dialog>
169
+ )
170
+ }
@@ -0,0 +1,37 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+
3
+ import { auditKeys } from '@/core/audit/hooks'
4
+ import { integrationsService, type Integrations } from './integrations.service'
5
+
6
+ export const integrationKeys = { all: ['integrations'] as const }
7
+
8
+ export function useIntegrations() {
9
+ return useQuery({ queryKey: integrationKeys.all, queryFn: () => integrationsService.get() })
10
+ }
11
+
12
+ /** Every write returns the new state; store it and refresh the audit log. */
13
+ export function useIntegrationMutation<TVars>(fn: (vars: TVars) => Promise<Integrations>) {
14
+ const qc = useQueryClient()
15
+ return useMutation({
16
+ mutationFn: fn,
17
+ onSuccess: (data) => {
18
+ qc.setQueryData(integrationKeys.all, data)
19
+ return qc.invalidateQueries({ queryKey: auditKeys.all })
20
+ },
21
+ })
22
+ }
23
+
24
+ export function useCreateApiKey() {
25
+ const qc = useQueryClient()
26
+ return useMutation({
27
+ mutationFn: (name: string) => integrationsService.createApiKey(name),
28
+ onSuccess: ({ integrations }) => {
29
+ qc.setQueryData(integrationKeys.all, integrations)
30
+ return qc.invalidateQueries({ queryKey: auditKeys.all })
31
+ },
32
+ })
33
+ }
34
+
35
+ export function useSendTestEmail() {
36
+ return useMutation({ mutationFn: () => integrationsService.sendTestEmail() })
37
+ }
@@ -0,0 +1,57 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { useSearchParams } from 'react-router'
3
+
4
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
5
+ import { Page } from '@/core/components/page'
6
+ import { ErrorState, LoadingState } from '@/core/components/states'
7
+ import { ApiKeysPanel } from './api-keys-panel'
8
+ import { useIntegrations } from './hooks'
9
+ import { PaymentsForm, SmtpForm, StorageForm } from './settings-forms'
10
+ import { WebhooksPanel } from './webhooks-panel'
11
+
12
+ const tabs = ['smtp', 'storage', 'payments', 'webhooks', 'apiKeys'] as const
13
+ type Tab = (typeof tabs)[number]
14
+
15
+ export default function IntegrationsPage() {
16
+ const { t } = useTranslation('company')
17
+ const query = useIntegrations()
18
+ const [params, setParams] = useSearchParams()
19
+ const tab: Tab = (tabs as readonly string[]).includes(params.get('tab') ?? '') ? (params.get('tab') as Tab) : 'smtp'
20
+
21
+ return (
22
+ <Page title={t('integrations.title')} description={t('integrations.description')}>
23
+ {query.isPending ? (
24
+ <LoadingState />
25
+ ) : query.error ? (
26
+ <ErrorState error={query.error} onRetry={() => query.refetch()} />
27
+ ) : (
28
+ <Tabs value={tab} onValueChange={(v) => setParams(v === 'smtp' ? {} : { tab: String(v) }, { replace: true })}>
29
+ <div className="scrollbar-none -mx-1 overflow-x-auto px-1 pb-1">
30
+ <TabsList>
31
+ {tabs.map((id) => (
32
+ <TabsTrigger key={id} value={id}>
33
+ {t(`integrations.tabs.${id}`)}
34
+ </TabsTrigger>
35
+ ))}
36
+ </TabsList>
37
+ </div>
38
+ <TabsContent value="smtp" className="mt-4">
39
+ <SmtpForm value={query.data.smtp} />
40
+ </TabsContent>
41
+ <TabsContent value="storage" className="mt-4">
42
+ <StorageForm value={query.data.storage} />
43
+ </TabsContent>
44
+ <TabsContent value="payments" className="mt-4">
45
+ <PaymentsForm value={query.data.payments} />
46
+ </TabsContent>
47
+ <TabsContent value="webhooks" className="mt-4">
48
+ <WebhooksPanel webhooks={query.data.webhooks} />
49
+ </TabsContent>
50
+ <TabsContent value="apiKeys" className="mt-4">
51
+ <ApiKeysPanel apiKeys={query.data.apiKeys} />
52
+ </TabsContent>
53
+ </Tabs>
54
+ )}
55
+ </Page>
56
+ )
57
+ }
@@ -0,0 +1,153 @@
1
+ import { demoSeed } from '@/core/api/demo'
2
+ import { createMockDocument, delay } from '@/core/api/mock'
3
+ import { auditService } from '@/core/audit/audit.service'
4
+
5
+ /** Secrets are never sent back; this placeholder means "keep the stored value". */
6
+ export const SECRET_PLACEHOLDER = '••••••••'
7
+
8
+ export type SmtpSettings = {
9
+ host: string
10
+ port: number
11
+ encryption: 'none' | 'ssl' | 'tls'
12
+ username: string
13
+ password: string
14
+ fromName: string
15
+ fromEmail: string
16
+ }
17
+
18
+ export type StorageSettings = {
19
+ provider: 'local' | 's3' | 'r2' | 'gcs'
20
+ bucket: string
21
+ region: string
22
+ accessKeyId: string
23
+ secretAccessKey: string
24
+ }
25
+
26
+ export type PaymentSettings = {
27
+ provider: 'none' | 'iyzico' | 'stripe' | 'paytr'
28
+ mode: 'test' | 'live'
29
+ publicKey: string
30
+ secretKey: string
31
+ }
32
+
33
+ export const webhookEvents = ['product.created', 'product.updated', 'product.deleted', 'user.invited', 'user.deactivated'] as const
34
+ export type WebhookEvent = (typeof webhookEvents)[number]
35
+
36
+ export type Webhook = {
37
+ id: string
38
+ url: string
39
+ events: WebhookEvent[]
40
+ active: boolean
41
+ createdAt: string
42
+ lastDeliveryAt?: string
43
+ lastStatus?: 'success' | 'failed'
44
+ }
45
+
46
+ export type ApiKey = { id: string; name: string; prefix: string; createdAt: string; lastUsedAt?: string }
47
+
48
+ export type Integrations = {
49
+ smtp: SmtpSettings
50
+ storage: StorageSettings
51
+ payments: PaymentSettings
52
+ webhooks: Webhook[]
53
+ apiKeys: ApiKey[]
54
+ }
55
+
56
+ /**
57
+ * Third-party connections. A real backend stores secrets encrypted and only
58
+ * reports whether one is set (here: SECRET_PLACEHOLDER).
59
+ */
60
+ export interface IntegrationsService {
61
+ get(): Promise<Integrations>
62
+ updateSmtp(input: SmtpSettings): Promise<Integrations>
63
+ updateStorage(input: StorageSettings): Promise<Integrations>
64
+ updatePayments(input: PaymentSettings): Promise<Integrations>
65
+ sendTestEmail(): Promise<void>
66
+ createWebhook(input: { url: string; events: WebhookEvent[] }): Promise<Integrations>
67
+ setWebhookActive(id: string, active: boolean): Promise<Integrations>
68
+ deleteWebhook(id: string): Promise<Integrations>
69
+ /** Returns the full key once. */
70
+ createApiKey(name: string): Promise<{ key: string; integrations: Integrations }>
71
+ revokeApiKey(id: string): Promise<Integrations>
72
+ }
73
+
74
+ const blank = (): Integrations => ({
75
+ smtp: { host: '', port: 587, encryption: 'tls', username: '', password: '', fromName: '', fromEmail: '' },
76
+ storage: { provider: 'r2', bucket: '', region: '', accessKeyId: '', secretAccessKey: '' },
77
+ payments: { provider: 'iyzico', mode: 'test', publicKey: '', secretKey: '' },
78
+ webhooks: [],
79
+ apiKeys: [],
80
+ })
81
+
82
+ /** Replace secrets with the placeholder before returning (as an API would). */
83
+ function redact(value: Integrations): Integrations {
84
+ const mask = (s: string) => (s ? SECRET_PLACEHOLDER : '')
85
+ return {
86
+ ...value,
87
+ smtp: { ...value.smtp, password: mask(value.smtp.password) },
88
+ storage: { ...value.storage, secretAccessKey: mask(value.storage.secretAccessKey) },
89
+ payments: { ...value.payments, secretKey: mask(value.payments.secretKey) },
90
+ }
91
+ }
92
+
93
+ const keep = (next: string, prev: string) => (next === SECRET_PLACEHOLDER ? prev : next)
94
+
95
+ function createMockIntegrationsService(): IntegrationsService {
96
+ const doc = createMockDocument('integrations', () => demoSeed<() => Integrations>('integrations')?.() ?? blank())
97
+ const save = (patch: Partial<Integrations>) => redact(doc.save({ ...doc.get(), ...patch }))
98
+
99
+ return {
100
+ async get() {
101
+ await delay(150, 300)
102
+ return redact(doc.get())
103
+ },
104
+ async updateSmtp(smtp) {
105
+ await delay()
106
+ auditService.record('integrationUpdated', 'SMTP')
107
+ return save({ smtp: { ...smtp, password: keep(smtp.password, doc.get().smtp.password) } })
108
+ },
109
+ async updateStorage(storage) {
110
+ await delay()
111
+ auditService.record('integrationUpdated', 'Storage')
112
+ return save({ storage: { ...storage, secretAccessKey: keep(storage.secretAccessKey, doc.get().storage.secretAccessKey) } })
113
+ },
114
+ async updatePayments(payments) {
115
+ await delay()
116
+ auditService.record('integrationUpdated', 'Payments')
117
+ return save({ payments: { ...payments, secretKey: keep(payments.secretKey, doc.get().payments.secretKey) } })
118
+ },
119
+ async sendTestEmail() {
120
+ await delay(600, 1000)
121
+ },
122
+ async createWebhook({ url, events }) {
123
+ await delay()
124
+ auditService.record('webhookCreated', url)
125
+ const webhook: Webhook = { id: `wh_${Date.now().toString(36)}`, url, events, active: true, createdAt: new Date().toISOString() }
126
+ return save({ webhooks: [...doc.get().webhooks, webhook] })
127
+ },
128
+ async setWebhookActive(id, active) {
129
+ await delay(150, 300)
130
+ return save({ webhooks: doc.get().webhooks.map((w) => (w.id === id ? { ...w, active } : w)) })
131
+ },
132
+ async deleteWebhook(id) {
133
+ await delay()
134
+ auditService.record('webhookDeleted', doc.get().webhooks.find((w) => w.id === id)?.url)
135
+ return save({ webhooks: doc.get().webhooks.filter((w) => w.id !== id) })
136
+ },
137
+ async createApiKey(name) {
138
+ await delay()
139
+ const random = Array.from(crypto.getRandomValues(new Uint8Array(24)), (b) => b.toString(36).padStart(2, '0')).join('').slice(0, 40)
140
+ const key = `fp_live_${random}`
141
+ auditService.record('apiKeyCreated', name)
142
+ const apiKey: ApiKey = { id: `key_${Date.now().toString(36)}`, name, prefix: key.slice(0, 12), createdAt: new Date().toISOString() }
143
+ return { key, integrations: save({ apiKeys: [...doc.get().apiKeys, apiKey] }) }
144
+ },
145
+ async revokeApiKey(id) {
146
+ await delay()
147
+ auditService.record('apiKeyRevoked', doc.get().apiKeys.find((k) => k.id === id)?.name)
148
+ return save({ apiKeys: doc.get().apiKeys.filter((k) => k.id !== id) })
149
+ },
150
+ }
151
+ }
152
+
153
+ export const integrationsService: IntegrationsService = createMockIntegrationsService()