@greatapps/greatauth-ui 0.3.6 → 0.3.8

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.
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import type { GauthUser, GauthUserHookConfig } from "../../types/users";
5
+ import { useCreateUser, useUpdateUser } from "../../hooks/use-users";
6
+ import {
7
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
8
+ } from "../ui/dialog";
9
+ import { Button } from "../ui/button";
10
+ import { Input } from "../ui/input";
11
+ import { Label } from "../ui/label";
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
13
+ import { Loader2 } from "lucide-react";
14
+ import { toast } from "sonner";
15
+ import { ImageCropUpload } from "../image-crop-upload";
16
+
17
+ const DEFAULT_PROFILES = [
18
+ { value: "owner", label: "Proprietário" },
19
+ { value: "admin", label: "Administrador" },
20
+ { value: "collaborator", label: "Colaborador" },
21
+ { value: "attendant", label: "Atendente" },
22
+ { value: "viewer", label: "Visualizador" },
23
+ ];
24
+
25
+ export interface UserFormDialogProps {
26
+ open: boolean;
27
+ onOpenChange: (open: boolean) => void;
28
+ user?: GauthUser;
29
+ config: GauthUserHookConfig;
30
+ profileOptions?: { value: string; label: string }[];
31
+ renderPhones?: (idUser: number) => React.ReactNode;
32
+ }
33
+
34
+ export function UserFormDialog({
35
+ open,
36
+ onOpenChange,
37
+ user,
38
+ config,
39
+ profileOptions,
40
+ renderPhones,
41
+ }: UserFormDialogProps) {
42
+ const isEditing = !!user;
43
+ const createUser = useCreateUser(config);
44
+ const updateUser = useUpdateUser(config);
45
+ const profiles = profileOptions || DEFAULT_PROFILES;
46
+
47
+ const [name, setName] = useState("");
48
+ const [lastName, setLastName] = useState("");
49
+ const [email, setEmail] = useState("");
50
+ const [profile, setProfile] = useState("collaborator");
51
+ const [password, setPassword] = useState("");
52
+ const [photo, setPhoto] = useState("");
53
+
54
+ useEffect(() => {
55
+ if (user) {
56
+ setName(user.name || "");
57
+ setLastName(user.last_name || "");
58
+ setEmail(user.email || "");
59
+ setProfile(user.profile || "collaborator");
60
+ setPhoto(user.photo || "");
61
+ } else {
62
+ setName(""); setLastName(""); setEmail(""); setProfile("collaborator"); setPhoto("");
63
+ }
64
+ setPassword("");
65
+ }, [user, open]);
66
+
67
+ const isPending = createUser.isPending || updateUser.isPending;
68
+
69
+ async function handleSubmit(e: React.FormEvent) {
70
+ e.preventDefault();
71
+ if (!name.trim() || !email.trim()) return;
72
+
73
+ try {
74
+ if (isEditing) {
75
+ await updateUser.mutateAsync({
76
+ id: user.id,
77
+ body: {
78
+ name: name.trim(),
79
+ last_name: lastName.trim() || undefined,
80
+ email: email.trim(),
81
+ profile,
82
+ photo: photo || null,
83
+ },
84
+ });
85
+ toast.success("Usuário atualizado");
86
+ } else {
87
+ await createUser.mutateAsync({
88
+ name: name.trim(),
89
+ last_name: lastName.trim() || undefined,
90
+ email: email.trim(),
91
+ profile,
92
+ password: password || undefined,
93
+ photo: photo || null,
94
+ });
95
+ toast.success("Usuário criado");
96
+ }
97
+ onOpenChange(false);
98
+ } catch {
99
+ toast.error(isEditing ? "Erro ao atualizar usuário" : "Erro ao criar usuário");
100
+ }
101
+ }
102
+
103
+ return (
104
+ <Dialog open={open} onOpenChange={onOpenChange}>
105
+ <DialogContent>
106
+ <DialogHeader>
107
+ <DialogTitle>{isEditing ? "Editar Usuário" : "Novo Usuário"}</DialogTitle>
108
+ </DialogHeader>
109
+ <form onSubmit={handleSubmit} className="space-y-4">
110
+ <div className="flex justify-center">
111
+ <ImageCropUpload
112
+ value={photo || null}
113
+ onChange={setPhoto}
114
+ onRemove={() => setPhoto("")}
115
+ entityType="users"
116
+ entityId={user?.id}
117
+ idAccount={Number(config.accountId) || 0}
118
+ name={`${name} ${lastName}`.trim() || null}
119
+ disabled={isPending}
120
+ />
121
+ </div>
122
+ <div className="grid grid-cols-2 gap-4">
123
+ <div className="space-y-2">
124
+ <Label htmlFor="user-name">Nome *</Label>
125
+ <Input id="user-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nome" required disabled={isPending} />
126
+ </div>
127
+ <div className="space-y-2">
128
+ <Label htmlFor="user-lastname">Sobrenome</Label>
129
+ <Input id="user-lastname" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Sobrenome" disabled={isPending} />
130
+ </div>
131
+ </div>
132
+ <div className="space-y-2">
133
+ <Label htmlFor="user-email">E-mail *</Label>
134
+ <Input id="user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@exemplo.com" required disabled={isPending} />
135
+ </div>
136
+ {!isEditing && (
137
+ <div className="space-y-2">
138
+ <Label htmlFor="user-password">Senha</Label>
139
+ <Input id="user-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Senha inicial (opcional)" disabled={isPending} />
140
+ </div>
141
+ )}
142
+ <div className="space-y-2">
143
+ <Label>Perfil de acesso</Label>
144
+ <Select value={profile} onValueChange={setProfile} disabled={isPending}>
145
+ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
146
+ <SelectContent>
147
+ {profiles.map((opt) => (
148
+ <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
149
+ ))}
150
+ </SelectContent>
151
+ </Select>
152
+ </div>
153
+ {isEditing && renderPhones && (
154
+ <div className="border-t pt-4">
155
+ {renderPhones(user.id)}
156
+ </div>
157
+ )}
158
+ <DialogFooter>
159
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>Cancelar</Button>
160
+ <Button type="submit" disabled={isPending || !name.trim() || !email.trim()}>
161
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
162
+ {isEditing ? "Salvar" : "Criar"}
163
+ </Button>
164
+ </DialogFooter>
165
+ </form>
166
+ </DialogContent>
167
+ </Dialog>
168
+ );
169
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { Badge } from "../ui/badge";
4
+ import type { UserProfile } from "../../types/users";
5
+
6
+ const profileConfig: Record<string, { label: string; className: string }> = {
7
+ owner: { label: "Proprietário", className: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" },
8
+ proprietario: { label: "Proprietário", className: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" },
9
+ admin: { label: "Administrador", className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" },
10
+ administrador: { label: "Administrador", className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" },
11
+ manager: { label: "Gerente", className: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200" },
12
+ gerente: { label: "Gerente", className: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200" },
13
+ supervisor: { label: "Supervisor", className: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" },
14
+ collaborator: { label: "Colaborador", className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
15
+ colaborador: { label: "Colaborador", className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
16
+ attendant: { label: "Atendente", className: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200" },
17
+ viewer: { label: "Visualizador", className: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" },
18
+ };
19
+
20
+ export interface UserProfileBadgeProps {
21
+ profile: UserProfile | string;
22
+ }
23
+
24
+ export function UserProfileBadge({ profile }: UserProfileBadgeProps) {
25
+ const config = profileConfig[profile] || { label: profile, className: "" };
26
+ return (
27
+ <Badge variant="secondary" className={config.className}>
28
+ {config.label}
29
+ </Badge>
30
+ );
31
+ }
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import type { ColumnDef } from "@tanstack/react-table";
5
+ import type { GauthUser, GauthUserHookConfig } from "../../types/users";
6
+ import { useUsers, useDeleteUser, useResetPassword } from "../../hooks/use-users";
7
+ import { UserFormDialog } from "./user-form-dialog";
8
+ import { UserProfileBadge } from "./user-profile-badge";
9
+ import { DataTable } from "./data-table";
10
+ import { EntityAvatar } from "../entity-avatar";
11
+ import { Input } from "../ui/input";
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
13
+ import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
14
+ import {
15
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
16
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
17
+ } from "../ui/alert-dialog";
18
+ import { Button } from "../ui/button";
19
+ import { Pencil, Trash2, Search, Forward, Plus } from "lucide-react";
20
+ import { format } from "date-fns";
21
+ import { ptBR } from "date-fns/locale";
22
+ import { toast } from "sonner";
23
+
24
+ const PAGE_SIZE = 15;
25
+
26
+ const PROFILE_FILTER_OPTIONS = [
27
+ { value: "all", label: "Todos os perfis" },
28
+ { value: "owner", label: "Proprietário" },
29
+ { value: "admin", label: "Administrador" },
30
+ { value: "collaborator", label: "Colaborador" },
31
+ { value: "attendant", label: "Atendente" },
32
+ { value: "viewer", label: "Visualizador" },
33
+ ];
34
+
35
+ function useColumns(
36
+ onEdit: (user: GauthUser) => void,
37
+ onDelete: (id: number) => void,
38
+ onResetPassword: (user: GauthUser) => void,
39
+ ): ColumnDef<GauthUser>[] {
40
+ return [
41
+ {
42
+ accessorKey: "name",
43
+ header: "Nome",
44
+ cell: ({ row }) => (
45
+ <div className="flex items-center gap-2">
46
+ <EntityAvatar
47
+ photo={row.original.photo}
48
+ name={`${row.original.name} ${row.original.last_name}`.trim()}
49
+ size="sm"
50
+ />
51
+ <span className="font-medium">
52
+ {row.original.name} {row.original.last_name}
53
+ </span>
54
+ </div>
55
+ ),
56
+ sortingFn: (rowA, rowB) => {
57
+ const a = `${rowA.original.name} ${rowA.original.last_name}`.toLowerCase();
58
+ const b = `${rowB.original.name} ${rowB.original.last_name}`.toLowerCase();
59
+ return a.localeCompare(b);
60
+ },
61
+ },
62
+ {
63
+ accessorKey: "email",
64
+ header: "E-mail",
65
+ },
66
+ {
67
+ accessorKey: "profile",
68
+ header: "Perfil",
69
+ cell: ({ row }) => <UserProfileBadge profile={row.original.profile} />,
70
+ sortingFn: (rowA, rowB) => rowA.original.profile.localeCompare(rowB.original.profile),
71
+ },
72
+ {
73
+ accessorKey: "last_login",
74
+ header: "Último acesso",
75
+ cell: ({ row }) => (
76
+ <span className="text-muted-foreground text-sm">
77
+ {row.original.last_login
78
+ ? format(new Date(row.original.last_login), "dd/MM/yyyy HH:mm", { locale: ptBR })
79
+ : "—"}
80
+ </span>
81
+ ),
82
+ },
83
+ {
84
+ accessorKey: "datetime_add",
85
+ header: "Criado em",
86
+ cell: ({ row }) => (
87
+ <span className="text-muted-foreground text-sm">
88
+ {format(new Date(row.original.datetime_add), "dd/MM/yyyy", { locale: ptBR })}
89
+ </span>
90
+ ),
91
+ },
92
+ {
93
+ id: "actions",
94
+ size: 120,
95
+ enableSorting: false,
96
+ cell: ({ row }) => (
97
+ <div className="flex items-center gap-1">
98
+ <Tooltip>
99
+ <TooltipTrigger asChild>
100
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onResetPassword(row.original)}>
101
+ <Forward className="h-4 w-4" />
102
+ </Button>
103
+ </TooltipTrigger>
104
+ <TooltipContent>Resetar senha</TooltipContent>
105
+ </Tooltip>
106
+ <Tooltip>
107
+ <TooltipTrigger asChild>
108
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onEdit(row.original)}>
109
+ <Pencil className="h-4 w-4" />
110
+ </Button>
111
+ </TooltipTrigger>
112
+ <TooltipContent>Editar</TooltipContent>
113
+ </Tooltip>
114
+ <Tooltip>
115
+ <TooltipTrigger asChild>
116
+ <Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => onDelete(row.original.id)}>
117
+ <Trash2 className="h-4 w-4" />
118
+ </Button>
119
+ </TooltipTrigger>
120
+ <TooltipContent>Excluir</TooltipContent>
121
+ </Tooltip>
122
+ </div>
123
+ ),
124
+ },
125
+ ];
126
+ }
127
+
128
+ export interface UsersPageProps {
129
+ config: GauthUserHookConfig;
130
+ profileOptions?: { value: string; label: string }[];
131
+ renderPhones?: (idUser: number) => React.ReactNode;
132
+ }
133
+
134
+ export function UsersPage({ config, profileOptions, renderPhones }: UsersPageProps) {
135
+ const [search, setSearch] = useState("");
136
+ const [profileFilter, setProfileFilter] = useState("all");
137
+ const [page, setPage] = useState(1);
138
+
139
+ const queryParams = useMemo(() => {
140
+ const params: Record<string, string> = {
141
+ limit: String(PAGE_SIZE),
142
+ page: String(page),
143
+ };
144
+ if (profileFilter !== "all") params.profile = profileFilter;
145
+ return params;
146
+ }, [profileFilter, page]);
147
+
148
+ const { data, isLoading } = useUsers(config, queryParams);
149
+ const deleteUser = useDeleteUser(config);
150
+ const resetPassword = useResetPassword(config);
151
+ const [editUser, setEditUser] = useState<GauthUser | null>(null);
152
+ const [formOpen, setFormOpen] = useState(false);
153
+ const [deleteId, setDeleteId] = useState<number | null>(null);
154
+ const [resetUser, setResetUser] = useState<GauthUser | null>(null);
155
+
156
+ const users = data?.data || [];
157
+ const total = data?.total || 0;
158
+
159
+ // Client-side search filter
160
+ const filtered = useMemo(() => {
161
+ if (!search) return users;
162
+ const term = search.toLowerCase();
163
+ return users.filter((u) =>
164
+ `${u.name} ${u.last_name}`.toLowerCase().includes(term) ||
165
+ u.email.toLowerCase().includes(term)
166
+ );
167
+ }, [users, search]);
168
+
169
+ const columns = useColumns(
170
+ (u) => { setEditUser(u); setFormOpen(true); },
171
+ (id) => setDeleteId(id),
172
+ (u) => setResetUser(u),
173
+ );
174
+
175
+ function handleDelete() {
176
+ if (!deleteId) return;
177
+ deleteUser.mutate(deleteId, {
178
+ onSuccess: () => { toast.success("Usuário excluído"); setDeleteId(null); },
179
+ onError: () => toast.error("Erro ao excluir usuário"),
180
+ });
181
+ }
182
+
183
+ function handleResetPassword() {
184
+ if (!resetUser) return;
185
+ resetPassword.mutate(resetUser.id, {
186
+ onSuccess: () => { toast.success("Senha resetada e email enviado"); setResetUser(null); },
187
+ onError: () => { toast.error("Erro ao resetar senha"); setResetUser(null); },
188
+ });
189
+ }
190
+
191
+ return (
192
+ <div className="flex flex-col gap-4 p-4">
193
+ <div className="flex items-center justify-between">
194
+ <div>
195
+ <h1 className="text-xl font-semibold">Usuários</h1>
196
+ <p className="text-sm text-muted-foreground">Gestão de usuários da conta</p>
197
+ </div>
198
+ <Button onClick={() => { setEditUser(null); setFormOpen(true); }} size="sm">
199
+ <Plus className="mr-2 h-4 w-4" />
200
+ Novo Usuário
201
+ </Button>
202
+ </div>
203
+
204
+ <div className="flex items-center gap-3">
205
+ <div className="relative flex-1 max-w-md">
206
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
207
+ <Input
208
+ placeholder="Buscar por nome ou e-mail..."
209
+ value={search}
210
+ onChange={(e) => { setSearch(e.target.value); setPage(1); }}
211
+ className="pl-9"
212
+ />
213
+ </div>
214
+ <Select value={profileFilter} onValueChange={(v) => { setProfileFilter(v); setPage(1); }}>
215
+ <SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
216
+ <SelectContent>
217
+ {PROFILE_FILTER_OPTIONS.map((opt) => (
218
+ <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
219
+ ))}
220
+ </SelectContent>
221
+ </Select>
222
+ </div>
223
+
224
+ <DataTable
225
+ columns={columns}
226
+ data={search ? filtered : users}
227
+ isLoading={isLoading}
228
+ emptyMessage="Nenhum usuário encontrado"
229
+ total={search ? filtered.length : total}
230
+ page={page}
231
+ onPageChange={setPage}
232
+ pageSize={PAGE_SIZE}
233
+ />
234
+
235
+ <UserFormDialog
236
+ open={formOpen}
237
+ onOpenChange={(open) => { setFormOpen(open); if (!open) setEditUser(null); }}
238
+ user={editUser ?? undefined}
239
+ config={config}
240
+ profileOptions={profileOptions}
241
+ renderPhones={renderPhones}
242
+ />
243
+
244
+ <AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
245
+ <AlertDialogContent>
246
+ <AlertDialogHeader>
247
+ <AlertDialogTitle>Excluir usuário?</AlertDialogTitle>
248
+ <AlertDialogDescription>Esta ação não pode ser desfeita. O usuário será removido permanentemente.</AlertDialogDescription>
249
+ </AlertDialogHeader>
250
+ <AlertDialogFooter>
251
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
252
+ <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Excluir</AlertDialogAction>
253
+ </AlertDialogFooter>
254
+ </AlertDialogContent>
255
+ </AlertDialog>
256
+
257
+ <AlertDialog open={!!resetUser} onOpenChange={(open) => !open && setResetUser(null)}>
258
+ <AlertDialogContent>
259
+ <AlertDialogHeader>
260
+ <AlertDialogTitle>Resetar senha?</AlertDialogTitle>
261
+ <AlertDialogDescription>
262
+ A senha de <strong>{resetUser?.name} {resetUser?.last_name}</strong> ({resetUser?.email}) será removida e um email com link para definir nova senha será enviado.
263
+ </AlertDialogDescription>
264
+ </AlertDialogHeader>
265
+ <AlertDialogFooter>
266
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
267
+ <AlertDialogAction onClick={handleResetPassword}>Resetar e enviar</AlertDialogAction>
268
+ </AlertDialogFooter>
269
+ </AlertDialogContent>
270
+ </AlertDialog>
271
+ </div>
272
+ );
273
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import type { GauthUser, GauthUserHookConfig, ApiResponse } from "../types/users";
5
+
6
+ function buildUrl(config: GauthUserHookConfig, path: string): string {
7
+ const lang = config.language || "pt-br";
8
+ const idWl = config.idWl || "1";
9
+ return `${config.gauthApiUrl}/v1/${lang}/${idWl}/accounts/${config.accountId}/${path}`;
10
+ }
11
+
12
+ async function request<T>(url: string, token: string, options?: RequestInit): Promise<ApiResponse<T>> {
13
+ const res = await fetch(url, {
14
+ headers: {
15
+ "Content-Type": "application/json",
16
+ Authorization: `Bearer ${token}`,
17
+ },
18
+ ...options,
19
+ });
20
+ if (!res.ok) {
21
+ let message = `HTTP ${res.status}`;
22
+ try { const b = await res.json(); message = b.message || message; } catch {}
23
+ throw new Error(message);
24
+ }
25
+ const json = await res.json();
26
+ if (json.status === 0) throw new Error(json.message || "Operação falhou");
27
+ return json;
28
+ }
29
+
30
+ export function useUsers(config: GauthUserHookConfig, params?: Record<string, string>) {
31
+ return useQuery({
32
+ queryKey: ["greatauth", "users", config.accountId, params],
33
+ queryFn: () => {
34
+ const qs = params ? "?" + new URLSearchParams(params).toString() : "";
35
+ return request<GauthUser[]>(buildUrl(config, `users${qs}`), config.token!);
36
+ },
37
+ enabled: !!config.token && !!config.accountId,
38
+ select: (res) => ({ data: res.data || [], total: res.total || 0 }),
39
+ });
40
+ }
41
+
42
+ export function useCreateUser(config: GauthUserHookConfig) {
43
+ const qc = useQueryClient();
44
+ return useMutation({
45
+ mutationFn: (data: { name: string; last_name?: string; email: string; password?: string; profile?: string; photo?: string | null }) =>
46
+ request<GauthUser>(buildUrl(config, "users"), config.token!, {
47
+ method: "POST",
48
+ body: JSON.stringify(data),
49
+ }),
50
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["greatauth", "users"] }),
51
+ });
52
+ }
53
+
54
+ export function useUpdateUser(config: GauthUserHookConfig) {
55
+ const qc = useQueryClient();
56
+ return useMutation({
57
+ mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
58
+ request<GauthUser>(buildUrl(config, `users/${id}`), config.token!, {
59
+ method: "PUT",
60
+ body: JSON.stringify(body),
61
+ }),
62
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["greatauth", "users"] }),
63
+ });
64
+ }
65
+
66
+ export function useDeleteUser(config: GauthUserHookConfig) {
67
+ const qc = useQueryClient();
68
+ return useMutation({
69
+ mutationFn: (id: number) =>
70
+ request<void>(buildUrl(config, `users/${id}`), config.token!, { method: "DELETE" }),
71
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["greatauth", "users"] }),
72
+ });
73
+ }
74
+
75
+ export function useResetPassword(config: GauthUserHookConfig) {
76
+ const qc = useQueryClient();
77
+ return useMutation({
78
+ mutationFn: (id: number) =>
79
+ request<{ message: string }>(buildUrl(config, `users/${id}/reset-password`), config.token!, { method: "POST" }),
80
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["greatauth", "users"] }),
81
+ });
82
+ }
package/src/index.ts CHANGED
@@ -15,10 +15,30 @@ export { AppSidebar } from "./components";
15
15
  export { AppHeader } from "./components";
16
16
  export { LoginForm } from "./components";
17
17
  export { ThemeToggle } from "./components";
18
+ export { EntityAvatar } from "./components";
19
+ export type { EntityAvatarProps } from "./components";
20
+ export { ImageCropUpload } from "./components";
21
+ export type { ImageCropUploadProps } from "./components";
18
22
 
19
23
  // UI Primitives (consuming apps may need)
20
24
  export { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "./components/ui/sidebar";
21
25
  export { TooltipProvider } from "./components/ui/tooltip";
22
26
 
27
+ // User Types
28
+ export type { GauthUser, UserProfile, UserPhone, PhoneLabel, GauthUserHookConfig } from "./types/users";
29
+
30
+ // User Hooks
31
+ export { useUsers, useCreateUser, useUpdateUser, useDeleteUser, useResetPassword } from "./hooks/use-users";
32
+
33
+ // User Components
34
+ export { UsersPage } from "./components/users/users-page";
35
+ export type { UsersPageProps } from "./components/users/users-page";
36
+ export { UserFormDialog } from "./components/users/user-form-dialog";
37
+ export type { UserFormDialogProps } from "./components/users/user-form-dialog";
38
+ export { UserProfileBadge } from "./components/users/user-profile-badge";
39
+ export type { UserProfileBadgeProps } from "./components/users/user-profile-badge";
40
+ export { DataTable } from "./components/users/data-table";
41
+ export type { DataTableProps } from "./components/users/data-table";
42
+
23
43
  // Utils
24
44
  export { cn } from "./lib/utils";
@@ -0,0 +1,82 @@
1
+ import type { Area } from "react-easy-crop";
2
+
3
+ export async function getCroppedImage(
4
+ imageSrc: string,
5
+ cropArea: Area,
6
+ outputSize = 400,
7
+ quality = 0.85,
8
+ ): Promise<string> {
9
+ const image = await loadImage(imageSrc);
10
+ const canvas = document.createElement("canvas");
11
+ canvas.width = outputSize;
12
+ canvas.height = outputSize;
13
+ const ctx = canvas.getContext("2d");
14
+
15
+ if (!ctx) throw new Error("Canvas context not available");
16
+
17
+ ctx.drawImage(
18
+ image,
19
+ cropArea.x,
20
+ cropArea.y,
21
+ cropArea.width,
22
+ cropArea.height,
23
+ 0,
24
+ 0,
25
+ outputSize,
26
+ outputSize,
27
+ );
28
+
29
+ const blob = await new Promise<Blob>((resolve, reject) => {
30
+ canvas.toBlob(
31
+ (b) => (b ? resolve(b) : reject(new Error("Failed to create blob"))),
32
+ "image/webp",
33
+ quality,
34
+ );
35
+ });
36
+
37
+ return blobToBase64(blob);
38
+ }
39
+
40
+ function loadImage(src: string): Promise<HTMLImageElement> {
41
+ return new Promise((resolve, reject) => {
42
+ const img = new Image();
43
+ img.crossOrigin = "anonymous";
44
+ img.onload = () => resolve(img);
45
+ img.onerror = reject;
46
+ img.src = src;
47
+ });
48
+ }
49
+
50
+ function blobToBase64(blob: Blob): Promise<string> {
51
+ return new Promise((resolve, reject) => {
52
+ const reader = new FileReader();
53
+ reader.onload = () => {
54
+ const result = reader.result as string;
55
+ resolve(result.split(",")[1]);
56
+ };
57
+ reader.onerror = reject;
58
+ reader.readAsDataURL(blob);
59
+ });
60
+ }
61
+
62
+ const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
63
+ const MAX_SIZE_BYTES = 5 * 1024 * 1024;
64
+
65
+ export function validateImageFile(file: File): string | null {
66
+ if (!ACCEPTED_TYPES.includes(file.type)) {
67
+ return "Formato não suportado. Use PNG, JPG ou WEBP";
68
+ }
69
+ if (file.size > MAX_SIZE_BYTES) {
70
+ return "Imagem muito grande. Máximo: 5MB";
71
+ }
72
+ return null;
73
+ }
74
+
75
+ export function fileToDataUrl(file: File): Promise<string> {
76
+ return new Promise((resolve, reject) => {
77
+ const reader = new FileReader();
78
+ reader.onload = () => resolve(reader.result as string);
79
+ reader.onerror = reject;
80
+ reader.readAsDataURL(file);
81
+ });
82
+ }