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
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,170 +172,204 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
});
};
const onTabChange = (value: string) => {
setSearchParams({ tab: value });
};
return (
<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" />
Account Information
</CardTitle>
<CardDescription className="mt-1.5">Your account details</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<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="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="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
Change Password
</CardTitle>
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
</div>
<CardContent className="p-6">
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="max-w-md"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="max-w-md"
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="max-w-md"
required
minLength={8}
/>
</div>
<Button type="submit" loading={isChangingPassword} className="mt-4">
<KeyRound className="h-4 w-4 mr-2" />
Change Password
</Button>
</form>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Download className="size-5" />
Backup Recovery Key
</CardTitle>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
</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.
</p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Download size={16} className="mr-2" />
Download recovery key
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleDownloadResticPassword}>
<DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription>
For security reasons, please enter your account password to download the recovery key file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="download-password">Your Password</Label>
<Input
id="download-password"
type="password"
value={downloadPassword}
onChange={(e) => setDownloadPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
<div className="mt-2">
<TabsContent value="account" 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">
<User className="size-5" />
Account Information
</CardTitle>
<CardDescription className="mt-1.5">Your account details</CardDescription>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setDownloadDialogOpen(false);
setDownloadPassword("");
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" loading={downloadResticPassword.isPending}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label>Username</Label>
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
</div>
</CardContent>
<TwoFactorSection twoFactorEnabled={loaderData.user?.twoFactorEnabled} />
</Card>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
Change Password
</CardTitle>
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
</div>
<CardContent className="p-6">
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="max-w-md"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="max-w-md"
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="max-w-md"
required
minLength={8}
/>
</div>
<Button type="submit" loading={isChangingPassword} className="mt-4">
<KeyRound className="h-4 w-4 mr-2" />
Change Password
</Button>
</form>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Download className="size-5" />
Backup Recovery Key
</CardTitle>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
</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.
</p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Download size={16} className="mr-2" />
Download recovery key
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleDownloadResticPassword}>
<DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription>
For security reasons, please enter your account password to download the recovery key file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="download-password">Your Password</Label>
<Input
id="download-password"
type="password"
value={downloadPassword}
onChange={(e) => setDownloadPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setDownloadDialogOpen(false);
setDownloadPassword("");
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" loading={downloadResticPassword.isPending}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
<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) => {
const hasUsers = await authService.hasUsers();
return c.json<GetStatusDto>({ hasUsers });
});
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",