feat: user management (#394)

* feat: user management settings

* feat: cleanup user's org when deleting them

* chore: pr feedback

* refactor(create-user): tanstack mutation
This commit is contained in:
Nico 2026-01-21 22:25:15 +01:00 committed by GitHub
parent 451aed8983
commit da37b08fa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 899 additions and 176 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

@ -29,6 +29,7 @@ import {
getStatus,
getSystemInfo,
getUpdates,
getUserDeletionImpact,
getVolume,
healthCheckVolume,
listBackupSchedules,
@ -109,6 +110,8 @@ import type {
GetSystemInfoResponse,
GetUpdatesData,
GetUpdatesResponse,
GetUserDeletionImpactData,
GetUserDeletionImpactResponse,
GetVolumeData,
GetVolumeResponse,
HealthCheckVolumeData,
@ -223,6 +226,31 @@ export const getStatusOptions = (options?: Options<GetStatusData>) =>
queryKey: getStatusQueryKey(options),
});
export const getUserDeletionImpactQueryKey = (options: Options<GetUserDeletionImpactData>) =>
createQueryKey("getUserDeletionImpact", options);
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpactOptions = (options: Options<GetUserDeletionImpactData>) =>
queryOptions<
GetUserDeletionImpactResponse,
DefaultError,
GetUserDeletionImpactResponse,
ReturnType<typeof getUserDeletionImpactQueryKey>
>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserDeletionImpact({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getUserDeletionImpactQueryKey(options),
});
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
/**

View file

@ -26,6 +26,7 @@ export {
getStatus,
getSystemInfo,
getUpdates,
getUserDeletionImpact,
getVolume,
healthCheckVolume,
listBackupSchedules,
@ -134,6 +135,9 @@ export type {
GetUpdatesData,
GetUpdatesResponse,
GetUpdatesResponses,
GetUserDeletionImpactData,
GetUserDeletionImpactResponse,
GetUserDeletionImpactResponses,
GetVolumeData,
GetVolumeErrors,
GetVolumeResponse,

View file

@ -55,6 +55,8 @@ import type {
GetSystemInfoResponses,
GetUpdatesData,
GetUpdatesResponses,
GetUserDeletionImpactData,
GetUserDeletionImpactResponses,
GetVolumeData,
GetVolumeErrors,
GetVolumeResponses,
@ -144,6 +146,17 @@ export const getStatus = <ThrowOnError extends boolean = false>(options?: Option
...options,
});
/**
* Get impact of deleting a user
*/
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(
options: Options<GetUserDeletionImpactData, ThrowOnError>,
) =>
(options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/deletion-impact/{userId}",
...options,
});
/**
* List all volumes
*/

View file

