Add user management
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
hashed_password: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name?: string;
|
||||
role?: 'admin' | 'user';
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { makeApiUrl } from '.';
|
||||
import type { CreateUserRequest, UpdateUserRequest, User } from './types/user';
|
||||
|
||||
export const getUsers = async (): Promise<User[]> => {
|
||||
const response = await fetch(makeApiUrl('/users'), {
|
||||
method: 'GET',
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sites');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getUserById = async (id: string): Promise<User> => {
|
||||
const response = await fetch(makeApiUrl(`/users/${id}`), {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createUser = async (data: CreateUserRequest): Promise<User> => {
|
||||
const response = await fetch(makeApiUrl('/users'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateUser = async (id: string, data: Partial<UpdateUserRequest>): Promise<User> => {
|
||||
const response = await fetch(makeApiUrl(`/users/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteUser = async (id: string): Promise<void> => {
|
||||
const response = await fetch(makeApiUrl(`/users/${id}`), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChartLine, CircleUserRound, LogOut, Settings } from 'lucide-react';
|
||||
import { ChartLine, CircleUserRound, LogOut, Settings, Users } from 'lucide-react';
|
||||
|
||||
interface NavbarProps {
|
||||
userName?: string;
|
||||
@@ -59,6 +59,12 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => {
|
||||
Analytics
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/users">
|
||||
<Users />
|
||||
Users
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">
|
||||
<Settings />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createUser } from '../../../api/users.api';
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ['users'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { deleteUser } from '../../../api/users.api';
|
||||
|
||||
export function useDeleteUser() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => deleteUser(userId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ['users'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUsers } from '../../../api/users.api';
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: getUsers,
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ThemeProvider } from './components/theme-provider';
|
||||
import './index.css';
|
||||
import NewSite from './pages/NewSite/NewSite';
|
||||
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
||||
import Users from './pages/Users/Users';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -18,6 +19,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="/" element={<MainPage />} />
|
||||
<Route path="/sites/new" element={<NewSite />} />
|
||||
<Route path="/sites/:id" element={<SiteOverview />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Plus, Trash2, UserX, X } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '../../components/ui/empty';
|
||||
import { useUsers } from '../../hooks/api/users/useUsers';
|
||||
import { useCreateUser } from '../../hooks/api/users/useCreateUser';
|
||||
import Page from '../Page';
|
||||
import { Skeleton } from '../../components/ui/skeleton';
|
||||
import { memo, useState } from 'react';
|
||||
import type { User } from '../../api/types/user';
|
||||
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../../components/ui/dialog';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { useDeleteUser } from '../../hooks/api/users/useDeleteUser';
|
||||
|
||||
const DeleteUserDialog = ({ userId }: { userId: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon-sm">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete user</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this user? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteUser.isPending}
|
||||
>
|
||||
{deleteUser.isPending ? 'Deleting...' : 'Delete user'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const UserRow = ({ user }: { user: User }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>{user.name.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.role}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<DeleteUserDialog userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersLoadingSkeleton = memo(() => {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CreateUserDialog = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const { mutate: createUser, isPending, error, reset } = useCreateUser();
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setRole('user');
|
||||
setPassword('');
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !password) return;
|
||||
|
||||
createUser(
|
||||
{ name: name.trim(), role, password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0 && password.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="default" variant="default">
|
||||
<Plus />
|
||||
Add user
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create user</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new user. They'll be able to sign in with the password you
|
||||
set.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="user-name">Name</Label>
|
||||
<Input
|
||||
id="user-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Jane Doe"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="user-role">Role</Label>
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v as 'admin' | 'user')}
|
||||
>
|
||||
<SelectTrigger id="user-role" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="user-password">Password</Label>
|
||||
<Input
|
||||
id="user-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to create user. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!isValid || isPending}>
|
||||
{isPending ? 'Creating...' : 'Create user'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const Users = () => {
|
||||
const { data: users, isLoading: loadingUsers, error: usersError } = useUsers();
|
||||
|
||||
if (loadingUsers || usersError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Users">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-2xl font-semibold">Users</h1>
|
||||
<CreateUserDialog />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{loadingUsers && <UsersLoadingSkeleton />}
|
||||
{usersError && (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<X />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-xl">Failed to load users</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
An error occurred while fetching your users. Please try again later.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
{users &&
|
||||
users.length > 0 &&
|
||||
!usersError &&
|
||||
!loadingUsers &&
|
||||
users.map((user) => <UserRow key={user.id} user={user} />)}
|
||||
{users && !usersError && !loadingUsers && users.length === 0 && (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<UserX />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-xl font-semibold">
|
||||
No users found
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
You haven't added any users yet.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
Reference in New Issue
Block a user