diff --git a/AGENTS.md b/AGENTS.md index 2e9509f3..afac88b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/client/modules/settings/components/create-user-dialog.tsx b/app/client/modules/settings/components/create-user-dialog.tsx new file mode 100644 index 00000000..cb7c4c2e --- /dev/null +++ b/app/client/modules/settings/components/create-user-dialog.tsx @@ -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({ + 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 ( + + + + + +
+ + + + + Create New User + + Fill in the details to create a new user account. + +
+ ( + + Full Name + + + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Role + + + + )} + /> +
+ + + + +
+ +
+
+ ); +} diff --git a/app/client/modules/settings/components/user-management.tsx b/app/client/modules/settings/components/user-management.tsx new file mode 100644 index 00000000..0ba5dac1 --- /dev/null +++ b/app/client/modules/settings/components/user-management.tsx @@ -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(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 ( +
+
+
+ + setSearch(e.target.value)} + /> +
+ void refetch()} /> +
+ +
+ + + + User + Role + Status + Actions + + + + + + Loading users... + + + 0) })}> + + No users found. + + + {filteredUsers?.map((user) => ( + + +
+ {user.name} + {user.email} +
+
+ + {user.role} + + + + Banned + + + Active + + + +
+ + + + {/* Ban/Unban Actions */} + + + + +
+
+
+ ))} +
+
+
+ + !open && setIsDeleting(null)}> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the user account and remove their data from our + servers. + + + + + + + + + + !open && setIsBanning(null)}> + + + {isBanning?.isBanned ? "Unban" : "Ban"} User + + 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."} + + + + + + + + + +
+ ); +} diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 45731e60..21832daa 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -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 ( - - {isAdmin && ( -
- - - System Settings - - Manage system-wide settings -
- )} - {isAdmin && ( - -
-
- -

When enabled, new users can sign up

-
- updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })} - disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending} - /> -
-
- )} -
- - - Account Information - - Your account details -
- -
- - -
- {/*
*/} - {/* */} - {/* */} - {/*
*/} -
+
+ + + Account + {isAdmin && Users} + {isAdmin && System} + -
- - - Change Password - - Update your password to keep your account secure -
- -
-
- - setCurrentPassword(e.target.value)} - className="max-w-md" - required - /> -
-
- - setNewPassword(e.target.value)} - className="max-w-md" - required - minLength={8} - /> -

Must be at least 8 characters long

-
-
- - setConfirmPassword(e.target.value)} - className="max-w-md" - required - minLength={8} - /> -
- -
-
- -
- - - Backup Recovery Key - - Download your recovery key for Restic backups -
- -

- 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. -

- - - - - - -
- - Download Recovery Key - - For security reasons, please enter your account password to download the recovery key file. - - -
-
- - setDownloadPassword(e.target.value)} - placeholder="Enter your password" - required - /> -
+
+ + +
+ + + Account Information + + Your account details
- - - - - - -
-
+ +
+ + +
+
- - +
+ + + Change Password + + Update your password to keep your account secure +
+ +
+
+ + setCurrentPassword(e.target.value)} + className="max-w-md" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="max-w-md" + required + minLength={8} + /> +

Must be at least 8 characters long

+
+
+ + setConfirmPassword(e.target.value)} + className="max-w-md" + required + minLength={8} + /> +
+ +
+
+ +
+ + + Backup Recovery Key + + Download your recovery key for Restic backups +
+ +

+ 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. +

+ + + + + + +
+ + Download Recovery Key + + For security reasons, please enter your account password to download the recovery key file. + + +
+
+ + setDownloadPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+
+ + + + +
+
+
+
+ + + + + + {isAdmin && ( + + +
+ + + User Management + + Manage users, roles and permissions +
+ +
+
+ )} + + {isAdmin && ( + + +
+ + + System Settings + + Manage system-wide settings +
+ +
+
+ +

When enabled, new users can sign up

+
+ + updateRegistrationStatusMutation.mutate({ body: { enabled: checked } }) + } + disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending} + /> +
+
+
+
+ )} +
+ + ); } diff --git a/package.json b/package.json index 1e96cb37..a155468c 100644 --- a/package.json +++ b/package.json @@ -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",