feat: cleanup user's org when deleting them
This commit is contained in:
parent
ed554483d9
commit
4ba182732c
11 changed files with 305 additions and 57 deletions
|
|
@ -29,6 +29,7 @@ import {
|
||||||
getStatus,
|
getStatus,
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
getUpdates,
|
getUpdates,
|
||||||
|
getUserDeletionImpact,
|
||||||
getVolume,
|
getVolume,
|
||||||
healthCheckVolume,
|
healthCheckVolume,
|
||||||
listBackupSchedules,
|
listBackupSchedules,
|
||||||
|
|
@ -109,6 +110,8 @@ import type {
|
||||||
GetSystemInfoResponse,
|
GetSystemInfoResponse,
|
||||||
GetUpdatesData,
|
GetUpdatesData,
|
||||||
GetUpdatesResponse,
|
GetUpdatesResponse,
|
||||||
|
GetUserDeletionImpactData,
|
||||||
|
GetUserDeletionImpactResponse,
|
||||||
GetVolumeData,
|
GetVolumeData,
|
||||||
GetVolumeResponse,
|
GetVolumeResponse,
|
||||||
HealthCheckVolumeData,
|
HealthCheckVolumeData,
|
||||||
|
|
@ -223,6 +226,31 @@ export const getStatusOptions = (options?: Options<GetStatusData>) =>
|
||||||
queryKey: getStatusQueryKey(options),
|
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);
|
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export {
|
||||||
getStatus,
|
getStatus,
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
getUpdates,
|
getUpdates,
|
||||||
|
getUserDeletionImpact,
|
||||||
getVolume,
|
getVolume,
|
||||||
healthCheckVolume,
|
healthCheckVolume,
|
||||||
listBackupSchedules,
|
listBackupSchedules,
|
||||||
|
|
@ -134,6 +135,9 @@ export type {
|
||||||
GetUpdatesData,
|
GetUpdatesData,
|
||||||
GetUpdatesResponse,
|
GetUpdatesResponse,
|
||||||
GetUpdatesResponses,
|
GetUpdatesResponses,
|
||||||
|
GetUserDeletionImpactData,
|
||||||
|
GetUserDeletionImpactResponse,
|
||||||
|
GetUserDeletionImpactResponses,
|
||||||
GetVolumeData,
|
GetVolumeData,
|
||||||
GetVolumeErrors,
|
GetVolumeErrors,
|
||||||
GetVolumeResponse,
|
GetVolumeResponse,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ import type {
|
||||||
GetSystemInfoResponses,
|
GetSystemInfoResponses,
|
||||||
GetUpdatesData,
|
GetUpdatesData,
|
||||||
GetUpdatesResponses,
|
GetUpdatesResponses,
|
||||||
|
GetUserDeletionImpactData,
|
||||||
|
GetUserDeletionImpactResponses,
|
||||||
GetVolumeData,
|
GetVolumeData,
|
||||||
GetVolumeErrors,
|
GetVolumeErrors,
|
||||||
GetVolumeResponses,
|
GetVolumeResponses,
|
||||||
|
|
@ -144,6 +146,17 @@ export const getStatus = <ThrowOnError extends boolean = false>(options?: Option
|
||||||
...options,
|
...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
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,34 @@ export type GetStatusResponses = {
|
||||||
|
|
||||||
export type GetStatusResponse = GetStatusResponses[keyof 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 = {
|
export type ListVolumesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { Plus, UserPlus, X } from "lucide-react";
|
import { Plus, UserPlus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Shield, ShieldAlert, UserMinus, UserCheck, Trash2, Search } from "lucide-react";
|
import { Shield, ShieldAlert, UserMinus, UserCheck, Trash2, Search, AlertTriangle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
|
@ -17,14 +17,20 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/client/components/ui/dialog";
|
} from "~/client/components/ui/dialog";
|
||||||
import { CreateUserDialog } from "./create-user-dialog";
|
import { CreateUserDialog } from "./create-user-dialog";
|
||||||
|
import { getUserDeletionImpactOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
export function UserManagement() {
|
export function UserManagement() {
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const currentUser = session?.user;
|
const currentUser = session?.user;
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||||
const [isBanning, setIsBanning] = useState<{ id: string; name: string; isBanned: boolean } | 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({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
queryKey: ["admin-users"],
|
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(
|
const filteredUsers = data?.users.filter(
|
||||||
(user) =>
|
(user) =>
|
||||||
user.name.toLowerCase().includes(search.toLowerCase()) ||
|
user.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
|
@ -42,40 +79,14 @@ export function UserManagement() {
|
||||||
(user as any).username?.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 () => {
|
const handleDeleteUser = async () => {
|
||||||
if (!isDeleting) return;
|
if (!userToDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await authClient.admin.removeUser({ userId: isDeleting });
|
const { error } = await authClient.admin.removeUser({ userId: userToDelete });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success("User deleted successfully");
|
toast.success("User deleted successfully");
|
||||||
setIsDeleting(null);
|
setUserToDelete(null);
|
||||||
void refetch();
|
void refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error("Failed to delete user", { description: err.message });
|
toast.error("Failed to delete user", { description: err.message });
|
||||||
|
|
@ -144,7 +155,7 @@ export function UserManagement() {
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Demote to User"
|
title="Demote to User"
|
||||||
className={cn({ hidden: user.role !== "admin" })}
|
className={cn({ hidden: user.role !== "admin" })}
|
||||||
onClick={() => handleSetRole(user.id, "user")}
|
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "user" })}
|
||||||
>
|
>
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -153,18 +164,17 @@ export function UserManagement() {
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Promote to Admin"
|
title="Promote to Admin"
|
||||||
className={cn({ hidden: user.role === "admin" })}
|
className={cn({ hidden: user.role === "admin" })}
|
||||||
onClick={() => handleSetRole(user.id, "admin")}
|
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "admin" })}
|
||||||
>
|
>
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Ban/Unban Actions */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Unban User"
|
title="Unban User"
|
||||||
className={cn({ hidden: !user.banned })}
|
className={cn({ hidden: !user.banned })}
|
||||||
onClick={() => setIsBanning({ id: user.id, name: user.name, isBanned: true })}
|
onClick={() => setUserToBan({ id: user.id, name: user.name, isBanned: true })}
|
||||||
>
|
>
|
||||||
<UserCheck className="h-4 w-4" />
|
<UserCheck className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -173,12 +183,12 @@ export function UserManagement() {
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Ban User"
|
title="Ban User"
|
||||||
className={cn({ hidden: !!user.banned })}
|
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 })}
|
||||||
>
|
>
|
||||||
<UserMinus className="h-4 w-4" />
|
<UserMinus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" title="Delete User" onClick={() => setIsDeleting(user.id)}>
|
<Button variant="ghost" size="icon" title="Delete User" onClick={() => setUserToDelete(user.id)}>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,7 +199,7 @@ export function UserManagement() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={Boolean(isDeleting)} onOpenChange={(open) => !open && setIsDeleting(null)}>
|
<Dialog open={Boolean(userToDelete)} onOpenChange={(open) => !open && setUserToDelete(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
|
|
@ -198,36 +208,74 @@ export function UserManagement() {
|
||||||
servers.
|
servers.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsDeleting(null)}>
|
<Button variant="outline" onClick={() => setUserToDelete(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDeleteUser}>
|
<Button variant="destructive" disabled={isLoadingImpact} onClick={handleDeleteUser}>
|
||||||
Delete User
|
Delete User
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(isBanning)} onOpenChange={(open) => !open && setIsBanning(null)}>
|
<Dialog open={Boolean(userToBan)} onOpenChange={(open) => !open && setUserToBan(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isBanning?.isBanned ? "Unban" : "Ban"} User</DialogTitle>
|
<DialogTitle>{userToBan?.isBanned ? "Unban" : "Ban"} User</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to {isBanning?.isBanned ? "unban" : "ban"} {isBanning?.name}?
|
Are you sure you want to {userToBan?.isBanned ? "unban" : "ban"} {userToBan?.name}?
|
||||||
{isBanning?.isBanned
|
{userToBan?.isBanned
|
||||||
? " They will regain access to the system."
|
? " They will regain access to the system."
|
||||||
: " They will be immediately signed out and lose access."}
|
: " They will be immediately signed out and lose access."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsBanning(null)}>
|
<Button variant="outline" onClick={() => setUserToBan(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" className={cn({ hidden: !isBanning?.isBanned })} onClick={handleBanUser}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className={cn({ hidden: !userToBan?.isBanned })}
|
||||||
|
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan!.id, ban: false })}
|
||||||
|
>
|
||||||
Unban User
|
Unban User
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" className={cn({ hidden: !!isBanning?.isBanned })} onClick={handleBanUser}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className={cn({ hidden: !!userToBan?.isBanned })}
|
||||||
|
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan!.id, ban: true })}
|
||||||
|
>
|
||||||
Ban User
|
Ban User
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { db } from "../server/db/db";
|
||||||
import { cryptoUtils } from "../server/utils/crypto";
|
import { cryptoUtils } from "../server/utils/crypto";
|
||||||
import { organization as organizationTable, member, usersTable } from "../server/db/schema";
|
import { organization as organizationTable, member, usersTable } from "../server/db/schema";
|
||||||
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
||||||
|
import { authService } from "../server/modules/auth/auth.service";
|
||||||
|
|
||||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||||
|
|
||||||
|
|
@ -36,6 +37,11 @@ const createBetterAuth = (secret: string) =>
|
||||||
}),
|
}),
|
||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
|
delete: {
|
||||||
|
before: async (user) => {
|
||||||
|
await authService.cleanupUserOrganizations(user.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
before: async (user) => {
|
before: async (user) => {
|
||||||
const anyUser = await db.query.usersTable.findFirst();
|
const anyUser = await db.query.usersTable.findFirst();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { Hono } from "hono";
|
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 { authService } from "./auth.service";
|
||||||
|
import { requireAdmin, requireAuth } from "./auth.middleware";
|
||||||
|
|
||||||
export const authController = new Hono().get("/status", getStatusDto, async (c) => {
|
export const authController = new Hono()
|
||||||
const hasUsers = await authService.hasUsers();
|
.get("/status", getStatusDto, async (c) => {
|
||||||
return c.json<GetStatusDto>({ hasUsers });
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,33 @@ export const getStatusDto = describeRoute({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetStatusDto = typeof statusResponseSchema.infer;
|
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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,13 @@ export const requireOrgAdmin = createMiddleware(async (c, next) => {
|
||||||
|
|
||||||
await 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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { db } from "../../db/db";
|
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 {
|
export class AuthService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -9,6 +18,71 @@ export class AuthService {
|
||||||
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||||
return !!user;
|
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"),
|
||||||
|
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<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();
|
export const authService = new AuthService();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue