@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.
- package/dist/components/layouts/UsersSelectorPopover.mjs +155 -139
- package/dist/components/layouts/UsersSelectorPopover.mjs.map +1 -1
- package/dist/index.mjs +11 -4
- package/dist/index.mjs.map +1 -1
- package/dist/modules/auth/actions/is-two-factor-pending.action.mjs +9 -0
- package/dist/modules/auth/actions/is-two-factor-pending.action.mjs.map +1 -0
- package/dist/modules/auth/actions/verify-two-factor.action.mjs +16 -0
- package/dist/modules/auth/actions/verify-two-factor.action.mjs.map +1 -0
- package/dist/modules/auth/hooks/useUserQuery.mjs +27 -1
- package/dist/modules/auth/hooks/useUserQuery.mjs.map +1 -1
- package/dist/modules/auth/services/auth.service.mjs +55 -18
- package/dist/modules/auth/services/auth.service.mjs.map +1 -1
- package/dist/modules/projects/actions/list-available-users.action.mjs +2 -2
- package/dist/modules/projects/actions/list-available-users.action.mjs.map +1 -1
- package/dist/modules/projects/actions/list-project-users.action.mjs +2 -2
- package/dist/modules/projects/actions/list-project-users.action.mjs.map +1 -1
- package/dist/modules/projects/hooks/list-available-users.hook.mjs +27 -0
- package/dist/modules/projects/hooks/list-available-users.hook.mjs.map +1 -0
- package/dist/modules/projects/hooks/list-project-users.hook.mjs +27 -0
- package/dist/modules/projects/hooks/list-project-users.hook.mjs.map +1 -0
- package/dist/modules/projects/services/project-users.service.mjs +21 -10
- package/dist/modules/projects/services/project-users.service.mjs.map +1 -1
- package/dist/modules/projects/types.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/layouts/UsersSelectorPopover.tsx +119 -126
- package/src/index.ts +5 -4
- package/src/modules/auth/actions/is-two-factor-pending.action.ts +7 -0
- package/src/modules/auth/actions/verify-two-factor.action.ts +15 -0
- package/src/modules/auth/hooks/useUserQuery.ts +28 -1
- package/src/modules/auth/schema.ts +23 -7
- package/src/modules/auth/services/auth.service.ts +75 -19
- package/src/modules/projects/actions/list-available-users.action.ts +3 -3
- package/src/modules/projects/actions/list-project-users.action.ts +3 -3
- package/src/modules/projects/hooks/list-available-users.hook.ts +26 -0
- package/src/modules/projects/hooks/list-project-users.hook.ts +26 -0
- package/src/modules/projects/services/project-users.service.ts +23 -10
- package/src/modules/projects/types.ts +13 -1
- package/dist/modules/projects/hooks/project-users.hook.mjs +0 -70
- package/dist/modules/projects/hooks/project-users.hook.mjs.map +0 -1
- 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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
35
|
-
projectId
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
}:
|
|
69
|
-
const
|
|
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">{
|
|
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={() =>
|
|
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={() =>
|
|
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
|
|
120
|
-
|
|
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
|
-
) :
|
|
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
|
-
{
|
|
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=
|
|
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
|
-
|
|
170
|
-
|
|
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 {
|
|
14
|
-
export
|
|
15
|
-
export {
|
|
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
|
|
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,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,
|
|
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
|
-
|
|
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(
|
|
7
|
-
return projectUsersService.listNotInProject(
|
|
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(
|
|
7
|
-
return projectUsersService.listInProject(
|
|
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
|
+
}
|