@seedgrid/fe-security 2026.7.0 → 2026.7.2

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 (125) hide show
  1. package/README.md +113 -0
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +5 -0
  5. package/dist/client/permission-provider.d.ts +18 -0
  6. package/dist/client/permission-provider.d.ts.map +1 -0
  7. package/dist/client/permission-provider.js +57 -0
  8. package/dist/client/pick-list-field.d.ts +15 -0
  9. package/dist/client/pick-list-field.d.ts.map +1 -0
  10. package/dist/client/pick-list-field.js +30 -0
  11. package/dist/client/status-badge.d.ts +6 -0
  12. package/dist/client/status-badge.d.ts.map +1 -0
  13. package/dist/client/status-badge.js +6 -0
  14. package/dist/config.d.ts +34 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +4 -0
  17. package/dist/dtos.d.ts +88 -0
  18. package/dist/dtos.d.ts.map +1 -0
  19. package/dist/dtos.js +3 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +10 -0
  23. package/dist/menu.d.ts +19 -0
  24. package/dist/menu.d.ts.map +1 -0
  25. package/dist/menu.js +41 -0
  26. package/dist/messages.d.ts +1858 -0
  27. package/dist/messages.d.ts.map +1 -0
  28. package/dist/messages.js +990 -0
  29. package/dist/module.d.ts +3 -0
  30. package/dist/module.d.ts.map +1 -0
  31. package/dist/module.js +37 -0
  32. package/dist/module.json +21 -0
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.d.ts.map +1 -1
  35. package/dist/paths.js +3 -0
  36. package/dist/permissions.d.ts +14 -0
  37. package/dist/permissions.d.ts.map +1 -0
  38. package/dist/permissions.js +39 -0
  39. package/dist/server/action.d.ts +3 -0
  40. package/dist/server/action.d.ts.map +1 -0
  41. package/dist/server/action.js +21 -0
  42. package/dist/server/create-security-server.d.ts +4 -0
  43. package/dist/server/create-security-server.d.ts.map +1 -0
  44. package/dist/server/create-security-server.js +166 -0
  45. package/dist/server/errors.d.ts +13 -0
  46. package/dist/server/errors.d.ts.map +1 -0
  47. package/dist/server/errors.js +26 -0
  48. package/dist/server/index.d.ts +8 -0
  49. package/dist/server/index.d.ts.map +1 -0
  50. package/dist/server/index.js +8 -0
  51. package/dist/server/problem-response.d.ts +3 -0
  52. package/dist/server/problem-response.d.ts.map +1 -0
  53. package/dist/server/problem-response.js +28 -0
  54. package/dist/server/route.d.ts +3 -0
  55. package/dist/server/route.d.ts.map +1 -0
  56. package/dist/server/route.js +22 -0
  57. package/dist/server/to-problem.d.ts +3 -0
  58. package/dist/server/to-problem.d.ts.map +1 -0
  59. package/dist/server/to-problem.js +22 -0
  60. package/dist/server-contract.d.ts +23 -0
  61. package/dist/server-contract.d.ts.map +1 -0
  62. package/dist/server-contract.js +4 -0
  63. package/dist/session-contract.d.ts +36 -0
  64. package/dist/session-contract.d.ts.map +1 -0
  65. package/dist/session-contract.js +8 -0
  66. package/dist/session-result.d.ts +22 -0
  67. package/dist/session-result.d.ts.map +1 -0
  68. package/dist/session-result.js +14 -0
  69. package/package.json +38 -3
  70. package/scaffold/README.md +97 -0
  71. package/scaffold/src/app/api/security/session/2fa/resend/route.ts +8 -0
  72. package/scaffold/src/app/api/security/session/2fa/verify/route.ts +8 -0
  73. package/scaffold/src/app/api/security/session/login/route.ts +7 -0
  74. package/scaffold/src/app/api/security/session/logout/route.ts +5 -0
  75. package/scaffold/src/app/api/security/session/refresh/route.ts +7 -0
  76. package/scaffold/src/app/api/security/users/[publicId]/avatar/route.ts +15 -0
  77. package/scaffold/src/app/auth/2fa/verify/page.tsx +286 -0
  78. package/scaffold/src/app/auth/forgot-password/confirm/page.tsx +96 -0
  79. package/scaffold/src/app/auth/forgot-password/page.tsx +165 -0
  80. package/scaffold/src/app/auth/forgot-password/success/page.tsx +31 -0
  81. package/scaffold/src/app/auth/login/page.tsx +386 -0
  82. package/scaffold/src/app/auth/welcome/page.tsx +90 -0
  83. package/scaffold/src/app/login/page.tsx +7 -0
  84. package/scaffold/src/app/page.tsx +12 -0
  85. package/scaffold/src/app/security/2fa/page.tsx +370 -0
  86. package/scaffold/src/app/security/companies/(create)/new/page.tsx +12 -0
  87. package/scaffold/src/app/security/companies/(edit)/[publicId]/page.tsx +18 -0
  88. package/scaffold/src/app/security/companies/(list)/page.tsx +298 -0
  89. package/scaffold/src/app/security/companies/editors/CompanyEditor.tsx +754 -0
  90. package/scaffold/src/app/security/profile/page.tsx +492 -0
  91. package/scaffold/src/app/security/roles/(create)/new/page.tsx +11 -0
  92. package/scaffold/src/app/security/roles/(edit)/[publicId]/page.tsx +17 -0
  93. package/scaffold/src/app/security/roles/(list)/page.tsx +269 -0
  94. package/scaffold/src/app/security/roles/editors/RoleEditor.tsx +328 -0
  95. package/scaffold/src/app/security/users/(create)/new/page.tsx +10 -0
  96. package/scaffold/src/app/security/users/(edit)/[publicId]/page.tsx +16 -0
  97. package/scaffold/src/app/security/users/(list)/page.tsx +289 -0
  98. package/scaffold/src/app/security/users/editors/UserEditor.tsx +478 -0
  99. package/scaffold/src/app/users/change-password/page.tsx +172 -0
  100. package/scaffold/src/modules/security/AuthProvider.tsx +405 -0
  101. package/scaffold/src/modules/security/components/AppShell.tsx.hbs +889 -0
  102. package/scaffold/src/modules/security/components/CompanyManagementGuard.tsx +59 -0
  103. package/scaffold/src/modules/security/components/PageFrame.tsx +105 -0
  104. package/scaffold/src/modules/security/components/RouteGuard.tsx +114 -0
  105. package/scaffold/src/modules/security/components/SecurityDashboard.tsx.hbs +315 -0
  106. package/scaffold/src/modules/security/config.ts +55 -0
  107. package/scaffold/src/modules/security/hooks/useAuth.ts +7 -0
  108. package/scaffold/src/modules/security/hooks/useDebouncedSearch.ts +13 -0
  109. package/scaffold/src/modules/security/hooks/usePermission.ts +35 -0
  110. package/scaffold/src/modules/security/host-access.ts +59 -0
  111. package/scaffold/src/modules/security/index.ts +44 -0
  112. package/scaffold/src/modules/security/navigation.ts +41 -0
  113. package/scaffold/src/modules/security/server/avatar-routes.ts +86 -0
  114. package/scaffold/src/modules/security/server/public-api-base-url.ts +26 -0
  115. package/scaffold/src/modules/security/server/session-routes.ts.hbs +322 -0
  116. package/scaffold/src/modules/security/server/session-server.ts.hbs +30 -0
  117. package/scaffold/src/modules/security/services/auth-service.ts +332 -0
  118. package/scaffold/src/modules/security/services/security-api.test.ts +178 -0
  119. package/scaffold/src/modules/security/services/security-api.ts +281 -0
  120. package/scaffold/src/modules/security/services/security-service.test.ts +71 -0
  121. package/scaffold/src/modules/security/services/security-service.ts +727 -0
  122. package/scaffold/src/modules/security/services/session-storage.ts +80 -0
  123. package/scaffold/src/modules/security/types.ts +295 -0
  124. package/scaffold/src/modules/security/useSecurityAppShellSections.tsx.hbs +180 -0
  125. package/scaffold/src/modules/security/utils.ts +194 -0
