diff --git a/wavefront/client/src/api/user-service.ts b/wavefront/client/src/api/user-service.ts index 0e52327c..72cf1557 100644 --- a/wavefront/client/src/api/user-service.ts +++ b/wavefront/client/src/api/user-service.ts @@ -23,4 +23,33 @@ export class UserService { }, }); } + + async listUsers(): Promise> { + return this.http.get('/v1/users'); + } + + async createUser(data: { + email: string; + password: string; + first_name: string; + last_name: string; + }): Promise> { + return this.http.post('/v1/users', data); + } + + async updateUser( + userId: string, + data: { + email?: string; + password?: string; + first_name?: string; + last_name?: string; + } + ): Promise> { + return this.http.patch(`/v1/users/${userId}`, data); + } + + async deleteUser(userId: string): Promise> { + return this.http.delete(`/v1/users/${userId}`); + } } diff --git a/wavefront/client/src/components/topbar/Topbar.tsx b/wavefront/client/src/components/topbar/Topbar.tsx index 42db56fa..256f58e3 100644 --- a/wavefront/client/src/components/topbar/Topbar.tsx +++ b/wavefront/client/src/components/topbar/Topbar.tsx @@ -12,7 +12,7 @@ import { IUser } from '@app/pages/types'; import { useAuthStore } from '@app/store'; import { useDashboardStore } from '@app/store/dashboard-store'; import { App } from '@app/types/app'; -import { UserIcon } from 'lucide-react'; +import { UserIcon, Settings } from 'lucide-react'; import { useEffect } from 'react'; import { useNavigate } from 'react-router'; @@ -75,6 +75,13 @@ const Topbar = ({ user, apps = [] }: { user: IUser; apps: App[] }) => { )}{' '} +
diff --git a/wavefront/client/src/hooks/data/fetch-hooks.ts b/wavefront/client/src/hooks/data/fetch-hooks.ts index f5ad49c1..41da45a3 100644 --- a/wavefront/client/src/hooks/data/fetch-hooks.ts +++ b/wavefront/client/src/hooks/data/fetch-hooks.ts @@ -59,6 +59,7 @@ import { getWorkflowPipelinesQueryFn, getWorkflowRunsQueryFn, getWorkflowsQueryFn, + getUsersQueryFn, readYamlQueryFn, } from './query-functions'; import { @@ -101,6 +102,7 @@ import { getWorkflowPipelinesKey, getWorkflowRunsKey, getWorkflowsKey, + getUsersKey, readYamlKey, } from './query-keys'; @@ -408,3 +410,7 @@ export const useGetPipelineFiles = ( export const useGetAppById = (appId: string, enabled: boolean = true): UseQueryResult => { return useQueryInit(getAppByIdKey(appId), () => getAppByIdFn(appId), enabled); }; + +export const useGetUsers = (): UseQueryResult => { + return useQueryInit(getUsersKey(), getUsersQueryFn, true); +}; diff --git a/wavefront/client/src/hooks/data/mutation-functions.ts b/wavefront/client/src/hooks/data/mutation-functions.ts index 9c1874ea..59e8a264 100644 --- a/wavefront/client/src/hooks/data/mutation-functions.ts +++ b/wavefront/client/src/hooks/data/mutation-functions.ts @@ -1,4 +1,5 @@ import floConsoleService from '@app/api'; +import { IUser } from '@app/types/user'; /** * Agent mutation functions @@ -31,3 +32,42 @@ export const updateAppFn = async (data: { }); return response.data; }; + +/** + * User mutation functions + */ +export const createUserMutationFn = async (data: { + email: string; + password: string; + first_name: string; + last_name: string; +}): Promise => { + const response = await floConsoleService.userService.createUser(data); + if (!response.data.data) { + throw new Error('Failed to create user'); + } + return response.data.data.user; +}; + +export const updateUserMutationFn = async ({ + userId, + data, +}: { + userId: string; + data: { + email?: string; + password?: string; + first_name?: string; + last_name?: string; + }; +}): Promise => { + const response = await floConsoleService.userService.updateUser(userId, data); + if (!response.data.data) { + throw new Error('Failed to update user'); + } + return response.data.data.user; +}; + +export const deleteUserMutationFn = async (userId: string): Promise => { + await floConsoleService.userService.deleteUser(userId); +}; diff --git a/wavefront/client/src/hooks/data/mutation-hooks.ts b/wavefront/client/src/hooks/data/mutation-hooks.ts index b211950a..287bec1f 100644 --- a/wavefront/client/src/hooks/data/mutation-hooks.ts +++ b/wavefront/client/src/hooks/data/mutation-hooks.ts @@ -1,7 +1,15 @@ import { QueryClient, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getAgentKey, getAgentsKey, getAppByIdKey } from './query-keys'; -import { deleteAgentMutationFn, updateAgentMutationFn, updateAppFn } from './mutation-functions'; +import { getAgentKey, getAgentsKey, getAppByIdKey, getUserKey, getUsersKey } from './query-keys'; +import { + createUserMutationFn, + deleteAgentMutationFn, + deleteUserMutationFn, + updateAgentMutationFn, + updateAppFn, + updateUserMutationFn, +} from './mutation-functions'; import { useNotifyStore } from '@app/store'; +import { extractErrorMessage } from '@app/lib/utils'; /** * Hook for deleting an agent @@ -70,3 +78,63 @@ export const useUpdateApp = ( }, }); }; + +/** + * User mutation hooks + */ +export const useCreateUser = () => { + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useNotifyStore(); + + return useMutation({ + mutationFn: createUserMutationFn, + onSuccess: () => { + notifySuccess('User created successfully'); + queryClient.invalidateQueries({ queryKey: getUsersKey() }); + }, + onError: (error) => { + console.error('Error creating user:', error); + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to create user'); + }, + }); +}; + +export const useUpdateUser = (userId: string | undefined) => { + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useNotifyStore(); + + return useMutation({ + mutationFn: updateUserMutationFn, + onSuccess: () => { + notifySuccess('User updated successfully'); + queryClient.invalidateQueries({ queryKey: getUsersKey() }); + if (userId) { + queryClient.invalidateQueries({ queryKey: getUserKey(userId) }); + } + }, + onError: (error) => { + console.error('Error updating user:', error); + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to update user'); + }, + }); +}; + +export const useDeleteUser = () => { + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useNotifyStore(); + + return useMutation({ + mutationFn: deleteUserMutationFn, + onSuccess: () => { + notifySuccess('User deleted successfully'); + queryClient.invalidateQueries({ queryKey: getUsersKey() }); + }, + onError: (error) => { + console.error('Error deleting user:', error); + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to delete user'); + }, + }); +}; diff --git a/wavefront/client/src/hooks/data/query-functions.ts b/wavefront/client/src/hooks/data/query-functions.ts index 39f7e225..a7a12619 100644 --- a/wavefront/client/src/hooks/data/query-functions.ts +++ b/wavefront/client/src/hooks/data/query-functions.ts @@ -13,6 +13,7 @@ import { SttConfig } from '@app/types/stt-config'; import { TelephonyConfig } from '@app/types/telephony-config'; import { ToolDetails } from '@app/types/tool'; import { TtsConfig } from '@app/types/tts-config'; +import { IUser } from '@app/types/user'; import { VoiceAgent } from '@app/types/voice-agent'; import { WorkflowListItem, WorkflowPipelineListItem, WorkflowRunListData } from '@app/types/workflow'; @@ -370,6 +371,14 @@ const getAppByIdFn = async (appId: string) => { return data?.app; }; +const getUsersQueryFn = async (): Promise => { + const response = await floConsoleService.userService.listUsers(); + if (response.data?.data?.users && Array.isArray(response.data.data.users)) { + return response.data.data.users; + } + return []; +}; + export { getAgentQueryFn, getAgentsQueryFn, @@ -410,5 +419,6 @@ export { getWorkflowPipelinesQueryFn, getWorkflowRunsQueryFn, getWorkflowsQueryFn, + getUsersQueryFn, readYamlQueryFn, }; diff --git a/wavefront/client/src/hooks/data/query-keys.ts b/wavefront/client/src/hooks/data/query-keys.ts index 30e0d88e..4f9dae8c 100644 --- a/wavefront/client/src/hooks/data/query-keys.ts +++ b/wavefront/client/src/hooks/data/query-keys.ts @@ -62,6 +62,8 @@ const getPipelinesKey = (appId: string, statusFilter?: string) => { const getPipelineKey = (appId: string, pipelineId: string) => ['pipeline', appId, pipelineId]; const getPipelineFilesKey = (appId: string, pipelineId: string) => ['pipeline-files', appId, pipelineId]; const getAppByIdKey = (appId: string) => ['app-by-id', appId]; +const getUsersKey = () => ['users']; +const getUserKey = (userId: string) => ['user', userId]; export { getAgentKey, @@ -104,4 +106,6 @@ export { getWorkflowRunsKey, getWorkflowsKey, getAppByIdKey, + getUserKey, + getUsersKey, }; diff --git a/wavefront/client/src/pages/apps/layout.tsx b/wavefront/client/src/pages/apps/layout.tsx index c398a85c..323402fa 100644 --- a/wavefront/client/src/pages/apps/layout.tsx +++ b/wavefront/client/src/pages/apps/layout.tsx @@ -4,7 +4,6 @@ import { DatasourcesIcon, ModelInferenceIcon, ModelRepositoryIcon, - // PermissionIcon, PhoneIcon, RagIcon, WorkflowIcon, diff --git a/wavefront/client/src/pages/apps/users/CreateUserDialog.tsx b/wavefront/client/src/pages/apps/users/CreateUserDialog.tsx new file mode 100644 index 00000000..06d104a8 --- /dev/null +++ b/wavefront/client/src/pages/apps/users/CreateUserDialog.tsx @@ -0,0 +1,147 @@ +import floConsoleService from '@app/api'; +import { Button } from '@app/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@app/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@app/components/ui/form'; +import { Input } from '@app/components/ui/input'; +import { extractErrorMessage } from '@app/lib/utils'; +import { useNotifyStore } from '@app/store'; +import { CreateUserInput, createUserSchema } from '@app/types/user'; +import { zodResolver } from '@hookform/resolvers/zod'; +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +interface CreateUserDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +const CreateUserDialog: React.FC = ({ isOpen, onOpenChange, onSuccess }) => { + const { notifySuccess, notifyError } = useNotifyStore(); + + const form = useForm({ + resolver: zodResolver(createUserSchema), + defaultValues: { + email: '', + password: '', + first_name: '', + last_name: '', + }, + }); + + // Reset form when dialog closes + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + const onSubmit = async (data: CreateUserInput) => { + try { + await floConsoleService.userService.createUser(data); + notifySuccess('User created successfully'); + onSuccess?.(); + onOpenChange(false); + } catch (error) { + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to create user'); + } + }; + + return ( + + + + Create New User + Add a new user to the system + + +
+ + ( + + + Email * + + + + + + + )} + /> + + ( + + + Password * + + + + + + + )} + /> + + ( + + + First Name * + + + + + + + )} + /> + + ( + + + Last Name * + + + + + + + )} + /> + + + + + + + +
+
+ ); +}; + +export default CreateUserDialog; diff --git a/wavefront/client/src/pages/apps/users/EditUserDialog.tsx b/wavefront/client/src/pages/apps/users/EditUserDialog.tsx new file mode 100644 index 00000000..3977f466 --- /dev/null +++ b/wavefront/client/src/pages/apps/users/EditUserDialog.tsx @@ -0,0 +1,180 @@ +import floConsoleService from '@app/api'; +import { Button } from '@app/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@app/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@app/components/ui/form'; +import { Input } from '@app/components/ui/input'; +import { extractErrorMessage } from '@app/lib/utils'; +import { useNotifyStore } from '@app/store'; +import { IUser, UpdateUserInput, updateUserSchema } from '@app/types/user'; +import { zodResolver } from '@hookform/resolvers/zod'; +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +interface EditUserDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + user: IUser; + onSuccess?: () => void; +} + +const EditUserDialog: React.FC = ({ isOpen, onOpenChange, user, onSuccess }) => { + const { notifySuccess, notifyError } = useNotifyStore(); + + const form = useForm({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + email: user.email, + password: '', + first_name: user.first_name, + last_name: user.last_name, + }, + }); + + // Reset form when user changes + useEffect(() => { + if (isOpen && user) { + form.reset({ + email: user.email, + password: '', + first_name: user.first_name, + last_name: user.last_name, + }); + } + }, [isOpen, user, form]); + + const onSubmit = async (data: UpdateUserInput) => { + try { + // Filter out empty values + const updateData: { + email?: string; + password?: string; + first_name?: string; + last_name?: string; + } = {}; + + if (data.email && data.email !== user.email) { + updateData.email = data.email; + } + if (data.password && data.password.trim()) { + updateData.password = data.password; + } + if (data.first_name && data.first_name !== user.first_name) { + updateData.first_name = data.first_name; + } + if (data.last_name && data.last_name !== user.last_name) { + updateData.last_name = data.last_name; + } + + if (Object.keys(updateData).length === 0) { + notifyError('No changes to update'); + return; + } + + await floConsoleService.userService.updateUser(user.id, updateData); + notifySuccess('User updated successfully'); + onSuccess?.(); + onOpenChange(false); + } catch (error) { + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to update user'); + } + }; + + return ( + + + + Edit User + Update user information + + +
+ + ( + + Email + + + + + + )} + /> + + ( + + Password (Optional) + + + + Only fill this if you want to change the password + + + )} + /> + + ( + + First Name + + + + + + )} + /> + + ( + + Last Name + + + + + + )} + /> + + + + + + + +
+
+ ); +}; + +export default EditUserDialog; diff --git a/wavefront/client/src/pages/apps/users/index.tsx b/wavefront/client/src/pages/apps/users/index.tsx new file mode 100644 index 00000000..a252c36c --- /dev/null +++ b/wavefront/client/src/pages/apps/users/index.tsx @@ -0,0 +1,216 @@ +import floConsoleService from '@app/api'; +import DeleteConfirmationDialog from '@app/components/DeleteConfirmationDialog'; +import { EmptyStateCard } from '@app/components/EmptyCard'; +import { Button } from '@app/components/ui/button'; +import { Input } from '@app/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@app/components/ui/table'; +import { useGetCurrentUser, useGetUsers } from '@app/hooks'; +import { getUsersKey } from '@app/hooks/data/query-keys'; +import { extractErrorMessage } from '@app/lib/utils'; +import { useNotifyStore } from '@app/store'; +import { IUser } from '@app/types/user'; +import { useQueryClient } from '@tanstack/react-query'; +import { Pencil, Trash2 } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import CreateUserDialog from './CreateUserDialog'; +import EditUserDialog from './EditUserDialog'; + +const UsersPage: React.FC = () => { + const queryClient = useQueryClient(); + const { notifySuccess, notifyError } = useNotifyStore(); + + const [searchQuery, setSearchQuery] = useState(''); + const [deleteItem, setDeleteItem] = useState(null); + const [deleting, setDeleting] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editItem, setEditItem] = useState(null); + + // Fetch users and current user + const { data: users = [], isLoading: usersLoading } = useGetUsers(); + const { data: currentUser } = useGetCurrentUser(true); + + // Determine permissions - placeholder for super admin check + // In a real implementation, this should come from the backend + const isSuperAdmin = useMemo(() => { + // Placeholder: assume all users with access to this page are super admins + // In production, add a dedicated endpoint or include this in whoami response + return true; + }, [currentUser]); + + const canEditUser = (user: IUser): boolean => { + if (!currentUser) return false; + // User can edit themselves, or super admin can edit anyone + return user.id === currentUser.id || isSuperAdmin; + }; + + const canDeleteUser = (user: IUser): boolean => { + if (!currentUser) return false; + // Only super admin can delete users + // Cannot delete self + return isSuperAdmin && user.id !== currentUser.id; + }; + + const handleDeleteClick = (e: React.MouseEvent, user: IUser) => { + e.stopPropagation(); + setDeleteItem(user); + }; + + const handleEditClick = (e: React.MouseEvent, user: IUser) => { + e.stopPropagation(); + setEditItem(user); + }; + + const handleEditSuccess = () => { + queryClient.invalidateQueries({ queryKey: getUsersKey() }); + setEditItem(null); + }; + + const handleDelete = async () => { + if (!deleteItem) return; + + setDeleting(true); + try { + await floConsoleService.userService.deleteUser(deleteItem.id); + notifySuccess('User deleted successfully'); + queryClient.invalidateQueries({ queryKey: getUsersKey() }); + setDeleteItem(null); + } catch (error) { + const errorMessage = extractErrorMessage(error); + notifyError(errorMessage || 'Failed to delete user'); + } finally { + setDeleting(false); + } + }; + + const handleDeleteCancel = () => { + setDeleteItem(null); + }; + + const handleCreateUser = () => { + setCreateDialogOpen(true); + }; + + const handleCreateSuccess = () => { + queryClient.invalidateQueries({ queryKey: getUsersKey() }); + setCreateDialogOpen(false); + }; + + const filteredUsers = users.filter((user) => { + const query = searchQuery.toLowerCase(); + return ( + user.email.toLowerCase().includes(query) || + user.first_name.toLowerCase().includes(query) || + user.last_name.toLowerCase().includes(query) + ); + }); + + return ( +
+
+
+

