Add user management

This commit is contained in:
2026-05-02 14:31:19 +02:00
parent 24ade563db
commit 5997a29d92
20 changed files with 983 additions and 2 deletions
+18
View File
@@ -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';
}
+62
View File
@@ -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');
}
};
+7 -1
View File
@@ -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'],
});
},
});
}
+9
View File
@@ -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,
});
}
+2
View File
@@ -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>
+285
View File
@@ -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&apos;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&apos;t added any users yet.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</Page>
);
};
export default Users;