diff --git a/AGENTS.md b/AGENTS.md index 2e9509f..afac88b 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/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index f29ecb6..c751274 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -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) => queryKey: getStatusQueryKey(options), }); +export const getUserDeletionImpactQueryKey = (options: Options) => + createQueryKey("getUserDeletionImpact", options); + +/** + * Get impact of deleting a user + */ +export const getUserDeletionImpactOptions = (options: Options) => + queryOptions< + GetUserDeletionImpactResponse, + DefaultError, + GetUserDeletionImpactResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getUserDeletionImpact({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getUserDeletionImpactQueryKey(options), + }); + export const listVolumesQueryKey = (options?: Options) => createQueryKey("listVolumes", options); /** diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 38a8eb3..db3c3de 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -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, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 7e57067..566dc80 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -55,6 +55,8 @@ import type { GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, + GetUserDeletionImpactData, + GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, @@ -144,6 +146,17 @@ export const getStatus = (options?: Option ...options, }); +/** + * Get impact of deleting a user + */ +export const getUserDeletionImpact = ( + options: Options, +) => + (options.client ?? client).get({ + url: "/api/v1/auth/deletion-impact/{userId}", + ...options, + }); + /** * List all volumes */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index ebb93f6..7143ffb 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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; diff --git a/app/client/modules/notifications/components/create-notification-form.tsx b/app/client/modules/notifications/components/create-notification-form.tsx index 8b91366..0760860 100644 --- a/app/client/modules/notifications/components/create-notification-form.tsx +++ b/app/client/modules/notifications/components/create-notification-form.tsx @@ -159,7 +159,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Type + + + field.onChange(v)} defaultValue={field.value} value={field.value}> + + + + + )} + /> + ( + + 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 0000000..d64ac35 --- /dev/null +++ b/app/client/modules/settings/components/user-management.tsx @@ -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(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 ( +
+
+
+ + 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 + + + +
+ + + + + + + +
+
+
+ ))} +
+
+
+ + !open && setUserToDelete(null)}> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the user account and remove their data from our + servers. + + + +
+
+ +
+

Important: Data Deletion

+

+ The following personal organizations and all their associated resources will be permanently deleted: +

+
+
+ +
+ {deletionImpact?.organizations.map((org) => ( +
+

{org.name}

+
+ {org.resources.volumesCount} Volumes + {org.resources.repositoriesCount} Repositories + {org.resources.backupSchedulesCount} Backups +
+
+ ))} +
+
+ +
+

Analyzing deletion impact...

+
+ + + + + +
+
+ + !open && setUserToBan(null)}> + + + {userToBan?.isBanned ? "Unban" : "Ban"} User + + 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."} + + + + + + + + + +
+ ); +} diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 45731e6..21832da 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/app/lib/auth.ts b/app/lib/auth.ts index 121d2e9..b0f1fa7 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -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>; @@ -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(); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index 050e22c..8d0eb71 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -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({ hasUsers }); -}); +export const authController = new Hono() + .get("/status", getStatusDto, async (c) => { + const hasUsers = await authService.hasUsers(); + return c.json({ 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(impact); + }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index 6033ebe..4a79501 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/app/server/modules/auth/auth.middleware.ts b/app/server/modules/auth/auth.middleware.ts index 2067d1c..99cd455 100644 --- a/app/server/modules/auth/auth.middleware.ts +++ b/app/server/modules/auth/auth.middleware.ts @@ -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(); +}); diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 06e0efd..15b60ad 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -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 { + 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 { + 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(); diff --git a/package.json b/package.json index 1e96cb3..a155468 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",