feat: user management settings
This commit is contained in:
parent
451aed8983
commit
ed554483d9
5 changed files with 629 additions and 165 deletions
|
|
@ -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
|
- **Imports**: Organize imports is disabled in Biome - do not auto-organize
|
||||||
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
|
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
|
||||||
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
|
- **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
|
- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings
|
||||||
- **Security**: Restic password file has 0600 permissions - never expose it
|
- **Security**: Restic password file has 0600 permissions - never expose it
|
||||||
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
|
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
|
||||||
|
|
|
||||||
186
app/client/modules/settings/components/create-user-dialog.tsx
Normal file
186
app/client/modules/settings/components/create-user-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
app/client/modules/settings/components/user-management.tsx
Normal file
238
app/client/modules/settings/components/user-management.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
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 { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
downloadResticPasswordMutation,
|
downloadResticPasswordMutation,
|
||||||
|
|
@ -22,9 +22,11 @@ import {
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { Switch } from "~/client/components/ui/switch";
|
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 { authClient } from "~/client/lib/auth-client";
|
||||||
import { appContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
import { TwoFactorSection } from "../components/two-factor-section";
|
import { TwoFactorSection } from "../components/two-factor-section";
|
||||||
|
import { UserManagement } from "../components/user-management";
|
||||||
import type { Route } from "./+types/settings";
|
import type { Route } from "./+types/settings";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
|
|
@ -54,6 +56,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
const [downloadPassword, setDownloadPassword] = useState("");
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const activeTab = searchParams.get("tab") || "account";
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAdmin = loaderData.user?.role === "admin";
|
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 (
|
return (
|
||||||
<Card className="p-0 gap-0">
|
<div className="space-y-6">
|
||||||
{isAdmin && (
|
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||||
<div className="border-b border-border/50 bg-card-header p-6">
|
<TabsList>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
<Users className="size-5" />
|
{isAdmin && <TabsTrigger value="users">Users</TabsTrigger>}
|
||||||
System Settings
|
{isAdmin && <TabsTrigger value="system">System</TabsTrigger>}
|
||||||
</CardTitle>
|
</TabsList>
|
||||||
<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="border-t border-border/50 bg-card-header p-6">
|
<div className="mt-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<TabsContent value="account" className="mt-0">
|
||||||
<KeyRound className="size-5" />
|
<Card className="p-0 gap-0">
|
||||||
Change Password
|
<div className="border-b border-border/50 bg-card-header p-6">
|
||||||
</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
<User className="size-5" />
|
||||||
</div>
|
Account Information
|
||||||
<CardContent className="p-6">
|
</CardTitle>
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<CardDescription className="mt-1.5">Your account details</CardDescription>
|
||||||
<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>
|
</div>
|
||||||
<DialogFooter>
|
<CardContent className="p-6 space-y-4">
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
type="button"
|
<Label>Username</Label>
|
||||||
variant="outline"
|
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||||
onClick={() => {
|
</div>
|
||||||
setDownloadDialogOpen(false);
|
</CardContent>
|
||||||
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} />
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
</Card>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||||
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
||||||
"start:e2e": "docker compose down && docker compose up --build zerobyte-e2e",
|
"start:e2e": "docker compose down && docker compose up --build zerobyte-e2e",
|
||||||
"gen:api-client": "openapi-ts",
|
"gen:api-client": "dotenv -e .env.local -- openapi-ts",
|
||||||
"gen:migrations": "drizzle-kit generate",
|
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
|
"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",
|
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue