diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index f29ecb61..c751274c 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 38a8eb3b..db3c3de8 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 7e570675..566dc80c 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 ebb93f62..7143ffb6 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/settings/components/create-user-dialog.tsx b/app/client/modules/settings/components/create-user-dialog.tsx index cb7c4c2e..dc76e7e5 100644 --- a/app/client/modules/settings/components/create-user-dialog.tsx +++ b/app/client/modules/settings/components/create-user-dialog.tsx @@ -1,6 +1,6 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; import { type } from "arktype"; -import { Plus, UserPlus, X } from "lucide-react"; +import { Plus, UserPlus } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; diff --git a/app/client/modules/settings/components/user-management.tsx b/app/client/modules/settings/components/user-management.tsx index 0ba5dac1..d64ac359 100644 --- a/app/client/modules/settings/components/user-management.tsx +++ b/app/client/modules/settings/components/user-management.tsx @@ -1,5 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; -import { Shield, ShieldAlert, UserMinus, UserCheck, Trash2, Search } from "lucide-react"; +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"; @@ -17,14 +17,20 @@ import { 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 [isDeleting, setIsDeleting] = useState(null); - const [isBanning, setIsBanning] = useState<{ id: string; name: string; isBanned: boolean } | null>(null); + 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"], @@ -35,6 +41,37 @@ export function UserManagement() { }, }); + 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()) || @@ -42,40 +79,14 @@ export function UserManagement() { (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; + if (!userToDelete) return; + try { - const { error } = await authClient.admin.removeUser({ userId: isDeleting }); + const { error } = await authClient.admin.removeUser({ userId: userToDelete }); if (error) throw error; toast.success("User deleted successfully"); - setIsDeleting(null); + setUserToDelete(null); void refetch(); } catch (err: any) { toast.error("Failed to delete user", { description: err.message }); @@ -144,7 +155,7 @@ export function UserManagement() { size="icon" title="Demote to User" className={cn({ hidden: user.role !== "admin" })} - onClick={() => handleSetRole(user.id, "user")} + onClick={() => setRoleMutation.mutate({ userId: user.id, role: "user" })} > @@ -153,18 +164,17 @@ export function UserManagement() { size="icon" title="Promote to Admin" className={cn({ hidden: user.role === "admin" })} - onClick={() => handleSetRole(user.id, "admin")} + onClick={() => setRoleMutation.mutate({ userId: user.id, role: "admin" })} > - {/* Ban/Unban Actions */} @@ -173,12 +183,12 @@ export function UserManagement() { size="icon" title="Ban User" className={cn({ hidden: !!user.banned })} - onClick={() => setIsBanning({ id: user.id, name: user.name, isBanned: false })} + onClick={() => setUserToBan({ id: user.id, name: user.name, isBanned: false })} > - @@ -189,7 +199,7 @@ export function UserManagement() { - !open && setIsDeleting(null)}> + !open && setUserToDelete(null)}> Are you absolutely sure? @@ -198,36 +208,74 @@ export function UserManagement() { 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 && setIsBanning(null)}> + !open && setUserToBan(null)}> - {isBanning?.isBanned ? "Unban" : "Ban"} User + {userToBan?.isBanned ? "Unban" : "Ban"} User - Are you sure you want to {isBanning?.isBanned ? "unban" : "ban"} {isBanning?.name}? - {isBanning?.isBanned + 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/lib/auth.ts b/app/lib/auth.ts index 121d2e91..b0f1fa71 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 050e22ce..8d0eb717 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 6033ebe5..4a795017 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 2067d1c0..99cd4558 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 06e0efd1..daeac206 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, sql, 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"), + sql`user_id != ${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();