feat: user management settings

This commit is contained in:
Nicolas Meienberger 2026-01-21 19:06:49 +01:00
parent 451aed8983
commit ed554483d9
5 changed files with 629 additions and 165 deletions

View file

@ -242,6 +242,7 @@ On startup, the server detects available capabilities (see `core/capabilities.ts
- **Imports**: Organize imports is disabled in Biome - do not auto-organize
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
- **Visibility**: Prefer using the `cn` helper with `{ hidden: condition }` instead of conditional rendering with ternaries or `&&` for toggling element visibility in the DOM.
- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings
- **Security**: Restic password file has 0600 permissions - never expose it
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts

View file

@ -0,0 +1,186 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { Plus, UserPlus, X } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { authClient } from "~/client/lib/auth-client";
const createUserSchema = type({
name: "string>=1",
username: "string>=1",
email: "string",
password: "string>=8",
role: "'user'|'admin'",
});
type CreateUserFormValues = typeof createUserSchema.infer;
interface CreateUserDialogProps {
onUserCreated?: () => void;
}
export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<CreateUserFormValues>({
resolver: arktypeResolver(createUserSchema),
defaultValues: {
name: "",
username: "",
email: "",
password: "",
role: "user",
},
});
const onSubmit = async (values: CreateUserFormValues) => {
setIsLoading(true);
try {
const { error } = await authClient.admin.createUser({
email: values.email,
password: values.password,
name: values.name,
role: values.role,
data: { username: values.username },
});
if (error) {
toast.error("Failed to create user", { description: error.message });
} else {
toast.success("User created successfully");
setIsOpen(false);
form.reset();
onUserCreated?.();
}
} catch (err) {
console.error(err);
toast.error("An unexpected error occurred");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Create User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Create New User
</DialogTitle>
<DialogDescription>Fill in the details to create a new user account.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} placeholder="John Doe" disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder="johndoe" disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" placeholder="john@example.com" disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="Min. 8 characters" disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isLoading}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" loading={isLoading}>
<UserPlus className="mr-2 h-4 w-4" />
Create User
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,238 @@
import { useQuery } from "@tanstack/react-query";
import { Shield, ShieldAlert, UserMinus, UserCheck, Trash2, Search } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "~/client/lib/auth-client";
import { Button } from "~/client/components/ui/button";
import { cn } from "~/client/lib/utils";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import { Input } from "~/client/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { CreateUserDialog } from "./create-user-dialog";
export function UserManagement() {
const { data: session } = authClient.useSession();
const currentUser = session?.user;
const [search, setSearch] = useState("");
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [isBanning, setIsBanning] = useState<{ id: string; name: string; isBanned: boolean } | null>(null);
const { data, isLoading, refetch } = useQuery({
queryKey: ["admin-users"],
queryFn: async () => {
const { data, error } = await authClient.admin.listUsers({ query: { limit: 100 } });
if (error) throw error;
return data;
},
});
const filteredUsers = data?.users.filter(
(user) =>
user.name.toLowerCase().includes(search.toLowerCase()) ||
user.email.toLowerCase().includes(search.toLowerCase()) ||
(user as any).username?.toLowerCase().includes(search.toLowerCase()),
);
const handleSetRole = async (userId: string, role: "user" | "admin") => {
try {
const { error } = await authClient.admin.setRole({ userId, role });
if (error) throw error;
toast.success(`User role updated to ${role}`);
void refetch();
} catch (err: any) {
toast.error("Failed to update role", { description: err.message });
}
};
const handleBanUser = async () => {
if (!isBanning) return;
try {
const { error } = isBanning.isBanned
? await authClient.admin.unbanUser({ userId: isBanning.id })
: await authClient.admin.banUser({ userId: isBanning.id });
if (error) throw error;
toast.success(`User ${isBanning.isBanned ? "unbanned" : "banned"} successfully`);
setIsBanning(null);
void refetch();
} catch (err: any) {
toast.error(`Failed to ${isBanning.isBanned ? "unban" : "ban"} user`, { description: err.message });
}
};
const handleDeleteUser = async () => {
if (!isDeleting) return;
try {
const { error } = await authClient.admin.removeUser({ userId: isDeleting });
if (error) throw error;
toast.success("User deleted successfully");
setIsDeleting(null);
void refetch();
} catch (err: any) {
toast.error("Failed to delete user", { description: err.message });
}
};
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<CreateUserDialog onUserCreated={() => void refetch()} />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className={cn({ hidden: !isLoading })}>
<TableCell colSpan={4} className="h-24 text-center">
Loading users...
</TableCell>
</TableRow>
<TableRow className={cn({ hidden: isLoading || (filteredUsers && filteredUsers.length > 0) })}>
<TableCell colSpan={4} className="h-24 text-center">
No users found.
</TableCell>
</TableRow>
{filteredUsers?.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-sm text-muted-foreground">{user.email}</span>
</div>
</TableCell>
<TableCell>
<Badge>{user.role}</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
Banned
</Badge>
<Badge variant="outline" className={cn("text-green-600 border-green-600", { hidden: user.banned })}>
Active
</Badge>
</TableCell>
<TableCell className="text-right">
<div className={cn("flex justify-end gap-2", { hidden: user.id === currentUser?.id })}>
<Button
variant="ghost"
size="icon"
title="Demote to User"
className={cn({ hidden: user.role !== "admin" })}
onClick={() => handleSetRole(user.id, "user")}
>
<ShieldAlert className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Promote to Admin"
className={cn({ hidden: user.role === "admin" })}
onClick={() => handleSetRole(user.id, "admin")}
>
<Shield className="h-4 w-4" />
</Button>
{/* Ban/Unban Actions */}
<Button
variant="ghost"
size="icon"
title="Unban User"
className={cn({ hidden: !user.banned })}
onClick={() => setIsBanning({ id: user.id, name: user.name, isBanned: true })}
>
<UserCheck className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Ban User"
className={cn({ hidden: !!user.banned })}
onClick={() => setIsBanning({ id: user.id, name: user.name, isBanned: false })}
>
<UserMinus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Delete User" onClick={() => setIsDeleting(user.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={Boolean(isDeleting)} onOpenChange={(open) => !open && setIsDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the user account and remove their data from our
servers.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDeleting(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteUser}>
Delete User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(isBanning)} onOpenChange={(open) => !open && setIsBanning(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isBanning?.isBanned ? "Unban" : "Ban"} User</DialogTitle>
<DialogDescription>
Are you sure you want to {isBanning?.isBanned ? "unban" : "ban"} {isBanning?.name}?
{isBanning?.isBanned
? " They will regain access to the system."
: " They will be immediately signed out and lose access."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsBanning(null)}>
Cancel
</Button>
<Button variant="default" className={cn({ hidden: !isBanning?.isBanned })} onClick={handleBanUser}>
Unban User
</Button>
<Button variant="destructive" className={cn({ hidden: !!isBanning?.isBanned })} onClick={handleBanUser}>
Ban User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, KeyRound, User, X, Users } from "lucide-react";
import { Download, KeyRound, User, X, Users, Settings as SettingsIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import {
downloadResticPasswordMutation,
@ -22,9 +22,11 @@ import {
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Switch } from "~/client/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { authClient } from "~/client/lib/auth-client";
import { appContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { UserManagement } from "../components/user-management";
import type { Route } from "./+types/settings";
export const handle = {
@ -54,6 +56,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "account";
const navigate = useNavigate();
const isAdmin = loaderData.user?.role === "admin";
@ -167,35 +172,22 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
});
};
const onTabChange = (value: string) => {
setSearchParams({ tab: value });
};
return (
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
{isAdmin && <TabsTrigger value="users">Users</TabsTrigger>}
{isAdmin && <TabsTrigger value="system">System</TabsTrigger>}
</TabsList>
<div className="mt-2">
<TabsContent value="account" className="mt-0">
<Card className="p-0 gap-0">
{isAdmin && (
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Users className="size-5" />
System Settings
</CardTitle>
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
</div>
)}
{isAdmin && (
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enable-registrations" className="text-base">
Enable new user registrations
</Label>
<p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
</div>
<Switch
id="enable-registrations"
checked={registrationStatusQuery.data?.enabled ?? false}
onCheckedChange={(checked) => updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })}
disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending}
/>
</div>
</CardContent>
)}
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<User className="size-5" />
@ -208,10 +200,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<Label>Username</Label>
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
</div>
{/* <div className="space-y-2"> */}
{/* <Label>Email</Label> */}
{/* <Input value={loaderData.user?.email || ""} disabled className="max-w-md" /> */}
{/* </div> */}
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
@ -275,9 +263,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</div>
<CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
This file contains the encryption password used by Restic to secure your backups. Store it in a safe place
(like a password manager or encrypted storage). If you lose access to this server, you'll need this file to
recover your backup data.
This file contains the encryption password used by Restic to secure your backups. Store it in a safe
place (like a password manager or encrypted storage). If you lose access to this server, you'll need
this file to recover your backup data.
</p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
@ -332,5 +320,56 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<TwoFactorSection twoFactorEnabled={loaderData.user?.twoFactorEnabled} />
</Card>
</TabsContent>
{isAdmin && (
<TabsContent value="users" className="mt-0">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Users className="size-5" />
User Management
</CardTitle>
<CardDescription className="mt-1.5">Manage users, roles and permissions</CardDescription>
</div>
<UserManagement />
</Card>
</TabsContent>
)}
{isAdmin && (
<TabsContent value="system" className="mt-0">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="size-5" />
System Settings
</CardTitle>
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
</div>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enable-registrations" className="text-base">
Enable new user registrations
</Label>
<p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
</div>
<Switch
id="enable-registrations"
checked={registrationStatusQuery.data?.enabled ?? false}
onCheckedChange={(checked) =>
updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })
}
disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending}
/>
</div>
</CardContent>
</Card>
</TabsContent>
)}
</div>
</Tabs>
</div>
);
}

View file

@ -13,8 +13,8 @@
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
"start:e2e": "docker compose down && docker compose up --build zerobyte-e2e",
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"gen:api-client": "dotenv -e .env.local -- openapi-ts",
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
"studio": "drizzle-kit studio",
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",