@greatapps/common 1.1.50 → 1.1.52

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 (40) hide show
  1. package/dist/components/layouts/UsersSelectorPopover.mjs +155 -139
  2. package/dist/components/layouts/UsersSelectorPopover.mjs.map +1 -1
  3. package/dist/index.mjs +11 -4
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/modules/auth/actions/is-two-factor-pending.action.mjs +9 -0
  6. package/dist/modules/auth/actions/is-two-factor-pending.action.mjs.map +1 -0
  7. package/dist/modules/auth/actions/verify-two-factor.action.mjs +16 -0
  8. package/dist/modules/auth/actions/verify-two-factor.action.mjs.map +1 -0
  9. package/dist/modules/auth/hooks/useUserQuery.mjs +27 -1
  10. package/dist/modules/auth/hooks/useUserQuery.mjs.map +1 -1
  11. package/dist/modules/auth/services/auth.service.mjs +55 -18
  12. package/dist/modules/auth/services/auth.service.mjs.map +1 -1
  13. package/dist/modules/projects/actions/list-available-users.action.mjs +2 -2
  14. package/dist/modules/projects/actions/list-available-users.action.mjs.map +1 -1
  15. package/dist/modules/projects/actions/list-project-users.action.mjs +2 -2
  16. package/dist/modules/projects/actions/list-project-users.action.mjs.map +1 -1
  17. package/dist/modules/projects/hooks/list-available-users.hook.mjs +27 -0
  18. package/dist/modules/projects/hooks/list-available-users.hook.mjs.map +1 -0
  19. package/dist/modules/projects/hooks/list-project-users.hook.mjs +27 -0
  20. package/dist/modules/projects/hooks/list-project-users.hook.mjs.map +1 -0
  21. package/dist/modules/projects/services/project-users.service.mjs +21 -10
  22. package/dist/modules/projects/services/project-users.service.mjs.map +1 -1
  23. package/dist/modules/projects/types.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/layouts/UsersSelectorPopover.tsx +119 -126
  26. package/src/index.ts +5 -4
  27. package/src/modules/auth/actions/is-two-factor-pending.action.ts +7 -0
  28. package/src/modules/auth/actions/verify-two-factor.action.ts +15 -0
  29. package/src/modules/auth/hooks/useUserQuery.ts +28 -1
  30. package/src/modules/auth/schema.ts +23 -7
  31. package/src/modules/auth/services/auth.service.ts +75 -19
  32. package/src/modules/projects/actions/list-available-users.action.ts +3 -3
  33. package/src/modules/projects/actions/list-project-users.action.ts +3 -3
  34. package/src/modules/projects/hooks/list-available-users.hook.ts +26 -0
  35. package/src/modules/projects/hooks/list-project-users.hook.ts +26 -0
  36. package/src/modules/projects/services/project-users.service.ts +23 -10
  37. package/src/modules/projects/types.ts +13 -1
  38. package/dist/modules/projects/hooks/project-users.hook.mjs +0 -70
  39. package/dist/modules/projects/hooks/project-users.hook.mjs.map +0 -1
  40. package/src/modules/projects/hooks/project-users.hook.ts +0 -81
@@ -1,22 +1,30 @@
1
1
  "use client"
2
2
 
3
- import { useState } from 'react';
3
+ import { useEffect, useState } from 'react';
4
4
  import { Plus, Settings, User, Users } from 'lucide-react';
5
5
  import Image from 'next/image';
6
6
  import { useRouter } from 'next/navigation';
7
+ import { useQueryClient, useMutation } from '@tanstack/react-query';
7
8
  import { Popover, PopoverContent, PopoverTrigger } from '../ui/overlay/Popover';
8
9
  import { Button } from '../ui/buttons/Button';
9
10
  import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '../ui/overlay/Command';
10
11
  import { Checkbox } from '../ui/form/Checkbox';
11
12
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/overlay/Tooltip';
12
13
  import { cn } from '../../infra/utils/clsx';