@@ -0,0 +1,478 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgConfirmationDialog,
8
+ SgGrid,
9
+ SgInputEmail,
10
+ SgInputPassword,
11
+ SgInputPhone,
12
+ SgStack,
13
+ SgInputText,
14
+ SgToggleSwitch,
15
+ sgWhistle,
16
+ } from "@seedgrid/fe-components";
17
+ import { useRouter } from "next/navigation";
18
+ import { useForm } from "react-hook-form";
19
+
20
+ import { useI18n } from "@/i18n";
21
+ import {
22
+ InlineNotice,
23
+ PageFrame,
24
+ PickListField,
25
+ extractApiMessage,
26
+ securityConfig,
27
+ securityService,
28
+ useCompanyManagementEnabledForCurrentHost,
29
+ useCrudPermission,
30
+ type CompanySummary,
31
+ type RoleSummary,
32
+ type UserSummary,
33
+ } from "@/modules/security";
34
+ import {
35
+ extractSelectionValues,
36
+ extractFirstFormErrorMessage,
37
+ resolveSelectedNumericIds,
38
+ showFormValidationWhistle,
39
+ toPickListOption,
40
+ } from "@/modules/security/utils";
41
+
42
+ type UserEditorProps = {
43
+ publicId?: string;
44
+ };
45
+
46
+ type UserFormValues = {
47
+ email: string;
48
+ name: string;
49
+ password: string;
50
+ whatsApp: string;
51
+ active: boolean;
52
+ changePasswordRequired: boolean;
53
+ roleValues: Array<string | number>;
54
+ companyValues: Array<string | number>;
55
+ };
56
+
57
+ const EMPTY_FORM: UserFormValues = {
58
+ email: "",
59
+ name: "",
60
+ password: "",
61
+ whatsApp: "",
62
+ active: true,
63
+ changePasswordRequired: true,
64
+ roleValues: [],
65
+ companyValues: [],
66
+ };
67
+
68
+ function hasAtLeastTwoWords(value: string) {
69
+ return value.trim().split(/\s+/).filter(Boolean).length >= 2;
70
+ }
71
+
72
+ export function UserEditor({ publicId }: UserEditorProps) {
73
+ const router = useRouter();
74
+ const { t } = useI18n();
75
+ const permissions = useCrudPermission("USER");
76
+ const companiesEnabled = useCompanyManagementEnabledForCurrentHost();
77
+ const isEditing = Boolean(publicId);
78
+ const isReadOnly = isEditing
79
+ ? permissions.canRead && !permissions.canUpdate
80
+ : !permissions.canCreate;
81
+ const [roles, setRoles] = useState<RoleSummary[]>([]);
82
+ const [companies, setCompanies] = useState<CompanySummary[]>([]);
83
+ const [loading, setLoading] = useState(true);
84
+ const [saving, setSaving] = useState(false);
85
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
86
+ const [loadError, setLoadError] = useState<string | null>(null);
87
+ const {
88
+ control,
89
+ handleSubmit,
90
+ register,
91
+ reset,
92
+ setValue,
93
+ watch,
94
+ trigger,
95
+ } = useForm<UserFormValues>({
96
+ defaultValues: EMPTY_FORM,
97
+ });
98
+ const formValues = watch();
99
+
100
+ useEffect(() => {
101
+ if (companiesEnabled == null) {
102
+ return;
103
+ }
104
+
105
+ let active = true;
106
+ setLoading(true);
107
+
108
+ void (async () => {
109
+ try {
110
+ const [loadedRoles, loadedCompanies, loadedUser] = await Promise.all([
111
+ securityService.listRoles(),
112
+ companiesEnabled
113
+ ? securityService.listAllCompanies()
114
+ : Promise.resolve([]),
115
+ publicId ? securityService.getUser(publicId) : Promise.resolve(null),
116
+ ]);
117
+
118
+ if (!active) {
119
+ return;
120
+ }
121
+
122
+ setRoles(loadedRoles);
123
+ setCompanies(loadedCompanies);
124
+
125
+ if (loadedUser) {
126
+ applyUser(loadedUser);
127
+ } else {
128
+ reset(EMPTY_FORM);
129
+ }
130
+ } catch (error) {
131
+ if (!active) {
132
+ return;
133
+ }
134
+
135
+ setLoadError(
136
+ extractApiMessage(error) ?? t("security.users.form.load_error")
137
+ );
138
+ } finally {
139
+ if (active) {
140
+ setLoading(false);
141
+ }
142
+ }
143
+ })();
144
+
145
+ return () => {
146
+ active = false;
147
+ };
148
+ }, [companiesEnabled, publicId, reset, t]);
149
+
150
+ const roleOptions = useMemo(
151
+ () =>
152
+ roles.map((role) =>
153
+ toPickListOption(
154
+ role.id ?? role.publicId,
155
+ role.description ? `${role.name} - ${role.description}` : role.name,
156
+ role
157
+ )
158
+ ),
159
+ [roles]
160
+ );
161
+ const companyOptions = useMemo(
162
+ () =>
163
+ companies.map((company) =>
164
+ toPickListOption(
165
+ company.id ?? company.publicId,
166
+ company.tradeName ?? company.corporateName ?? company.publicId,
167
+ company
168
+ )
169
+ ),
170
+ [companies]
171
+ );
172
+
173
+ function applyUser(user: UserSummary) {
174
+ reset({
175
+ email: user.email ?? "",
176
+ name: user.name ?? "",
177
+ password: "",
178
+ whatsApp: user.whatsApp ?? "",
179
+ active: Boolean(user.active),
180
+ changePasswordRequired: Boolean(user.changePasswordRequired),
181
+ roleValues: extractSelectionValues(user.roles),
182
+ companyValues: user.companies?.length
183
+ ? extractSelectionValues(user.companies)
184
+ : user.companyIds?.length
185
+ ? user.companyIds
186
+ : user.companyPublicIds?.length
187
+ ? user.companyPublicIds
188
+ : [],
189
+ });
190
+ }
191
+
192
+ async function handleValidSubmit(values: UserFormValues) {
193
+ if (isReadOnly) {
194
+ return;
195
+ }
196
+
197
+ const roleIds = resolveSelectedNumericIds(roles, values.roleValues);
198
+ const companyIds = companiesEnabled
199
+ ? resolveSelectedNumericIds(companies, values.companyValues)
200
+ : [];
201
+
202
+ if (!roleIds || (companiesEnabled && !companyIds)) {
203
+ sgWhistle.error({ message: t("security.common.missing_numeric_ids") });
204
+ return;
205
+ }
206
+
207
+ setSaving(true);
208
+
209
+ try {
210
+ const payload = {
211
+ email: values.email.trim(),
212
+ name: values.name.trim(),
213
+ password: values.password.trim() || undefined,
214
+ whatsApp: values.whatsApp.trim() || undefined,
215
+ roleIds,
216
+ // `?? []` so pra fechar o tipo (number[], nao number[] | null): o guard
217
+ // acima ja retornou cedo quando companyIds era null com companies
218
+ // habilitado; com companies desabilitado ele ja e []. TS nao estreita
219
+ // pela condicao composta, dai o coerce (nunca substitui em runtime).
220
+ companyIds: companyIds ?? [],
221
+ active: values.active,
222
+ changePasswordRequired: values.changePasswordRequired,
223
+ };
224
+
225
+ const savedUser = publicId
226
+ ? await securityService.updateUser(publicId, payload)
227
+ : await securityService.createUser(payload);
228
+
229
+ sgWhistle.success({ message: t("security.users.form.save_success") });
230
+ router.push(`${securityConfig.routes.users}/${savedUser.publicId}`);
231
+ } catch (error) {
232
+ sgWhistle.error({
233
+ message: extractApiMessage(error) ?? t("security.users.form.save_error"),
234
+ });
235
+ } finally {
236
+ setSaving(false);
237
+ }
238
+ }
239
+
240
+ function handleInvalidSubmit(errors: unknown) {
241
+ const message =
242
+ extractFirstFormErrorMessage(errors) ?? t("security.common.field_required");
243
+
244
+ showFormValidationWhistle("security-user-form-validation", message);
245
+ }
246
+
247
+ async function handleDelete() {
248
+ if (!publicId) {
249
+ return;
250
+ }
251
+
252
+ try {
253
+ await securityService.deleteUser(publicId);
254
+ sgWhistle.success({ message: t("security.users.form.delete_success") });
255
+ router.push(securityConfig.routes.users);
256
+ } catch (error) {
257
+ sgWhistle.error({
258
+ message: extractApiMessage(error) ?? t("security.users.list.delete_error"),
259
+ });
260
+ } finally {
261
+ setDeleteDialogOpen(false);
262
+ }
263
+ }
264
+
265
+ return (
266
+ <PageFrame
267
+ title={
268
+ isEditing
269
+ ? t("security.users.form.title_edit")
270
+ : t("security.users.form.title_new")
271
+ }
272
+ description={
273
+ isEditing
274
+ ? t("security.users.form.description_edit")
275
+ : t("security.users.form.description_new")
276
+ }
277
+ readonlyLabel={isReadOnly ? t("security.common.readonly") : null}
278
+ actions={
279
+ <SgStack direction="row" wrap gap={8}>
280
+ <SgButton
281
+ appearance="outline"
282
+ severity="primary"
283
+ shape="rounded"
284
+ label={t("security.common.back")}
285
+ onClick={() => {
286
+ router.push(securityConfig.routes.users);
287
+ }}
288
+ />
289
+ {isEditing && permissions.canDelete ? (
290
+ <SgButton
291
+ appearance="outline"
292
+ severity="danger"
293
+ shape="rounded"
294
+ label={t("security.common.delete")}
295
+ onClick={() => {
296
+ setDeleteDialogOpen(true);
297
+ }}
298
+ />
299
+ ) : null}
300
+ </SgStack>
301
+ }
302
+ >
303
+ {loadError ? <InlineNotice tone="danger">{loadError}</InlineNotice> : null}
304
+
305
+ {companiesEnabled == null || loading ? (
306
+ <InlineNotice>{t("security.common.loading")}</InlineNotice>
307
+ ) : (
308
+ <form
309
+ noValidate
310
+ onSubmit={handleSubmit(handleValidSubmit, handleInvalidSubmit)}
311
+ >
312
+ <SgStack gap={24}>
313
+ <SgGrid minItemWidth={280} gap={16}>
314
+ <SgInputEmail
315
+ id="security-user-email"
316
+ name="email"
317
+ label={t("security.users.form.email")}
318
+ register={register}
319
+ required
320
+ rules={ {
321
+ required: t("security.common.field_required"),
322
+ }}
323
+ readOnly={isReadOnly}
324
+ />
325
+ <SgInputText
326
+ id="security-user-name"
327
+ name="name"
328
+ label={t("security.users.form.name")}
329
+ register={register}
330
+ required
331
+ minNumberOfWords={2}
332
+ minNumberOfWordsMessage={t("security.users.form.name_min_words")}
333
+ rules={ {
334
+ required: t("security.common.field_required"),
335
+ validate: (value) =>
336
+ hasAtLeastTwoWords(String(value))
337
+ ? true
338
+ : t("security.users.form.name_min_words"),
339
+ }}
340
+ readOnly={isReadOnly}
341
+ />
342
+ {!isEditing ? (
343
+ <SgInputPassword
344
+ id="security-user-password"
345
+ name="password"
346
+ label={t("security.users.form.password")}
347
+ register={register}
348
+ rules={ {
349
+ required: t("security.common.field_required"),
350
+ }}
351
+ readOnly={isReadOnly}
352
+ />
353
+ ) : null}
354
+ <SgInputPhone
355
+ id="security-user-whatsapp"
356
+ name="whatsApp"
357
+ label={t("security.users.form.whatsapp")}
358
+ register={register}
359
+ readOnly={isReadOnly}
360
+ hintText="(11) 99999-0000"
361
+ />
362
+ </SgGrid>
363
+
364
+ <SgGrid minItemWidth={280} gap={16}>
365
+ <SgToggleSwitch
366
+ id="security-user-active"
367
+ name="active"
368
+ label={t("security.users.form.active")}
369
+ control={control}
370
+ readOnly={isReadOnly}
371
+ />
372
+ <SgToggleSwitch
373
+ id="security-user-change-password-required"
374
+ name="changePasswordRequired"
375
+ label={t("security.users.form.change_password_required")}
376
+ control={control}
377
+ readOnly={isReadOnly}
378
+ />
379
+ </SgGrid>
380
+
381
+ <SgGrid minItemWidth={320} gap={24}>
382
+ <SgStack gap={12}>
383
+ <PickListField
384
+ id="security-user-roles"
385
+ title={t("security.users.form.roles")}
386
+ sourceHeader={t("security.users.form.roles_available")}
387
+ targetHeader={t("security.users.form.roles_selected")}
388
+ options={roleOptions}
389
+ selectedValues={formValues.roleValues ?? []}
390
+ onChange={(values) => {
391
+ setValue("roleValues", values, {
392
+ shouldDirty: true,
393
+ shouldTouch: true,
394
+ shouldValidate: true,
395
+ });
396
+ void trigger("roleValues");
397
+ }}
398
+ readOnly={isReadOnly}
399
+ emptyMessage={t("security.common.no_results")}
400
+ />
401
+ </SgStack>
402
+
403
+ {companiesEnabled ? (
404
+ <SgStack gap={12}>
405
+ <PickListField
406
+ id="security-user-companies"
407
+ title={t("security.users.form.companies")}
408
+ sourceHeader={t("security.users.form.companies_available")}
409
+ targetHeader={t("security.users.form.companies_selected")}
410
+ options={companyOptions}
411
+ selectedValues={formValues.companyValues ?? []}
412
+ onChange={(values) => {
413
+ setValue("companyValues", values, {
414
+ shouldDirty: true,
415
+ shouldTouch: true,
416
+ shouldValidate: true,
417
+ });
418
+ void trigger("companyValues");
419
+ }}
420
+ readOnly={isReadOnly}
421
+ emptyMessage={t("security.common.no_results")}
422
+ />
423
+ </SgStack>
424
+ ) : null}
425
+ </SgGrid>
426
+
427
+ <input
428
+ type="hidden"
429
+ {...register("roleValues", {
430
+ validate: (value) =>
431
+ Array.isArray(value) && value.length > 0
432
+ ? true
433
+ : t("security.users.form.roles_required"),
434
+ })}
435
+ />
436
+ {companiesEnabled ? (
437
+ <input
438
+ type="hidden"
439
+ {...register("companyValues", {
440
+ validate: (value) =>
441
+ Array.isArray(value) && value.length > 0
442
+ ? true
443
+ : t("security.users.form.companies_required"),
444
+ })}
445
+ />
446
+ ) : null}
447
+
448
+ {!isReadOnly ? (
449
+ <SgStack direction="row" justify="end">
450
+ <SgButton
451
+ appearance="solid"
452
+ severity="primary"
453
+ shape="rounded"
454
+ label={t("security.common.save")}
455
+ loading={saving}
456
+ disabled={saving}
457
+ type="submit"
458
+ />
459
+ </SgStack>
460
+ ) : null}
461
+ </SgStack>
462
+ </form>
463
+ )}
464
+
465
+ <SgConfirmationDialog
466
+ open={deleteDialogOpen}
467
+ severity="danger"
468
+ title={t("security.users.list.delete_title")}
469
+ message={t("security.users.list.delete_message")}
470
+ onCancel={() => {
471
+ setDeleteDialogOpen(false);
472
+ }}
473
+ onConfirm={handleDelete}
474
+ />
475
+ </PageFrame>
476
+ );
477
+ }
478
+
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgInputPassword,
8
+ SgPanel,
9
+ SgStack,
10
+ toast,
11
+ } from "@seedgrid/fe-components";
12
+ import { useRouter } from "next/navigation";
13
+ import { type FieldErrors, useForm } from "react-hook-form";
14
+
15
+ import { useI18n } from "@/i18n";
16
+ import {
17
+ PageFrame,
18
+ RouteGuard,
19
+ extractApiMessage,
20
+ securityConfig,
21
+ securityService,
22
+ useAuth,
23
+ } from "@/modules/security";
24
+
25
+ type ChangePasswordFormValues = {
26
+ currentPassword: string;
27
+ newPassword: string;
28
+ confirmPassword: string;
29
+ };
30
+
31
+ export default function ChangePasswordPage() {
32
+ return (
33
+ <RouteGuard permissions={["USER_CHANGE_PASSWORD"]}>
34
+ <ChangePasswordScreen />
35
+ </RouteGuard>
36
+ );
37
+ }
38
+
39
+ function ChangePasswordScreen() {
40
+ const { t } = useI18n();
41
+ const router = useRouter();
42
+ const auth = useAuth();
43
+ const [pending, setPending] = useState(false);
44
+ const { handleSubmit, register, reset } = useForm<ChangePasswordFormValues>({
45
+ defaultValues: {
46
+ currentPassword: "",
47
+ newPassword: "",
48
+ confirmPassword: "",
49
+ },
50
+ });
51
+ const currentPasswordRules = {
52
+ required: t("security.change_password.validation.current_password_required"),
53
+ };
54
+ const newPasswordRules = {
55
+ required: t("security.change_password.validation.new_password_required"),
56
+ };
57
+ const confirmPasswordRules = {
58
+ required: t("security.change_password.validation.confirm_password_required"),
59
+ };
60
+ const currentPasswordInputProps = {
61
+ autoComplete: "current-password",
62
+ };
63
+ const newPasswordInputProps = {
64
+ autoComplete: "new-password",
65
+ };
66
+ async function handleChangePassword(values: ChangePasswordFormValues) {
67
+ if (values.newPassword !== values.confirmPassword) {
68
+ toast.error(t("security.change_password.mismatch"));
69
+ return;
70
+ }
71
+
72
+ setPending(true);
73
+
74
+ try {
75
+ await securityService.changePassword({
76
+ currentPassword: values.currentPassword,
77
+ newPassword: values.newPassword,
78
+ confirmNewPassword: values.confirmPassword,
79
+ });
80
+ toast.success(t("security.change_password.success"));
81
+ reset();
82
+
83
+ if (auth.session?.changePasswordRequired) {
84
+ await auth.logout();
85
+ router.push(securityConfig.routes.login);
86
+ return;
87
+ }
88
+ } catch (error) {
89
+ toast.error(
90
+ extractApiMessage(error) ?? t("security.change_password.error")
91
+ );
92
+ } finally {
93
+ setPending(false);
94
+ }
95
+ }
96
+
97
+ function handleInvalidSubmit(errors: FieldErrors<ChangePasswordFormValues>) {
98
+ const firstMessage =
99
+ errors.currentPassword?.message ??
100
+ errors.newPassword?.message ??
101
+ errors.confirmPassword?.message;
102
+
103
+ if (firstMessage) {
104
+ toast.error(firstMessage);
105
+ }
106
+ }
107
+
108
+ return (
109
+ <PageFrame
110
+ title={t("security.change_password.title")}
111
+ description={t("security.change_password.description")}
112
+ >
113
+ <SgPanel borderStyle="none" className="mx-auto w-full max-w-xl pt-4">
114
+ <form
115
+ noValidate
116
+ onSubmit={handleSubmit(handleChangePassword, handleInvalidSubmit)}
117
+ >
118
+ <SgStack gap={16}>
119
+ <SgInputPassword
120
+ id="security-change-password-current"
121
+ name="currentPassword"
122
+ label={t("security.change_password.current_password")}
123
+ register={register}
124
+ required
125
+ rules={currentPasswordRules}
126
+ minSize={0}
127
+ showStrengthBar={false}
128
+ commonPasswordCheck={false}
129
+ upperRequired={false}
130
+ lowerRequired={false}
131
+ numberRequired={false}
132
+ specialCharacterRequired={false}
133
+ prohibitsRepeatedCharactersInSequence={false}
134
+ prohibitsSequentialAscCharacters={false}
135
+ prohibitsSequentialDescCharacters={false}
136
+ inputProps={currentPasswordInputProps}
137
+ />
138
+ <SgInputPassword
139
+ id="security-change-password-new"
140
+ name="newPassword"
141
+ label={t("security.change_password.new_password")}
142
+ register={register}
143
+ required
144
+ rules={newPasswordRules}
145
+ inputProps={newPasswordInputProps}
146
+ />
147
+ <SgInputPassword
148
+ id="security-change-password-confirm"
149
+ name="confirmPassword"
150
+ label={t("security.change_password.confirm_password")}
151
+ register={register}
152
+ required
153
+ rules={confirmPasswordRules}
154
+ inputProps={newPasswordInputProps}
155
+ />
156
+ <SgStack direction="row" justify="end">
157
+ <SgButton
158
+ appearance="solid"
159
+ severity="primary"
160
+ shape="rounded"
161
+ label={t("security.change_password.submit")}
162
+ loading={pending}
163
+ disabled={pending}
164
+ type="submit"
165
+ />
166
+ </SgStack>
167
+ </SgStack>
168
+ </form>
169
+ </SgPanel>
170
+ </PageFrame>
171
+ );
172
+ }