rl-core-front 0.1.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 (112) hide show
  1. package/README.md +129 -0
  2. package/package.json +113 -0
  3. package/src/_services/api/axios.factory.ts +97 -0
  4. package/src/_services/api/list-query.ts +26 -0
  5. package/src/_services/api/schema.d.ts +2527 -0
  6. package/src/_services/auth/index.ts +127 -0
  7. package/src/_utils/advanced-filter.ts +386 -0
  8. package/src/_utils/calendar.ts +103 -0
  9. package/src/_utils/crop-image.ts +65 -0
  10. package/src/_utils/filter.ts +313 -0
  11. package/src/_utils/format.ts +35 -0
  12. package/src/_utils/initials.ts +24 -0
  13. package/src/_utils/password.ts +7 -0
  14. package/src/components/app-shell.tsx +314 -0
  15. package/src/components/breadcrumbs.tsx +55 -0
  16. package/src/components/index.ts +7 -0
  17. package/src/components/language-selector.tsx +51 -0
  18. package/src/components/password-field.tsx +40 -0
  19. package/src/components/theme-toggle.tsx +27 -0
  20. package/src/components/ui/alert.tsx +27 -0
  21. package/src/components/ui/avatar.tsx +50 -0
  22. package/src/components/ui/badge.tsx +36 -0
  23. package/src/components/ui/button.tsx +62 -0
  24. package/src/components/ui/card.tsx +86 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/column-visibility-modal.tsx +110 -0
  27. package/src/components/ui/confirm-dialog.tsx +69 -0
  28. package/src/components/ui/data-table.tsx +401 -0
  29. package/src/components/ui/date-range-picker.tsx +250 -0
  30. package/src/components/ui/dialog.tsx +138 -0
  31. package/src/components/ui/dropdown-menu.tsx +108 -0
  32. package/src/components/ui/field.tsx +20 -0
  33. package/src/components/ui/filter-chips.tsx +88 -0
  34. package/src/components/ui/filter-field.tsx +137 -0
  35. package/src/components/ui/filter-group.tsx +155 -0
  36. package/src/components/ui/filter-rule.tsx +221 -0
  37. package/src/components/ui/filter-sheet.tsx +201 -0
  38. package/src/components/ui/index.ts +30 -0
  39. package/src/components/ui/input.tsx +25 -0
  40. package/src/components/ui/label.tsx +23 -0
  41. package/src/components/ui/row-actions.tsx +32 -0
  42. package/src/components/ui/select.tsx +97 -0
  43. package/src/components/ui/separator.tsx +31 -0
  44. package/src/components/ui/sheet.tsx +119 -0
  45. package/src/components/ui/sonner.tsx +28 -0
  46. package/src/components/ui/spinner.tsx +12 -0
  47. package/src/components/ui/switch.tsx +29 -0
  48. package/src/components/ui/table.tsx +84 -0
  49. package/src/components/ui/tabs.tsx +52 -0
  50. package/src/components/user-avatar.tsx +54 -0
  51. package/src/contexts/auth-context.tsx +90 -0
  52. package/src/contexts/color-mode-context.tsx +105 -0
  53. package/src/contexts/i18n-context.tsx +76 -0
  54. package/src/contexts/index.ts +6 -0
  55. package/src/contexts/socket-context.tsx +153 -0
  56. package/src/contexts/toast-context.tsx +40 -0
  57. package/src/features/audit/audit-screen.tsx +299 -0
  58. package/src/features/audit/hooks/use-audit-trail.ts +68 -0
  59. package/src/features/audit/services/audit.service.ts +24 -0
  60. package/src/features/dashboard/dashboard-screen.tsx +73 -0
  61. package/src/features/login/components/backup-codes-dialog.tsx +66 -0
  62. package/src/features/login/components/index.ts +5 -0
  63. package/src/features/login/components/login-form.tsx +62 -0
  64. package/src/features/login/components/two-factor-setup.tsx +71 -0
  65. package/src/features/login/components/two-factor-verify.tsx +56 -0
  66. package/src/features/login/hooks/use-login-flow.ts +119 -0
  67. package/src/features/login/login-screen.tsx +83 -0
  68. package/src/features/login/validation/schemas.ts +30 -0
  69. package/src/features/logs/hooks/use-error-logs.ts +65 -0
  70. package/src/features/logs/hooks/use-request-logs.ts +65 -0
  71. package/src/features/logs/logs-screen.tsx +31 -0
  72. package/src/features/logs/panels/error-logs-panel.tsx +183 -0
  73. package/src/features/logs/panels/index.ts +3 -0
  74. package/src/features/logs/panels/request-logs-panel.tsx +124 -0
  75. package/src/features/logs/services/logs.service.ts +68 -0
  76. package/src/features/notifications/components/notification-item.tsx +67 -0
  77. package/src/features/notifications/hooks/use-notifications.ts +78 -0
  78. package/src/features/notifications/notifications-center.tsx +119 -0
  79. package/src/features/notifications/services/notifications.service.ts +38 -0
  80. package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
  81. package/src/features/profile/components/index.ts +2 -0
  82. package/src/features/profile/force-password-change.tsx +114 -0
  83. package/src/features/profile/profile-screen.tsx +385 -0
  84. package/src/features/rbac/panels/actions-panel.tsx +20 -0
  85. package/src/features/rbac/panels/catalog-panel.tsx +243 -0
  86. package/src/features/rbac/panels/groups-panel.tsx +269 -0
  87. package/src/features/rbac/panels/index.ts +8 -0
  88. package/src/features/rbac/panels/permissions-panel.tsx +204 -0
  89. package/src/features/rbac/panels/resources-panel.tsx +20 -0
  90. package/src/features/rbac/panels/roles-panel.tsx +224 -0
  91. package/src/features/rbac/panels/scopes-panel.tsx +20 -0
  92. package/src/features/rbac/rbac-screen.tsx +80 -0
  93. package/src/features/rbac/services/rbac.service.ts +110 -0
  94. package/src/features/recovery/forgot-password-screen.tsx +69 -0
  95. package/src/features/recovery/reset-password-screen.tsx +163 -0
  96. package/src/features/users/components/user-form-dialog.tsx +187 -0
  97. package/src/features/users/hooks/use-users.ts +181 -0
  98. package/src/features/users/services/users.service.ts +83 -0
  99. package/src/features/users/users-screen.tsx +331 -0
  100. package/src/hooks/use-column-visibility.ts +54 -0
  101. package/src/hooks/use-confirm.tsx +70 -0
  102. package/src/hooks/use-filter-schema.ts +85 -0
  103. package/src/hooks/use-filters.ts +148 -0
  104. package/src/hooks/use-list-query.ts +69 -0
  105. package/src/hooks/use-request.ts +114 -0
  106. package/src/i18n/messages/en.ts +296 -0
  107. package/src/i18n/messages/pt.ts +305 -0
  108. package/src/index.ts +60 -0
  109. package/src/lib/utils.ts +6 -0
  110. package/src/middleware.ts +42 -0
  111. package/src/styles/core.css +142 -0
  112. package/tailwind-preset.ts +103 -0
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { yupResolver } from "@hookform/resolvers/yup";
4
+ import NextLink from "next/link";
5
+ import type { JSX } from "react";
6
+ import { useState } from "react";
7
+ import { useForm } from "react-hook-form";
8
+ import * as yup from "yup";
9
+
10
+ import { authService } from "#core/_services/auth";
11
+ import { Alert, Button, Card, Field, Input } from "#core/components/ui";
12
+ import { useI18n } from "#core/contexts";
13
+ import { useRequest } from "#core/hooks/use-request";
14
+
15
+ export function ForgotPasswordScreen(): JSX.Element {
16
+ const { run, loading } = useRequest();
17
+ const { t } = useI18n();
18
+ const [sent, setSent] = useState(false);
19
+ const schema = yup.object({
20
+ email: yup
21
+ .string()
22
+ .required(t("validation.required"))
23
+ .email(t("validation.email")),
24
+ });
25
+ const {
26
+ register,
27
+ handleSubmit,
28
+ formState: { errors },
29
+ } = useForm<{ email: string }>({ resolver: yupResolver(schema) });
30
+
31
+ const onSubmit = async (data: { email: string }): Promise<void> => {
32
+ await run(() => authService.forgotPassword(data.email));
33
+ setSent(true);
34
+ };
35
+
36
+ return (
37
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
38
+ <Card className="w-full max-w-md p-8">
39
+ <h1 className="mb-4 text-xl font-semibold">
40
+ {t("recovery.forgotTitle")}
41
+ </h1>
42
+ {sent ? (
43
+ <Alert variant="success">{t("recovery.forgotSuccess")}</Alert>
44
+ ) : (
45
+ <form
46
+ onSubmit={handleSubmit(onSubmit)}
47
+ className="flex flex-col gap-4"
48
+ >
49
+ <p className="text-sm text-muted-foreground">
50
+ {t("recovery.forgotHint")}
51
+ </p>
52
+ <Field label={t("auth.email")} error={errors.email?.message}>
53
+ <Input type="email" autoFocus {...register("email")} />
54
+ </Field>
55
+ <Button type="submit" size="lg" disabled={loading}>
56
+ {loading ? t("recovery.sending") : t("recovery.sendLink")}
57
+ </Button>
58
+ </form>
59
+ )}
60
+ <NextLink
61
+ href="/login"
62
+ className="mt-4 block text-center text-sm text-primary hover:underline"
63
+ >
64
+ {t("recovery.backToLogin")}
65
+ </NextLink>
66
+ </Card>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { yupResolver } from "@hookform/resolvers/yup";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import type { JSX } from "react";
6
+ import { useEffect, useState } from "react";
7
+ import { useForm } from "react-hook-form";
8
+ import * as yup from "yup";
9
+
10
+ import {
11
+ authService,
12
+ PasswordTokenCheck,
13
+ PasswordTokenStatus,
14
+ } from "#core/_services/auth";
15
+ import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
16
+ import { PasswordField } from "#core/components";
17
+ import { Alert, Button, Card, Field, Spinner } from "#core/components/ui";
18
+ import { useI18n } from "#core/contexts";
19
+ import { useRequest } from "#core/hooks/use-request";
20
+
21
+ const INVALID_MESSAGE_KEY: Record<PasswordTokenStatus, string> = {
22
+ valid: "recovery.linkInvalid",
23
+ invalid: "recovery.linkInvalid",
24
+ expired: "recovery.linkExpired",
25
+ used: "recovery.linkUsed",
26
+ };
27
+
28
+ export function ResetPasswordScreen(): JSX.Element {
29
+ const router = useRouter();
30
+ const params = useSearchParams();
31
+ const token = params.get("token") ?? "";
32
+ const { run, loading, error } = useRequest();
33
+ const { t } = useI18n();
34
+ const [done, setDone] = useState(false);
35
+ const [check, setCheck] = useState<PasswordTokenCheck | null>(null);
36
+ const [checking, setChecking] = useState(true);
37
+
38
+ // Valida o link ANTES de exibir o formulário — token ausente, expirado,
39
+ // já usado ou inexistente nunca chega a renderizar os campos de senha.
40
+ useEffect(() => {
41
+ if (!token) {
42
+ setCheck({ valid: false, status: "invalid" });
43
+ setChecking(false);
44
+ return;
45
+ }
46
+ let alive = true;
47
+ authService
48
+ .inspectPasswordToken(token)
49
+ .then((res) => alive && setCheck(res))
50
+ .catch(() => alive && setCheck({ valid: false, status: "invalid" }))
51
+ .finally(() => alive && setChecking(false));
52
+ return () => {
53
+ alive = false;
54
+ };
55
+ }, [token]);
56
+
57
+ const schema = yup.object({
58
+ newPassword: yup
59
+ .string()
60
+ .required(t("validation.required"))
61
+ .matches(STRONG_PASSWORD_REGEX, t("validation.passwordStrong")),
62
+ confirm: yup
63
+ .string()
64
+ .required(t("validation.required"))
65
+ .oneOf([yup.ref("newPassword")], t("validation.passwordsNotMatch")),
66
+ });
67
+
68
+ const {
69
+ register,
70
+ handleSubmit,
71
+ formState: { errors },
72
+ } = useForm<{ newPassword: string; confirm: string }>({
73
+ resolver: yupResolver(schema),
74
+ });
75
+
76
+ const onSubmit = async (data: { newPassword: string; confirm: string }): Promise<void> => {
77
+ const res = await run(
78
+ () => authService.resetPassword(token, data.newPassword),
79
+ { success: t("recovery.resetSuccess") },
80
+ );
81
+ if (res !== null) {
82
+ setDone(true);
83
+ setTimeout(() => router.replace("/login"), 1500);
84
+ }
85
+ };
86
+
87
+ const isFirstAccess = check?.purpose === "first_access";
88
+ const title = isFirstAccess
89
+ ? t("recovery.firstAccessTitle")
90
+ : t("recovery.resetTitle");
91
+
92
+ return (
93
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
94
+ <Card className="w-full max-w-md p-8">
95
+ <h1 className="mb-4 text-xl font-semibold">{title}</h1>
96
+
97
+ {checking && (
98
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
99
+ <Spinner className="h-4 w-4" />
100
+ {t("recovery.checking")}
101
+ </div>
102
+ )}
103
+
104
+ {!checking && check && !check.valid && (
105
+ <div className="flex flex-col gap-4">
106
+ <Alert variant="error">{t(INVALID_MESSAGE_KEY[check.status])}</Alert>
107
+ <Button size="lg" onClick={() => router.push("/forgot-password")}>
108
+ {t("recovery.requestNewLink")}
109
+ </Button>
110
+ <Button variant="ghost" onClick={() => router.push("/login")}>
111
+ {t("recovery.backToLogin")}
112
+ </Button>
113
+ </div>
114
+ )}
115
+
116
+ {!checking && check?.valid && (
117
+ <>
118
+ {isFirstAccess && (
119
+ <p className="mb-4 text-sm text-muted-foreground">
120
+ {t("recovery.firstAccessHint")}
121
+ </p>
122
+ )}
123
+ {error && (
124
+ <Alert variant="error" className="mb-2">
125
+ {error}
126
+ </Alert>
127
+ )}
128
+ {done ? (
129
+ <Alert variant="success">{t("recovery.resetSuccess")}</Alert>
130
+ ) : (
131
+ <form
132
+ onSubmit={handleSubmit(onSubmit)}
133
+ className="flex flex-col gap-4"
134
+ >
135
+ <Field
136
+ label={t("recovery.newPassword")}
137
+ error={errors.newPassword?.message}
138
+ >
139
+ <PasswordField
140
+ error={!!errors.newPassword}
141
+ {...register("newPassword")}
142
+ />
143
+ </Field>
144
+ <Field
145
+ label={t("recovery.confirmPassword")}
146
+ error={errors.confirm?.message}
147
+ >
148
+ <PasswordField
149
+ error={!!errors.confirm}
150
+ {...register("confirm")}
151
+ />
152
+ </Field>
153
+ <Button type="submit" size="lg" disabled={loading}>
154
+ {loading ? t("recovery.sending") : t("recovery.resetCta")}
155
+ </Button>
156
+ </form>
157
+ )}
158
+ </>
159
+ )}
160
+ </Card>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import { yupResolver } from "@hookform/resolvers/yup";
4
+ import type { JSX } from "react";
5
+ import { useEffect } from "react";
6
+ import { Controller, useForm } from "react-hook-form";
7
+ import * as yup from "yup";
8
+
9
+ import { maskPhoneBR } from "#core/_utils/format";
10
+ import {
11
+ Alert,
12
+ Button,
13
+ Checkbox,
14
+ Dialog,
15
+ DialogContent,
16
+ DialogFooter,
17
+ DialogForm,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ Field,
21
+ Input,
22
+ Label,
23
+ } from "#core/components/ui";
24
+ import { useI18n } from "#core/contexts";
25
+ import {
26
+ CreateUserPayload,
27
+ Role,
28
+ User,
29
+ } from "#core/features/users/services/users.service";
30
+
31
+ interface Props {
32
+ open: boolean;
33
+ loading: boolean;
34
+ roles: Role[];
35
+ editing?: User | null;
36
+ onClose: () => void;
37
+ onSubmit: (data: CreateUserPayload, id?: string) => Promise<boolean>;
38
+ }
39
+
40
+ export function UserFormDialog({
41
+ open,
42
+ loading,
43
+ roles,
44
+ editing,
45
+ onClose,
46
+ onSubmit,
47
+ }: Props): JSX.Element {
48
+ const { t } = useI18n();
49
+ const isEdit = !!editing;
50
+
51
+ const schema = yup.object({
52
+ name: yup.string().required(t("validation.required")).min(2),
53
+ lastName: yup.string().optional(),
54
+ phone: yup.string().optional(),
55
+ email: yup
56
+ .string()
57
+ .required(t("validation.required"))
58
+ .email(t("validation.email")),
59
+ roleIds: yup.array().of(yup.string()).optional(),
60
+ });
61
+
62
+ const {
63
+ register,
64
+ handleSubmit,
65
+ reset,
66
+ control,
67
+ formState: { errors },
68
+ } = useForm({
69
+ resolver: yupResolver(schema),
70
+ defaultValues: {
71
+ name: "",
72
+ lastName: "",
73
+ phone: "",
74
+ email: "",
75
+ roleIds: [] as string[],
76
+ },
77
+ });
78
+
79
+ useEffect(() => {
80
+ if (open) {
81
+ reset({
82
+ name: editing?.name ?? "",
83
+ lastName: editing?.lastName ?? "",
84
+ phone: editing?.phone ?? "",
85
+ email: editing?.email ?? "",
86
+ roleIds: editing?.roles?.map((r) => r.id) ?? [],
87
+ });
88
+ }
89
+ }, [open, editing, reset]);
90
+
91
+ const submit = handleSubmit(async (data) => {
92
+ const payload: CreateUserPayload = {
93
+ name: data.name,
94
+ lastName: data.lastName || undefined,
95
+ phone: data.phone || undefined,
96
+ email: data.email,
97
+ // o schema Yup infere (string | undefined)[]; aqui estreitamos sem cast
98
+ roleIds: data.roleIds?.filter((id): id is string => !!id),
99
+ };
100
+ const ok = await onSubmit(payload, editing?.id);
101
+ if (ok) {onClose();}
102
+ });
103
+
104
+ return (
105
+ <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
106
+ <DialogContent className="max-w-lg">
107
+ <DialogHeader>
108
+ <DialogTitle>
109
+ {isEdit ? t("common.edit") : t("users.newUser")}
110
+ </DialogTitle>
111
+ </DialogHeader>
112
+
113
+ <DialogForm onSubmit={submit}>
114
+ <div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto px-1">
115
+ <Field label={t("users.name")} error={errors.name?.message}>
116
+ <Input {...register("name")} />
117
+ </Field>
118
+ <Field label={t("users.lastName")} error={errors.lastName?.message}>
119
+ <Input {...register("lastName")} />
120
+ </Field>
121
+ <Controller
122
+ name="phone"
123
+ control={control}
124
+ render={({ field }) => (
125
+ <Field label={t("users.phone")}>
126
+ <Input
127
+ placeholder="(11) 90000-0000"
128
+ value={field.value ?? ""}
129
+ onChange={(e) => field.onChange(maskPhoneBR(e.target.value))}
130
+ />
131
+ </Field>
132
+ )}
133
+ />
134
+ <Field label={t("users.email")} error={errors.email?.message}>
135
+ <Input type="email" {...register("email")} />
136
+ </Field>
137
+ {!isEdit && (
138
+ <Alert variant="info">{t("users.passwordByEmailHint")}</Alert>
139
+ )}
140
+
141
+ <Controller
142
+ name="roleIds"
143
+ control={control}
144
+ render={({ field }) => {
145
+ const selected = (field.value ?? []) as string[];
146
+ const toggle = (id: string): void =>
147
+ field.onChange(
148
+ selected.includes(id)
149
+ ? selected.filter((x) => x !== id)
150
+ : [...selected, id],
151
+ );
152
+ return (
153
+ <div className="space-y-2">
154
+ <Label>{t("users.roles")}</Label>
155
+ <div className="flex flex-col gap-2 rounded-md border border-border p-3">
156
+ {roles.map((r) => (
157
+ <label
158
+ key={r.id}
159
+ className="flex cursor-pointer items-center gap-2 text-sm"
160
+ >
161
+ <Checkbox
162
+ checked={selected.includes(r.id)}
163
+ onCheckedChange={() => toggle(r.id)}
164
+ />
165
+ {r.name}
166
+ </label>
167
+ ))}
168
+ </div>
169
+ </div>
170
+ );
171
+ }}
172
+ />
173
+ </div>
174
+
175
+ <DialogFooter className="gap-2">
176
+ <Button type="button" variant="outline" onClick={onClose}>
177
+ {t("common.cancel")}
178
+ </Button>
179
+ <Button type="submit" disabled={loading}>
180
+ {isEdit ? t("common.save") : t("common.create")}
181
+ </Button>
182
+ </DialogFooter>
183
+ </DialogForm>
184
+ </DialogContent>
185
+ </Dialog>
186
+ );
187
+ }
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import type { SortingState } from "@tanstack/react-table";
4
+ import {
5
+ Dispatch,
6
+ SetStateAction,
7
+ useCallback,
8
+ useEffect,
9
+ useState,
10
+ } from "react";
11
+
12
+ import { useI18n } from "#core/contexts";
13
+ import {
14
+ CreateUserPayload,
15
+ Role,
16
+ UpdateUserPayload,
17
+ User,
18
+ usersService,
19
+ } from "#core/features/users/services/users.service";
20
+ import type { FiltersState } from "#core/hooks/use-filters";
21
+ import { useListQuery } from "#core/hooks/use-list-query";
22
+ import { RequestOperation, useRequest } from "#core/hooks/use-request";
23
+
24
+ export interface UseUsersResult {
25
+ rows: User[];
26
+ roles: Role[];
27
+ total: number;
28
+ page: number;
29
+ pageSize: number;
30
+ sorting: SortingState;
31
+ /** Carregando a listagem — é o spinner da tabela. */
32
+ loading: boolean;
33
+ /** Executando uma ação de linha (enviar link, resetar 2FA, excluir). */
34
+ acting: boolean;
35
+ setPage: Dispatch<SetStateAction<number>>;
36
+ setPageSize: Dispatch<SetStateAction<number>>;
37
+ setSorting: Dispatch<SetStateAction<SortingState>>;
38
+ /** Filtros dinâmicos — repassar para o `DataTable`. */
39
+ filters: FiltersState;
40
+ refresh: () => Promise<void>;
41
+ create: (payload: CreateUserPayload) => Promise<boolean>;
42
+ update: (id: string, payload: UpdateUserPayload) => Promise<boolean>;
43
+ setActive: (id: string, isActive: boolean) => Promise<void>;
44
+ sendPasswordResetLink: (id: string) => Promise<void>;
45
+ resetTwoFactor: (id: string) => Promise<void>;
46
+ sendFirstAccessLink: (id: string) => Promise<void>;
47
+ remove: (id: string) => Promise<void>;
48
+ }
49
+
50
+ export function useUsers(): UseUsersResult {
51
+ const { run, loading } = useRequest();
52
+ // `run` separado para as ações: com um só, qualquer clique de linha ligava o
53
+ // spinner da tabela — que não recarrega quando a ação não muda nenhuma coluna.
54
+ const { run: runAction, loading: acting } = useRequest();
55
+ const { t } = useI18n();
56
+
57
+ const list = useListQuery("/users/filter-schema");
58
+
59
+ const [rows, setRows] = useState<User[]>([]);
60
+ const [roles, setRoles] = useState<Role[]>([]);
61
+ const [total, setTotal] = useState(0);
62
+
63
+ const fetch = useCallback(async () => {
64
+ const res = await run(() => usersService.list(list.query));
65
+ if (res) {
66
+ setRows(res.items);
67
+ setTotal(res.meta.total);
68
+ }
69
+ }, [run, list.query]);
70
+
71
+ useEffect(() => {
72
+ void fetch();
73
+ }, [fetch]);
74
+
75
+ useEffect(() => {
76
+ void run(() => usersService.listRoles()).then((r) => r && setRoles(r));
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, []);
79
+
80
+ const create = useCallback(
81
+ async (payload: CreateUserPayload) => {
82
+ const res = await runAction(() => usersService.create(payload), {
83
+ success: RequestOperation.Create,
84
+ });
85
+ if (res) {
86
+ await fetch();
87
+ return true;
88
+ }
89
+ return false;
90
+ },
91
+ [runAction, fetch],
92
+ );
93
+
94
+ const update = useCallback(
95
+ async (id: string, payload: UpdateUserPayload) => {
96
+ const res = await runAction(() => usersService.update(id, payload), {
97
+ success: RequestOperation.Update,
98
+ });
99
+ if (res) {
100
+ await fetch();
101
+ return true;
102
+ }
103
+ return false;
104
+ },
105
+ [runAction, fetch],
106
+ );
107
+
108
+ const setActive = useCallback(
109
+ async (id: string, isActive: boolean) => {
110
+ const res = await runAction(() => usersService.setActive(id, isActive));
111
+ if (res) {await fetch();}
112
+ },
113
+ [runAction, fetch],
114
+ );
115
+
116
+ const sendPasswordResetLink = useCallback(
117
+ async (id: string): Promise<void> => {
118
+ await runAction(() => usersService.sendPasswordResetLink(id), {
119
+ success: t("users.passwordResetLinkSent"),
120
+ });
121
+ },
122
+ [runAction, t],
123
+ );
124
+
125
+ const remove = useCallback(
126
+ async (id: string) => {
127
+ const res = await runAction(() => usersService.remove(id), {
128
+ success: RequestOperation.Delete,
129
+ });
130
+ if (res !== null) {
131
+ await fetch();
132
+ }
133
+ },
134
+ [runAction, fetch],
135
+ );
136
+
137
+ const sendFirstAccessLink = useCallback(
138
+ async (id: string): Promise<void> => {
139
+ // nenhuma coluna da tabela muda com o envio — não precisa refazer o fetch
140
+ await runAction(() => usersService.sendFirstAccessLink(id), {
141
+ success: t("users.firstAccessLinkSent"),
142
+ });
143
+ },
144
+ [runAction, t],
145
+ );
146
+
147
+ const resetTwoFactor = useCallback(
148
+ async (id: string) => {
149
+ const res = await runAction(() => usersService.resetTwoFactor(id), {
150
+ success: t("users.twoFactorReset"),
151
+ });
152
+ if (res !== null) {
153
+ await fetch();
154
+ }
155
+ },
156
+ [runAction, fetch, t],
157
+ );
158
+
159
+ return {
160
+ rows,
161
+ roles,
162
+ total,
163
+ page: list.page,
164
+ pageSize: list.pageSize,
165
+ sorting: list.sorting,
166
+ loading,
167
+ acting,
168
+ setPage: list.setPage,
169
+ setPageSize: list.setPageSize,
170
+ setSorting: list.setSorting,
171
+ filters: list.filters,
172
+ refresh: fetch,
173
+ create,
174
+ update,
175
+ setActive,
176
+ sendPasswordResetLink,
177
+ resetTwoFactor,
178
+ sendFirstAccessLink,
179
+ remove,
180
+ };
181
+ }
@@ -0,0 +1,83 @@
1
+ import { api } from "#core/_services/api/axios.factory";
2
+ import type { ListQuery } from "#core/_services/api/list-query";
3
+ import { toListParams } from "#core/_services/api/list-query";
4
+ import type { components } from "#core/_services/api/schema";
5
+
6
+ /**
7
+ * Tipos derivados do `openapi.json` (§5): campo que mudar no backend quebra o
8
+ * build, não a tela. Nada de redigitar a forma da resposta aqui.
9
+ */
10
+ export type Role = components["schemas"]["UserRoleResponse"];
11
+ export type User = components["schemas"]["UserResponse"];
12
+ export type PaginationMeta = components["schemas"]["PaginationMetaResponse"];
13
+
14
+ export interface Paginated<T> {
15
+ items: T[];
16
+ meta: PaginationMeta;
17
+ }
18
+
19
+ /**
20
+ * Sem senha: o usuário recebe um link de primeiro acesso por email
21
+ * e define a própria senha.
22
+ */
23
+ export interface CreateUserPayload {
24
+ name: string;
25
+ lastName?: string;
26
+ phone?: string;
27
+ email: string;
28
+ roleIds?: string[];
29
+ }
30
+
31
+ export interface UpdateUserPayload {
32
+ name?: string;
33
+ lastName?: string;
34
+ phone?: string;
35
+ email?: string;
36
+ roleIds?: string[];
37
+ }
38
+
39
+ export const usersService = {
40
+ list(query: ListQuery) {
41
+ return api
42
+ .get<Paginated<User>>("/users", { params: toListParams(query) })
43
+ .then((r) => r.data);
44
+ },
45
+
46
+ get(id: string) {
47
+ return api.get<User>(`/users/${id}`).then((r) => r.data);
48
+ },
49
+
50
+ create(payload: CreateUserPayload) {
51
+ return api.post<User>("/users", payload).then((r) => r.data);
52
+ },
53
+
54
+ update(id: string, payload: UpdateUserPayload) {
55
+ return api.patch<User>(`/users/${id}`, payload).then((r) => r.data);
56
+ },
57
+
58
+ setActive(id: string, isActive: boolean) {
59
+ return api
60
+ .patch<User>(`/users/${id}/active/${isActive}`)
61
+ .then((r) => r.data);
62
+ },
63
+
64
+ sendPasswordResetLink(id: string) {
65
+ return api.post(`/users/${id}/reset-password`).then((r) => r.data);
66
+ },
67
+
68
+ sendFirstAccessLink(id: string) {
69
+ return api.post(`/users/${id}/first-access-link`).then((r) => r.data);
70
+ },
71
+
72
+ resetTwoFactor(id: string) {
73
+ return api.post(`/users/${id}/reset-2fa`).then((r) => r.data);
74
+ },
75
+
76
+ remove(id: string) {
77
+ return api.delete(`/users/${id}`).then((r) => r.data);
78
+ },
79
+
80
+ listRoles() {
81
+ return api.get<Role[]>("/rbac/roles").then((r) => r.data);
82
+ },
83
+ };