13
- import { useProjectUsers } from '../../modules/projects/hooks/project-users.hook';
14
+ import { useListProjectUsers, LIST_PROJECT_USERS_BASE_KEY } from '../../modules/projects/hooks/list-project-users.hook';
15
+ import { useListAvailableUsers, LIST_AVAILABLE_USERS_BASE_KEY } from '../../modules/projects/hooks/list-available-users.hook';
16
+ import { addProjectUserAction } from '../../modules/projects/actions/add-project-user.action';
17
+ import { removeProjectUserAction } from '../../modules/projects/actions/remove-project-user.action';
14
18
 
15
- export interface UserSelectorItem {
16
- id: number;
17
- name: string;
18
- avatar: string;
19
- checked: boolean;
19
+ const DEFAULT_AVATAR = '/images/Logomark.png';
20
+
21
+ export interface UsersSelectorPopoverProps {
22
+ projectId?: number;
23
+ addRoute?: string;
24
+ manageRoute?: string;
25
+ side?: 'top' | 'bottom' | 'left' | 'right';
26
+ align?: 'start' | 'center' | 'end';
27
+ contentClassName?: string;
20
28
  }
21
29
 
22
30
  function UserItemSkeleton() {
@@ -31,50 +39,94 @@ function UserItemSkeleton() {
31
39
  );
32
40
  }
33
41
 
34
- export interface UsersSelectorPopoverProps {
35
- projectId?: number;
36
- users?: UserSelectorItem[];
37
- onToggleUser?: (id: number) => void;
38
- addRoute?: string;
39
- manageRoute?: string;
40
- userCount?: number;
41
- side?: 'top' | 'bottom' | 'left' | 'right';
42
- align?: 'start' | 'center' | 'end';
43
- contentClassName?: string;
44
- }
45
-
46
- interface ContentProps {
47
- users: UserSelectorItem[];
48
- isLoading: boolean;
49
- userCount: number;
50
- toggleUser: (id: number) => void;
51
- addRoute: string;
52
- manageRoute: string;
53
- side?: UsersSelectorPopoverProps['side'];
54
- align?: UsersSelectorPopoverProps['align'];
55
- contentClassName?: string;
56
- }
57
-
58
- function UsersSelectorPopoverContent({
59
- users,
60
- isLoading,
61
- userCount,
62
- toggleUser,
63
- addRoute,
64
- manageRoute,
42
+ export function UsersSelectorPopover({
43
+ projectId,
44
+ addRoute = '/my-teams?createNew=true',
45
+ manageRoute = '/my-teams',
65
46
  side,
66
47
  align,
67
48
  contentClassName,
68
- }: ContentProps) {
69
- const route = useRouter();
49
+ }: UsersSelectorPopoverProps) {
50
+ const router = useRouter();
51
+ const queryClient = useQueryClient();
70
52
  const [open, setOpen] = useState(false);
71
53
  const [hoveredId, setHoveredId] = useState<number | null>(null);
54
+ const [searchInput, setSearchInput] = useState('');
55
+ const [search, setSearch] = useState('');
56
+
57
+ const id = projectId ?? 0;
58
+
59
+ useEffect(() => {
60
+ const timer = setTimeout(() => setSearch(searchInput), 300);
61
+ return () => clearTimeout(timer);
62
+ }, [searchInput]);
63
+
64
+ const projectUsers = useListProjectUsers(id, search);
65
+ const availableUsers = useListAvailableUsers(id, search);
66
+
67
+ const inProject = projectUsers.data?.pages.flatMap((p) => p.data) ?? [];
68
+ const notInProject = availableUsers.data?.pages.flatMap((p) => p.data) ?? [];
69
+ const isLoading = projectUsers.isLoading || availableUsers.isLoading;
70
+ const isFetchingMore = projectUsers.isFetchingNextPage || availableUsers.isFetchingNextPage;
71
+ const totalInProject = projectUsers.data?.pages[0]?.total ?? inProject.length;
72
+
73
+ const invalidate = () => {
74
+ queryClient.invalidateQueries({ queryKey: LIST_PROJECT_USERS_BASE_KEY(id) });
75
+ queryClient.invalidateQueries({ queryKey: LIST_AVAILABLE_USERS_BASE_KEY(id) });
76
+ };
77
+
78
+ const addMutation = useMutation({
79
+ mutationFn: (userId: number) => addProjectUserAction({ projectId: id, users: [userId] }),
80
+ onSuccess: invalidate,
81
+ });
82
+
83
+ const removeMutation = useMutation({
84
+ mutationFn: (userId: number) => removeProjectUserAction({ projectId: id, users: [userId] }),
85
+ onSuccess: invalidate,
86
+ });
87
+
88
+ const allUsers = [
89
+ ...inProject.map((u) => ({
90
+ id: u.id_user,
91
+ name: u.name,
92
+ avatar: u.photo ?? DEFAULT_AVATAR,
93
+ checked: true as const,
94
+ removable: u.profile !== 'owner' && u.profile !== 'admin',
95
+ })),
96
+ ...notInProject.map((u) => ({
97
+ id: u.id,
98
+ name: u.name,
99
+ avatar: u.photo ?? DEFAULT_AVATAR,
100
+ checked: false as const,
101
+ removable: true,
102
+ })),
103
+ ];
104
+
105
+ const toggleUser = (id: number, checked: boolean, removable: boolean) => {
106
+ if (checked) {
107
+ if (!removable) return;
108
+ removeMutation.mutate(id);
109
+ } else {
110
+ addMutation.mutate(id);
111
+ }
112
+ };
113
+
114
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
115
+ const target = e.currentTarget;
116
+ const nearBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 50;
117
+ if (!nearBottom) return;
118
+ if (projectUsers.hasNextPage && !projectUsers.isFetchingNextPage) {
119
+ projectUsers.fetchNextPage();
120
+ } else if (availableUsers.hasNextPage && !availableUsers.isFetchingNextPage) {
121
+ availableUsers.fetchNextPage();
122
+ }
123
+ };
72
124
 
73
125
  return (
74
126
  <Popover open={open} onOpenChange={setOpen}>
75
127
  <PopoverTrigger className="cursor-pointer">
76
128
  <div className="flex items-center gap-2 text-gray-950 px-3 py-2 border border-gray-200 rounded-full hover:border-gray-950">
77
- <span className="paragraph-small-semibold">{userCount}</span>
129
+ <span className="paragraph-small-semibold">{totalInProject}</span>
78
130
  <User size={16} />
79
131
  </div>
80
132
  </PopoverTrigger>
@@ -95,7 +147,7 @@ function UsersSelectorPopoverContent({
95
147
  <Button
96
148
  variant="secondary"
97
149
  className="h-8 paragraph-small-semibold text-gray-950"
98
- onClick={() => route.push(addRoute)}
150
+ onClick={() => router.push(addRoute)}
99
151
  >
100
152
  <Plus size={16} />
101
153
  Adicionar
@@ -105,7 +157,7 @@ function UsersSelectorPopoverContent({
105
157
  <Button
106
158
  variant="secondary"
107
159
  className="p-0 size-8"
108
- onClick={() => route.push(manageRoute)}
160
+ onClick={() => router.push(manageRoute)}
109
161
  >
110
162
  <Settings size={18} />
111
163
  </Button>
@@ -115,16 +167,23 @@ function UsersSelectorPopoverContent({
115
167
  </div>
116
168
  </div>
117
169
 
118
- <Command className="gap-2" value="">
119
- <CommandInput placeholder="Busque aqui" />
120
- <CommandList className="custom-scrollbar paragraph-small-medium text-gray-600 h-[162px]">
170
+ <Command shouldFilter={false} className="gap-2" value="">
171
+ <CommandInput
172
+ placeholder="Busque aqui"
173
+ value={searchInput}
174
+ onValueChange={setSearchInput}
175
+ />
176
+ <CommandList
177
+ className="custom-scrollbar paragraph-small-medium text-gray-600 h-[162px]"
178
+ onScroll={handleScroll}
179
+ >
121
180
  {isLoading ? (
122
181
  <>
123
182
  <UserItemSkeleton />
124
183
  <UserItemSkeleton />
125
184
  <UserItemSkeleton />
126
185
  </>
127
- ) : users.length === 0 ? (
186
+ ) : allUsers.length === 0 ? (
128
187
  <div className="flex flex-col items-center justify-center gap-2 h-full text-gray-400 py-4">
129
188
  <Users size={28} className="text-gray-300" />
130
189
  <span className="paragraph-small-medium text-center text-gray-500">
@@ -136,7 +195,7 @@ function UsersSelectorPopoverContent({
136
195
  ) : (
137
196
  <>
138
197
  <CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
139
- {users.map((user) => (
198
+ {allUsers.map((user) => (
140
199
  <CommandItem
141
200
  key={user.id}
142
201
  value={user.name}
@@ -145,8 +204,8 @@ function UsersSelectorPopoverContent({
145
204
  onPointerLeave={() => setHoveredId(null)}
146
205
  onFocus={() => setHoveredId(user.id)}
147
206
  onBlur={() => setHoveredId(null)}
148
- onSelect={() => toggleUser(user.id)}
149
- className="cursor-pointer"
207
+ onSelect={() => toggleUser(user.id, user.checked, user.removable)}
208
+ className={user.removable ? 'cursor-pointer' : 'cursor-default'}
150
209
  >
151
210
  <div className="flex items-center justify-between w-full">
152
211
  <div className="flex items-center gap-2">
@@ -159,15 +218,16 @@ function UsersSelectorPopoverContent({
159
218
  />
160
219
  {user.name}
161
220
  </div>
162
- <Tooltip open={hoveredId === user.id}>
221
+ <Tooltip open={user.removable && hoveredId === user.id}>
163
222
  <TooltipTrigger
164
223
  onClick={(e) => e.stopPropagation()}
165
224
  onPointerDown={(e) => e.stopPropagation()}
166
225
  >
167
226
  <Checkbox
168
227
  checked={user.checked}
169
- onCheckedChange={() => toggleUser(user.id)}
170
- className={hoveredId === user.id ? 'border-gray-950' : ''}
228
+ disabled={!user.removable}
229
+ onCheckedChange={() => toggleUser(user.id, user.checked, user.removable)}
230
+ className={user.removable && hoveredId === user.id ? 'border-gray-950' : ''}
171
231
  />
172
232
  </TooltipTrigger>
173
233
  <TooltipContent>
@@ -177,6 +237,12 @@ function UsersSelectorPopoverContent({
177
237
  </div>
178
238
  </CommandItem>
179
239
  ))}
240
+ {isFetchingMore && (
241
+ <>
242
+ <UserItemSkeleton />
243
+ <UserItemSkeleton />
244
+ </>
245
+ )}
180
246
  </>
181
247
  )}
182
248
  </CommandList>
@@ -186,76 +252,3 @@ function UsersSelectorPopoverContent({
186
252
  </Popover>
187
253
  );
188
254
  }
189
-
190
- function ConnectedUsersSelectorPopover({
191
- projectId,
192
- addRoute,
193
- manageRoute,
194
- side,
195
- align,
196
- contentClassName,
197
- }: Required<Pick<UsersSelectorPopoverProps, 'projectId'>> &
198
- Pick<UsersSelectorPopoverProps, 'addRoute' | 'manageRoute' | 'side' | 'align' | 'contentClassName'>) {
199
- const { users, isLoading, userCount, toggleUser } = useProjectUsers(projectId);
200
- return (
201
- <UsersSelectorPopoverContent
202
- users={users}
203
- isLoading={isLoading}
204
- userCount={userCount}
205
- toggleUser={toggleUser}
206
- addRoute={addRoute ?? '/my-teams?createNew=true'}
207
- manageRoute={manageRoute ?? '/my-teams'}
208
- side={side}
209
- align={align}
210
- contentClassName={contentClassName}
211
- />
212
- );
213
- }
214
-
215
- function StaticUsersSelectorPopover({
216
- users: externalUsers = [],
217
- onToggleUser,
218
- userCount = 5,
219
- addRoute = '/my-teams?createNew=true',
220
- manageRoute = '/my-teams',
221
- side,
222
- align,
223
- contentClassName,
224
- }: Omit<UsersSelectorPopoverProps, 'projectId'>) {
225
- const [internalUsers, setInternalUsers] = useState<UserSelectorItem[]>(externalUsers);
226
-
227
- const users = externalUsers.length > 0 ? externalUsers : internalUsers;
228
-
229
- const toggleUser = (id: number) => {
230
- if (onToggleUser) {
231
- onToggleUser(id);
232
- } else {
233
- setInternalUsers((prev) =>
234
- prev.map((u) => (u.id === id ? { ...u, checked: !u.checked } : u))
235
- );
236
- }
237
- };
238
-
239
- return (
240
- <UsersSelectorPopoverContent
241
- users={users}
242
- isLoading={false}
243
- userCount={userCount}
244
- toggleUser={toggleUser}
245
- addRoute={addRoute}
246
- manageRoute={manageRoute}
247
- side={side}
248
- align={align}
249
- contentClassName={contentClassName}
250
- />
251
- );
252
- }
253
-
254
- function UsersSelectorPopover(props: UsersSelectorPopoverProps) {
255
- if (props.projectId != null) {
256
- return <ConnectedUsersSelectorPopover {...props} projectId={props.projectId} />;
257
- }
258
- return <StaticUsersSelectorPopover {...props} />;
259
- }
260
-
261
- export { UsersSelectorPopover };
package/src/index.ts CHANGED
@@ -10,9 +10,10 @@ export * from './providers/auth.provider';
10
10
  export * from './providers/whitelabel.provider';
11
11
 
12
12
  // Hooks
13
- export { useProjectUsers, projectUsersQueryKey } from './modules/projects/hooks/project-users.hook';
14
- export type { ProjectUser, AccountUser, AddRemoveProjectUsersParams } from './modules/projects/types';
15
- export { useUserQuery, useUserValidateSession, useInvalidateUser, useSetUserData, USER_QUERY_KEY } from './modules/auth/hooks/useUserQuery';
13
+ export { useListProjectUsers, LIST_PROJECT_USERS_QUERY_KEY, LIST_PROJECT_USERS_BASE_KEY } from './modules/projects/hooks/list-project-users.hook';
14
+ export { useListAvailableUsers, LIST_AVAILABLE_USERS_QUERY_KEY, LIST_AVAILABLE_USERS_BASE_KEY } from './modules/projects/hooks/list-available-users.hook';
15
+ export type { ProjectUser, AccountUser, AddRemoveProjectUsersParams, ListUsersParams, UsersPage } from './modules/projects/types';
16
+ export { useUserQuery, useUserValidateSession, useInvalidateUser, useSetUserData, useTwoFactorVerify, useTwoFactorPending, USER_QUERY_KEY } from './modules/auth/hooks/useUserQuery';
16
17
  export { useSubscriptions, SUBSCRIPTIONS_QUERY_KEY } from './modules/subscriptions/hooks/list-subscriptions.hook';
17
18
  export { useActiveSubscription } from './modules/subscriptions/hooks/find-active-subscription.hook';
18
19
  export { useIaCredits } from './modules/ia-credits/hooks/ia-credits.hook';
@@ -51,7 +52,7 @@ export type { NotificationItemProps } from './components/layouts/NotificationIte
51
52
  export { NotificationsPopover } from './components/layouts/NotificationsPopover';
52
53
  export type { NotificationsPopoverProps, NotificationData } from './components/layouts/NotificationsPopover';
53
54
  export { UsersSelectorPopover } from './components/layouts/UsersSelectorPopover';
54
- export type { UsersSelectorPopoverProps, UserSelectorItem } from './components/layouts/UsersSelectorPopover';
55
+ export type { UsersSelectorPopoverProps } from './components/layouts/UsersSelectorPopover';
55
56
  export { ProfilePopover } from './components/layouts/ProfilePopover';
56
57
  export type { ProfilePopoverProps, ProfileMenuItem } from './components/layouts/ProfilePopover';
57
58
  export { NavBarItem } from './components/layouts/NavBarItem';
@@ -0,0 +1,7 @@
1
+ 'use server';
2
+
3
+ import { authService } from '../services/auth.service';
4
+
5
+ export async function isTwoFactorPendingAction(): Promise<boolean> {
6
+ return authService.isTwoFactorPending();
7
+ }
@@ -0,0 +1,15 @@
1
+ 'use server';
2
+
3
+ import { authService } from '../services/auth.service';
4
+ import { ApiError } from '../../../infra/api/types';
5
+ import { getClientInfoFromRequest } from '../../../infra/utils/client-info';
6
+
7
+ export async function verifyTwoFactorAction(code: number): Promise<{ success: boolean }> {
8
+ if (!Number.isInteger(code) || code < 0 || code > 999999) {
9
+ throw new ApiError('Código inválido', 'INVALID_CODE', 400);
10
+ }
11
+
12
+ const clientInfo = await getClientInfoFromRequest();
13
+ await authService.verifyTwoFactor(code, clientInfo);
14
+ return { success: true };
15
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
3
+ import { useMutation, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
4
4
  import { User } from '../../users/schema';
5
5
 
6
6
  export const USER_QUERY_KEY = ['user'];
@@ -38,6 +38,33 @@ export function useUserValidateSession(options?: UseQueryOptions) {
38
38
  });
39
39
  }
40
40
 
41
+ export function useTwoFactorVerify() {
42
+ const queryClient = useQueryClient();
43
+
44
+ return useMutation({
45
+ mutationFn: async (code: number) => {
46
+ const { verifyTwoFactorAction } = await import('../actions/verify-two-factor.action');
47
+ return verifyTwoFactorAction(code);
48
+ },
49
+ onSuccess: () => {
50
+ queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
51
+ queryClient.invalidateQueries({ queryKey: ['two-factor-pending'] });
52
+ },
53
+ });
54
+ }
55
+
56
+ export function useTwoFactorPending() {
57
+ return useQuery({
58
+ queryKey: ['two-factor-pending'],
59
+ queryFn: async () => {
60
+ const { isTwoFactorPendingAction } = await import('../actions/is-two-factor-pending.action');
61
+ return isTwoFactorPendingAction();
62
+ },
63
+ staleTime: 10 * 1000,
64
+ retry: false,
65
+ });
66
+ }
67
+
41
68
  export function useInvalidateUser() {
42
69
  const queryClient = useQueryClient();
43
70
  return () => queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
@@ -15,6 +15,29 @@ export interface LoginApiResponse {
15
15
  two_factor_authentication?: boolean;
16
16
  }
17
17
 
18
+ export type LoginResponse =
19
+ | { twoFactorRequired: true }
20
+ | { twoFactorRequired?: false; user: User; accessToken: string; refreshToken: string; expiresAt: string };
21
+
22
+ export interface TwoFactorRequest {
23
+ location: GeoLocation;
24
+ ip: string;
25
+ timezone: string;
26
+ agent: string;
27
+ cookie: string;
28
+ code: number;
29
+ }
30
+
31
+ export interface TwoFactorApiResponse {
32
+ status: 0 | 1;
33
+ message: string;
34
+ cookie?: string;
35
+ }
36
+
37
+ export interface TwoFactorResponse {
38
+ success: boolean;
39
+ }
40
+
18
41
  export interface GeoLocation {
19
42
  continent: string;
20
43
  country: string;
@@ -37,13 +60,6 @@ export interface LoginRequest {
37
60
  rememberMe?: boolean;
38
61
  }
39
62
 
40
- export interface LoginResponse {
41
- user: User;
42
- accessToken: string;
43
- refreshToken: string;
44
- expiresAt: string;
45
- }
46
-
47
63
  export interface RegisterRequest {
48
64
  accountName: string;
49
65
  businessType: number;
@@ -25,34 +25,34 @@ import {
25
25
  ResetPasswordResponse,
26
26
  SessionKeepRequest,
27
27
  SessionKeepResponse,
28
+ TwoFactorApiResponse,
29
+ TwoFactorRequest,
30
+ TwoFactorResponse,
28
31
  VerifyEmailRequest,
29
32
  VerifyEmailResponse,
30
33
  } from '../schema';
31
34
 
32
35
  const AUTH_COOKIE_NAME = 'greatapps';
36
+ const PENDING_2FA_COOKIE_NAME = 'greatapps_2fa_pending';
33
37
  const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
38
+ const PENDING_2FA_MAX_AGE = 60 * 10; // 10 minutos para completar o 2FA
39
+
40
+ const COOKIE_OPTIONS = {
41
+ httpOnly: true,
42
+ secure: process.env.NODE_ENV === 'production',
43
+ sameSite: 'lax' as const,
44
+ path: '/',
45
+ domain: process.env.DOMAIN_COOKIE,
46
+ };
34
47
 
35
48
  async function setAuthCookie(token: string): Promise<void> {
36
49
  const cookieStore = await cookies();
37
- cookieStore.set(AUTH_COOKIE_NAME, token, {
38
- httpOnly: true,
39
- secure: process.env.NODE_ENV === 'production',
40
- sameSite: 'lax',
41
- maxAge: COOKIE_MAX_AGE,
42
- path: '/',
43
- domain: process.env.DOMAIN_COOKIE
44
- });
50
+ cookieStore.set(AUTH_COOKIE_NAME, token, { ...COOKIE_OPTIONS, maxAge: COOKIE_MAX_AGE });
45
51
  }
46
52
 
47
53
  async function removeAuthCookie(): Promise<void> {
48
54
  const cookieStore = await cookies();
49
- cookieStore.delete({
50
- name: AUTH_COOKIE_NAME,
51
- path: "/",
52
- secure: process.env.NODE_ENV === 'production',
53
- sameSite: 'lax',
54
- domain: process.env.DOMAIN_COOKIE,
55
- });
55
+ cookieStore.delete({ name: AUTH_COOKIE_NAME, ...COOKIE_OPTIONS });
56
56
  }
57
57
 
58
58
  async function getAuthCookie(): Promise<string | undefined> {
@@ -61,6 +61,21 @@ async function getAuthCookie(): Promise<string | undefined> {
61
61
  return cookieStore.get(AUTH_COOKIE_NAME)?.value;
62
62
  }
63
63
 
64
+ async function setPending2FACookie(token: string): Promise<void> {
65
+ const cookieStore = await cookies();
66
+ cookieStore.set(PENDING_2FA_COOKIE_NAME, token, { ...COOKIE_OPTIONS, maxAge: PENDING_2FA_MAX_AGE });
67
+ }
68
+
69
+ async function removePending2FACookie(): Promise<void> {
70
+ const cookieStore = await cookies();
71
+ cookieStore.delete({ name: PENDING_2FA_COOKIE_NAME, ...COOKIE_OPTIONS });
72
+ }
73
+
74
+ async function getPending2FACookie(): Promise<string | undefined> {
75
+ const cookieStore = await cookies();
76
+ return cookieStore.get(PENDING_2FA_COOKIE_NAME)?.value;
77
+ }
78
+
64
79
  class AuthService {
65
80
  async login(credentials: LoginRequest, clientInfo: ClientInfo): Promise<LoginResponse> {
66
81
  const response = await apiClient.post<LoginApiResponse>('/auth/login', {
@@ -80,17 +95,59 @@ class AuthService {
80
95
  throw new ApiError('Resposta de autenticação inválida', 'INVALID_RESPONSE', 500);
81
96
  }
82
97
 
98
+ if (response.two_factor_authentication) {
99
+ // Armazena o cookie temporariamente até o 2FA ser concluído.
100
+ // O cookie principal NÃO é setado aqui para evitar falso positivo em isAuthenticated().
101
+ await setPending2FACookie(response.cookie);
102
+ return { twoFactorRequired: true };
103
+ }
104
+
83
105
  await setAuthCookie(response.cookie);
84
106
 
85
- // O usuário completo será carregado pelo AuthProvider via getUserDataAction
86
107
  return {
87
- user: {} as User, // Placeholder, será preenchido pelo AuthProvider
108
+ user: {} as User,
88
109
  accessToken: response.cookie,
89
110
  refreshToken: '',
90
111
  expiresAt: new Date(Date.now() + COOKIE_MAX_AGE * 1000).toISOString(),
91
112
  };
92
113
  }
93
114
 
115
+ async verifyTwoFactor(code: number, clientInfo: ClientInfo): Promise<TwoFactorResponse> {
116
+ const pendingCookie = await getPending2FACookie();
117
+
118
+ if (!pendingCookie) {
119
+ throw new ApiError('Nenhuma autenticação 2FA pendente', 'NO_PENDING_2FA', 400);
120
+ }
121
+
122
+ const payload: TwoFactorRequest = {
123
+ location: clientInfo.location,
124
+ ip: clientInfo.ip,
125
+ timezone: clientInfo.timezone,
126
+ agent: clientInfo.agent,
127
+ cookie: pendingCookie,
128
+ code,
129
+ };
130
+
131
+ const response = await apiClient.post<TwoFactorApiResponse>('/auth/code', payload);
132
+
133
+ if (response.status === 0) {
134
+ throw new ApiError(response.message || 'Código 2FA inválido', 'TWO_FACTOR_FAILED', 401);
135
+ }
136
+
137
+ if (!response.cookie) {
138
+ throw new ApiError('Resposta de autenticação inválida após 2FA', 'INVALID_RESPONSE', 500);
139
+ }
140
+
141
+ await Promise.all([setAuthCookie(response.cookie), removePending2FACookie()]);
142
+
143
+ return { success: true };
144
+ }
145
+
146
+ async isTwoFactorPending(): Promise<boolean> {
147
+ const pending = await getPending2FACookie();
148
+ return !!pending;
149
+ }
150
+
94
151
  async register(data: RegisterRequest): Promise<RegisterResponse> {
95
152
  const today = new Date();
96
153
  const trialEndDate = new Date(today);
@@ -170,8 +227,7 @@ class AuthService {
170
227
  throw new ApiError(response.message || 'Erro ao realizar logout', 'LOGOUT_FAILED', 400);
171
228
  }
172
229
  } finally {
173
- console.log('[AuthService] Logout - removendo cookie localmente');
174
- await removeAuthCookie();
230
+ await Promise.all([removeAuthCookie(), removePending2FACookie()]);
175
231
  }
176
232
 
177
233
  return {
@@ -1,8 +1,8 @@
1
1
  'use server';
2
2
 
3
3
  import { projectUsersService } from '../services/project-users.service';
4
- import type { AccountUser } from '../types';
4
+ import type { AccountUser, ListUsersParams, UsersPage } from '../types';
5
5
 
6
- export async function listAvailableUsersAction(id_project: number): Promise<AccountUser[]> {
7
- return projectUsersService.listNotInProject(id_project);
6
+ export async function listAvailableUsersAction(projectId: number, params: ListUsersParams = {}): Promise<UsersPage<AccountUser>> {
7
+ return projectUsersService.listNotInProject(projectId, params);
8
8
  }
@@ -1,8 +1,8 @@
1
1
  'use server';
2
2
 
3
3
  import { projectUsersService } from '../services/project-users.service';
4
- import type { ProjectUser } from '../types';
4
+ import type { ListUsersParams, ProjectUser, UsersPage } from '../types';
5
5
 
6
- export async function listProjectUsersAction(id_project: number): Promise<ProjectUser[]> {
7
- return projectUsersService.listInProject(id_project);
6
+ export async function listProjectUsersAction(projectId: number, params: ListUsersParams = {}): Promise<UsersPage<ProjectUser>> {
7
+ return projectUsersService.listInProject(projectId, params);
8
8
  }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { useInfiniteQuery } from '@tanstack/react-query';
4
+ import { listAvailableUsersAction } from '../actions/list-available-users.action';
5
+
6
+ const PAGE_SIZE = 20;
7
+
8
+ export const LIST_AVAILABLE_USERS_BASE_KEY = (projectId: number) => ['project-users', projectId, 'out'];
9
+ export const LIST_AVAILABLE_USERS_QUERY_KEY = (projectId: number, search: string = '') => [
10
+ ...LIST_AVAILABLE_USERS_BASE_KEY(projectId),
11
+ search,
12
+ ];
13
+
14
+ export function useListAvailableUsers(projectId: number, search: string = '') {
15
+ return useInfiniteQuery({
16
+ queryKey: LIST_AVAILABLE_USERS_QUERY_KEY(projectId, search),
17
+ queryFn: ({ pageParam }) =>
18
+ listAvailableUsersAction(projectId, { search: search || undefined, page: pageParam as number, limit: PAGE_SIZE }),
19
+ getNextPageParam: (lastPage, allPages) => {
20
+ const loaded = allPages.reduce((sum, p) => sum + p.data.length, 0);
21
+ return loaded < lastPage.total ? allPages.length + 1 : undefined;
22
+ },
23
+ initialPageParam: 1,
24
+ enabled: !!projectId,
25
+ });
26
+ }