Users

+

Manage user accounts

+
+
+ setSearchQuery(e.target.value)} + className="w-[220px]" + /> + {isSuperAdmin && ( + + )} +
+
+ + {usersLoading ? ( +
+
Loading users...
+
+ ) : filteredUsers.length === 0 ? ( +
+ {isSuperAdmin ? ( + + ) : ( + {}} + /> + )} +
+ ) : ( +
+ + + + Email + First Name + Last Name + Actions + + + + {filteredUsers.map((user) => ( + + {user.email} + {user.first_name} + {user.last_name} + +
+ {canEditUser(user) && ( + + )} + {canDeleteUser(user) && ( + + )} +
+
+
+ ))} +
+
+
+ )} + + {/* Delete Confirmation Dialog */} + + + {/* Create User Dialog */} + + + {/* Edit User Dialog */} + {editItem && ( + !open && setEditItem(null)} + user={editItem} + onSuccess={handleEditSuccess} + /> + )} +
+ ); +}; + +export default UsersPage; diff --git a/wavefront/client/src/router/routes.tsx b/wavefront/client/src/router/routes.tsx index 897b34b4..d2a2dcc5 100644 --- a/wavefront/client/src/router/routes.tsx +++ b/wavefront/client/src/router/routes.tsx @@ -25,6 +25,7 @@ import WorkflowDetail from '@app/pages/apps/[appId]/workflows/[id]'; import WorkflowsLayout from '@app/pages/apps/[appId]/workflows/layout'; import WorkflowPipelinesPage from '@app/pages/apps/[appId]/workflows/pipelines'; import WorkflowPipelineDetail from '@app/pages/apps/[appId]/workflows/pipelines/[workflowPipelineId]'; +import UsersPage from '@app/pages/apps/users'; import CreateApp from '@app/pages/apps/create'; import EditApp from '@app/pages/apps/edit/[appId]'; import AppLayout from '@app/pages/apps/layout'; @@ -65,6 +66,10 @@ const routes = { path: '/logout', element: , }, + { + path: '/apps/users', + element: , + }, { path: 'apps/:app', element: , diff --git a/wavefront/client/src/types/user.ts b/wavefront/client/src/types/user.ts index 0b4cad05..e89cab0f 100644 --- a/wavefront/client/src/types/user.ts +++ b/wavefront/client/src/types/user.ts @@ -4,3 +4,41 @@ export interface IUser { first_name: string; last_name: string; } + +import { emailRegex, passwordRegex } from '@app/utils/regex'; +import { z } from 'zod'; + +export const createUserSchema = z.object({ + email: z.string().min(1, 'Email is required').regex(emailRegex, 'Invalid email format'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordRegex, 'Password must contain uppercase, lowercase, number, and special character'), + first_name: z.string().min(1, 'First name is required').max(100, 'First name must be 100 characters or less'), + last_name: z.string().min(1, 'Last name is required').max(100, 'Last name must be 100 characters or less'), +}); + +export const updateUserSchema = z.object({ + email: z.string().regex(emailRegex, 'Invalid email format').optional().or(z.literal('')), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordRegex, 'Password must contain uppercase, lowercase, number, and special character') + .optional() + .or(z.literal('')), + first_name: z + .string() + .min(1, 'First name is required') + .max(100, 'First name must be 100 characters or less') + .optional() + .or(z.literal('')), + last_name: z + .string() + .min(1, 'Last name is required') + .max(100, 'Last name must be 100 characters or less') + .optional() + .or(z.literal('')), +}); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; diff --git a/wavefront/server/apps/floconsole/floconsole/services/user_service.py b/wavefront/server/apps/floconsole/floconsole/services/user_service.py index 23873882..37ed33fe 100644 --- a/wavefront/server/apps/floconsole/floconsole/services/user_service.py +++ b/wavefront/server/apps/floconsole/floconsole/services/user_service.py @@ -30,7 +30,7 @@ async def update_user(self, user_id: UUID, **update_data) -> Optional[User]: async def delete_user(self, user_id: UUID) -> Optional[User]: """Soft delete user by ID""" result = await self.user_repository.find_one_and_update( - filters={'id': user_id, 'deleted': False}, deleted=True + filters={'id': user_id, 'deleted': False}, refresh=True, deleted=True ) return result