@ -22,6 +22,34 @@ export type GetStatusResponses = {
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
export type GetUserDeletionImpactData = {
body?: never;
path: {
userId: string;
};
query?: never;
url: "/api/v1/auth/deletion-impact/{userId}";
};
export type GetUserDeletionImpactResponses = {
/**
* List of organizations and resources to be deleted
*/
200: {
organizations: Array<{
id: string;
name: string;
resources: {
backupSchedulesCount: number;
repositoriesCount: number;
volumesCount: number;
};
}>;
};
};
export type GetUserDeletionImpactResponse = GetUserDeletionImpactResponses[keyof GetUserDeletionImpactResponses];
export type ListVolumesData = {
body?: never;
path?: never;

View file

@ -159,7 +159,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormLabel>Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
disabled={mode === "update"}
>

View file

@ -90,7 +90,7 @@ export const NtfyForm = ({ form }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<Select onValueChange={field.onChange} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />

View file

@ -126,7 +126,7 @@ export const CreateRepositoryForm = ({
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
@ -164,7 +164,7 @@ export const CreateRepositoryForm = ({
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />

View file

@ -52,7 +52,7 @@ export const RcloneRepositoryForm = ({ form }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<Select onValueChange={(v) => field.onChange(v)} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />

View file

@ -0,0 +1,198 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { Plus, UserPlus } 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 form = useForm<CreateUserFormValues>({
resolver: arktypeResolver(createUserSchema),
defaultValues: {
name: "",
username: "",
email: "",
password: "",
role: "user",
},
});
const createUserMutation = useMutation({
mutationFn: async (values: CreateUserFormValues) => {
const { error } = await authClient.admin.createUser({
email: values.email,
password: values.password,
name: values.name,
role: values.role,
data: { username: values.username },
});
if (error) {
throw new Error(error.message);
}
},
onSuccess: () => {
toast.success("User created successfully");
setIsOpen(false);
form.reset();
onUserCreated?.();
},
onError: (error: Error) => {
toast.error("Failed to create user", { description: error.message });
},
});
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((values) => createUserMutation.mutate(values))} className="space-y-6">
<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={createUserMutation.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder="johndoe" disabled={createUserMutation.isPending} />
</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={createUserMutation.isPending}
/>
</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={createUserMutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} value={field.value} disabled={createUserMutation.isPending}>
<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={createUserMutation.isPending}
>
Cancel
</Button>
<Button type="submit" loading={createUserMutation.isPending}>
<UserPlus className="mr-2 h-4 w-4" />
Create User
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,286 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Shield, ShieldAlert, UserMinus, UserCheck, Trash2, Search, AlertTriangle } 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";
import { getUserDeletionImpactOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function UserManagement() {
const { data: session } = authClient.useSession();
const currentUser = session?.user;
const [search, setSearch] = useState("");
const [userToDelete, setUserToDelete] = useState<string | null>(null);
const [userToBan, setUserToBan] = useState<{ id: string; name: string; isBanned: boolean } | null>(null);
const { data: deletionImpact, isLoading: isLoadingImpact } = useQuery({
...getUserDeletionImpactOptions({ path: { userId: userToDelete ?? "" } }),
enabled: Boolean(userToDelete),
});
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 setRoleMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: string; role: "user" | "admin" }) => {
const { error } = await authClient.admin.setRole({ userId, role });
if (error) throw error;
},
onSuccess: () => {
toast.success("User role updated successfully");
void refetch();
},
onError: (err: any) => {
toast.error("Failed to update role", { description: err.message });
},
});
const toggleBanUserMutation = useMutation({
mutationFn: async ({ userId, ban }: { userId: string; ban: boolean }) => {
const { error } = ban ? await authClient.admin.banUser({ userId }) : await authClient.admin.unbanUser({ userId });
if (error) throw error;
},
onSuccess: () => {
toast.success("User ban status updated successfully");
void refetch();
},
onMutate: () => {
setUserToBan(null);
},
onError: (err: any) => {
toast.error("Failed to update ban status", { description: err.message });
},
});
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 handleDeleteUser = async () => {
if (!userToDelete) return;
try {
const { error } = await authClient.admin.removeUser({ userId: userToDelete });
if (error) throw error;
toast.success("User deleted successfully");
setUserToDelete(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={() => setRoleMutation.mutate({ userId: user.id, role: "user" })}
>
<ShieldAlert className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Promote to Admin"
className={cn({ hidden: user.role === "admin" })}
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "admin" })}
>
<Shield className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Unban User"
className={cn({ hidden: !user.banned })}
onClick={() => setUserToBan({ 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={() => setUserToBan({ id: user.id, name: user.name, isBanned: false })}
>
<UserMinus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Delete User" onClick={() => setUserToDelete(user.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={Boolean(userToDelete)} onOpenChange={(open) => !open && setUserToDelete(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>
<div className={cn("space-y-4", { hidden: !deletionImpact?.organizations.length })}>
<div className="flex items-start gap-3 p-3 text-sm border rounded-lg bg-destructive/10 border-destructive/20 text-destructive">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="font-semibold">Important: Data Deletion</p>
<p>
The following personal organizations and all their associated resources will be permanently deleted:
</p>
</div>
</div>
<div className="space-y-3 overflow-y-auto max-h-48">
{deletionImpact?.organizations.map((org) => (
<div key={org.id} className="p-3 border rounded-md bg-muted/50">
<p className="font-medium">{org.name}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-muted-foreground">
<span>{org.resources.volumesCount} Volumes</span>
<span>{org.resources.repositoriesCount} Repositories</span>
<span>{org.resources.backupSchedulesCount} Backups</span>
</div>
</div>
))}
</div>
</div>
<div className={cn("text-center py-4", { hidden: !isLoadingImpact })}>
<p className="text-sm text-muted-foreground">Analyzing deletion impact...</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserToDelete(null)}>
Cancel
</Button>
<Button variant="destructive" disabled={isLoadingImpact} onClick={handleDeleteUser}>
Delete User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(userToBan)} onOpenChange={(open) => !open && setUserToBan(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{userToBan?.isBanned ? "Unban" : "Ban"} User</DialogTitle>
<DialogDescription>
Are you sure you want to {userToBan?.isBanned ? "unban" : "ban"} {userToBan?.name}?
{userToBan?.isBanned
? " They will regain access to the system."
: " They will be immediately signed out and lose access."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUserToBan(null)}>
Cancel
</Button>
<Button
variant="default"
className={cn({ hidden: !userToBan?.isBanned })}
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan!.id, ban: false })}
>
Unban User
</Button>
<Button
variant="destructive"
className={cn({ hidden: !!userToBan?.isBanned })}
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan!.id, ban: true })}
>
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

@ -15,6 +15,7 @@ import { db } from "../server/db/db";
import { cryptoUtils } from "../server/utils/crypto";
import { organization as organizationTable, member, usersTable } from "../server/db/schema";
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
import { authService } from "../server/modules/auth/auth.service";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -36,6 +37,11 @@ const createBetterAuth = (secret: string) =>
}),
databaseHooks: {
user: {
delete: {
before: async (user) => {
await authService.cleanupUserOrganizations(user.id);
},
},
create: {
before: async (user) => {
const anyUser = await db.query.usersTable.findFirst();

View file

@ -1,8 +1,15 @@
import { Hono } from "hono";
import { getStatusDto, type GetStatusDto } from "./auth.dto";
import { type GetStatusDto, getStatusDto, getUserDeletionImpactDto, type UserDeletionImpactDto } from "./auth.dto";
import { authService } from "./auth.service";
import { requireAdmin, requireAuth } from "./auth.middleware";
export const authController = new Hono().get("/status", getStatusDto, async (c) => {
export const authController = new Hono()
.get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers();
return c.json<GetStatusDto>({ hasUsers });
})
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {
const userId = c.req.param("userId");
const impact = await authService.getUserDeletionImpact(userId);
return c.json<UserDeletionImpactDto>(impact);
});

View file

@ -22,3 +22,33 @@ export const getStatusDto = describeRoute({
});
export type GetStatusDto = typeof statusResponseSchema.infer;
export const userDeletionImpactDto = type({
organizations: type({
id: "string",
name: "string",
resources: {
volumesCount: "number",
repositoriesCount: "number",
backupSchedulesCount: "number",
},
}).array(),
});
export type UserDeletionImpactDto = typeof userDeletionImpactDto.infer;
export const getUserDeletionImpactDto = describeRoute({
description: "Get impact of deleting a user",
operationId: "getUserDeletionImpact",
tags: ["Auth"],
responses: {
200: {
description: "List of organizations and resources to be deleted",
content: {
"application/json": {
schema: resolver(userDeletionImpactDto),
},
},
},
},
});

View file

@ -59,3 +59,13 @@ export const requireOrgAdmin = createMiddleware(async (c, next) => {
await next();
});
export const requireAdmin = createMiddleware(async (c, next) => {
const user = c.get("user");
if (!user || user.role !== "admin") {
return c.json({ message: "Forbidden" }, 403);
}
await next();
});

View file

@ -1,5 +1,14 @@
import { db } from "../../db/db";
import { usersTable } from "../../db/schema";
import {
usersTable,
member,
organization,
volumesTable,
repositoriesTable,
backupSchedulesTable,
} from "../../db/schema";
import { eq, ne, and, count, inArray } from "drizzle-orm";
import type { UserDeletionImpactDto } from "./auth.dto";
export class AuthService {
/**
@ -9,6 +18,71 @@ export class AuthService {
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
return !!user;
}
/**
* Get the impact of deleting a user
*/
async getUserDeletionImpact(userId: string): Promise<UserDeletionImpactDto> {
const userMemberships = await db.query.member.findMany({
where: and(eq(member.userId, userId), eq(member.role, "owner")),
});
const impacts: UserDeletionImpactDto["organizations"] = [];
for (const membership of userMemberships) {
const otherOwners = await db
.select({ count: count() })
.from(member)
.where(
and(
eq(member.organizationId, membership.organizationId),
eq(member.role, "owner"),
ne(member.userId, userId),
),
);
if (otherOwners[0].count === 0) {
const org = await db.query.organization.findFirst({
where: eq(organization.id, membership.organizationId),
});
if (org) {
const [volumes, repos, schedules] = await Promise.all([
db.select({ count: count() }).from(volumesTable).where(eq(volumesTable.organizationId, org.id)),
db.select({ count: count() }).from(repositoriesTable).where(eq(repositoriesTable.organizationId, org.id)),
db
.select({ count: count() })
.from(backupSchedulesTable)
.where(eq(backupSchedulesTable.organizationId, org.id)),
]);
impacts.push({
id: org.id,
name: org.name,
resources: {
volumesCount: volumes[0].count,
repositoriesCount: repos[0].count,
backupSchedulesCount: schedules[0].count,
},
});
}
}
}
return { organizations: impacts };
}
/**
* Cleanup organizations where the user was the sole owner
*/
async cleanupUserOrganizations(userId: string): Promise<void> {
const impact = await this.getUserDeletionImpact(userId);
const orgIds = impact.organizations.map((o) => o.id);
if (orgIds.length > 0) {
await db.delete(organization).where(inArray(organization.id, orgIds));
}
}
}
export const authService = new AuthService();

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",