feat: per-user account management
This commit is contained in:
parent
f1c0d398c3
commit
200c4bc0ae
9 changed files with 226 additions and 9 deletions
|
|
@ -349,6 +349,23 @@ export const getAdminUsersOptions = (options?: Options<GetAdminUsersData>) => qu
|
|||
queryKey: getAdminUsersQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete an account linked to a user
|
||||
*/
|
||||
export const deleteUserAccountMutation = (options?: Partial<Options<DeleteUserAccountData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteUserAccountData>> => {
|
||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteUserAccountData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await deleteUserAccount({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getUserDeletionImpactQueryKey = (options: Options<GetUserDeletionImpactData>) => createQueryKey('getUserDeletionImpact', options);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
deleteSnapshots,
|
||||
deleteSsoInvitation,
|
||||
deleteSsoProvider,
|
||||
deleteUserAccount,
|
||||
deleteVolume,
|
||||
devPanelExec,
|
||||
downloadResticPassword,
|
||||
|
|
@ -113,6 +114,9 @@ export type {
|
|||
DeleteSsoProviderData,
|
||||
DeleteSsoProviderErrors,
|
||||
DeleteSsoProviderResponses,
|
||||
DeleteUserAccountData,
|
||||
DeleteUserAccountErrors,
|
||||
DeleteUserAccountResponses,
|
||||
DeleteVolumeData,
|
||||
DeleteVolumeResponse,
|
||||
DeleteVolumeResponses,
|
||||
|
|
|
|||
|
|
@ -196,6 +196,11 @@ export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(option
|
|||
*/
|
||||
export const getAdminUsers = <ThrowOnError extends boolean = false>(options?: Options<GetAdminUsersData, ThrowOnError>) => (options?.client ?? client).get<GetAdminUsersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/admin-users', ...options });
|
||||
|
||||
/**
|
||||
* Delete an account linked to a user
|
||||
*/
|
||||
export const deleteUserAccount = <ThrowOnError extends boolean = false>(options: Options<DeleteUserAccountData, ThrowOnError>) => (options.client ?? client).delete<DeleteUserAccountResponses, DeleteUserAccountErrors, ThrowOnError>({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options });
|
||||
|
||||
/**
|
||||
* Get impact of deleting a user
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ export type GetAdminUsersResponses = {
|
|||
offset: number;
|
||||
total: number;
|
||||
users: Array<{
|
||||
accounts: Array<{
|
||||
id: string;
|
||||
providerId: string;
|
||||
}>;
|
||||
banned: boolean;
|
||||
email: string;
|
||||
id: string;
|
||||
|
|
@ -178,6 +182,34 @@ export type GetAdminUsersResponses = {
|
|||
|
||||
export type GetAdminUsersResponse = GetAdminUsersResponses[keyof GetAdminUsersResponses];
|
||||
|
||||
export type DeleteUserAccountData = {
|
||||
body?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
accountId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}';
|
||||
};
|
||||
|
||||
export type DeleteUserAccountErrors = {
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: unknown;
|
||||
/**
|
||||
* Cannot delete the last account
|
||||
*/
|
||||
409: unknown;
|
||||
};
|
||||
|
||||
export type DeleteUserAccountResponses = {
|
||||
/**
|
||||
* Account deleted successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetUserDeletionImpactData = {
|
||||
body?: never;
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function decodeLoginError(error?: string): LoginErrorCode | null {
|
|||
export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null {
|
||||
switch (errorCode) {
|
||||
case "ACCOUNT_LINK_REQUIRED":
|
||||
return "Your account exists but is not linked to this organization SSO provider. Sign in with username/password first, then link SSO from settings.";
|
||||
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
|
||||
case "EMAIL_NOT_VERIFIED":
|
||||
return "Your identity provider did not mark your email as verified.";
|
||||
case "INVITE_REQUIRED":
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Shield, ShieldAlert, UserCheck, Trash2, Search, AlertTriangle, Ban } from "lucide-react";
|
||||
import { Shield, ShieldAlert, UserCheck, Trash2, Search, AlertTriangle, Ban, KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
|
|
@ -17,12 +17,21 @@ import {
|
|||
DialogTitle,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { CreateUserDialog } from "./create-user-dialog";
|
||||
import { getAdminUsersOptions, getUserDeletionImpactOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
getAdminUsersOptions,
|
||||
getUserDeletionImpactOptions,
|
||||
deleteUserAccountMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function UserManagement({ currentUser }: { currentUser: { id: string } | undefined | null }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||
const [userToBan, setUserToBan] = useState<{ id: string; name: string; isBanned: boolean } | null>(null);
|
||||
const [userToManageAccounts, setUserToManageAccounts] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
accounts: { id: string; providerId: string }[];
|
||||
} | null>(null);
|
||||
|
||||
const { data: deletionImpact, isLoading: isLoadingImpact } = useQuery({
|
||||
...getUserDeletionImpactOptions({ path: { userId: userToDelete ?? "" } }),
|
||||
|
|
@ -74,6 +83,23 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
},
|
||||
});
|
||||
|
||||
const deleteAccount = useMutation({
|
||||
...deleteUserAccountMutation(),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Account removed successfully");
|
||||
setUserToManageAccounts((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
accounts: prev.accounts.filter((a) => a.id !== variables.path.accountId),
|
||||
};
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to remove account", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedSearch = search.toLowerCase();
|
||||
const filteredUsers = data.users.filter((user) => {
|
||||
const name = user.name?.toLowerCase() ?? "";
|
||||
|
|
@ -135,12 +161,12 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className={cn("flex justify-end gap-2", { hidden: user.id === currentUser?.id })}>
|
||||
<div className={cn("flex justify-end gap-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Demote to User"
|
||||
className={cn({ hidden: user.role !== "admin" })}
|
||||
className={cn({ hidden: user.role !== "admin" || user.id === currentUser?.id })}
|
||||
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "user" })}
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
|
|
@ -149,7 +175,7 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
title="Promote to Admin"
|
||||
className={cn({ hidden: user.role === "admin" })}
|
||||
className={cn({ hidden: user.role === "admin" || user.id === currentUser?.id })}
|
||||
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "admin" })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
|
|
@ -159,7 +185,7 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
title="Unban user"
|
||||
className={cn({ hidden: !user.banned })}
|
||||
className={cn({ hidden: !user.banned || user.id === currentUser?.id })}
|
||||
onClick={() => setUserToBan({ id: user.id, name: user.name ?? user.email, isBanned: true })}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
|
|
@ -168,15 +194,36 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
title="Ban user"
|
||||
className={cn({ hidden: !!user.banned })}
|
||||
className={cn({ hidden: !!user.banned || user.id === currentUser?.id })}
|
||||
onClick={() => setUserToBan({ id: user.id, name: user.name ?? user.email, isBanned: false })}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" title="Delete User" onClick={() => setUserToDelete(user.id)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete User"
|
||||
className={cn({ hidden: user.id === currentUser?.id })}
|
||||
onClick={() => setUserToDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Manage Accounts"
|
||||
onClick={() =>
|
||||
setUserToManageAccounts({
|
||||
id: user.id,
|
||||
name: user.name ?? user.email,
|
||||
accounts: user.accounts,
|
||||
})
|
||||
}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -271,6 +318,45 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(userToManageAccounts)} onOpenChange={(open) => !open && setUserToManageAccounts(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Accounts</DialogTitle>
|
||||
<DialogDescription>Linked authentication accounts for {userToManageAccounts?.name}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
{userToManageAccounts?.accounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No accounts linked.</p>
|
||||
) : null}
|
||||
{userToManageAccounts?.accounts.map((account) => (
|
||||
<div key={account.id} className="flex items-center justify-between p-3 border rounded-md">
|
||||
<span className="text-sm font-medium">{account.providerId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Remove account"
|
||||
disabled={deleteAccount.isPending}
|
||||
onClick={() =>
|
||||
deleteAccount.mutate({
|
||||
path: { userId: userToManageAccounts.id, accountId: account.id },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserToManageAccounts(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
deleteSsoInvitationDto,
|
||||
updateSsoProviderAutoLinkingBody,
|
||||
updateSsoProviderAutoLinkingDto,
|
||||
deleteUserAccountDto,
|
||||
} from "./auth.dto";
|
||||
import { authService } from "./auth.service";
|
||||
import { requireAdmin, requireAuth } from "./auth.middleware";
|
||||
|
|
@ -100,6 +101,9 @@ export const authController = new Hono()
|
|||
query: { limit: 100 },
|
||||
});
|
||||
|
||||
const userIds = usersData.users.map((u) => u.id);
|
||||
const accountsByUser = await authService.getUserAccounts(userIds);
|
||||
|
||||
return c.json<AdminUsersDto>({
|
||||
users: usersData.users.map((adminUser) => ({
|
||||
id: adminUser.id,
|
||||
|
|
@ -107,12 +111,25 @@ export const authController = new Hono()
|
|||
email: adminUser.email,
|
||||
role: adminUser.role ?? "user",
|
||||
banned: Boolean(adminUser.banned),
|
||||
accounts: accountsByUser[adminUser.id] ?? [],
|
||||
})),
|
||||
total: usersData.total,
|
||||
limit: "limit" in usersData ? (usersData.limit ?? 100) : 100,
|
||||
offset: "offset" in usersData ? (usersData.offset ?? 0) : 0,
|
||||
});
|
||||
})
|
||||
.delete("/admin-users/:userId/accounts/:accountId", requireAuth, requireAdmin, deleteUserAccountDto, async (c) => {
|
||||
const userId = c.req.param("userId");
|
||||
const accountId = c.req.param("accountId");
|
||||
|
||||
const result = await authService.deleteUserAccount(userId, accountId);
|
||||
|
||||
if (result.lastAccount) {
|
||||
return c.json({ message: "Cannot delete the last account of a user" }, 409);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {
|
||||
const userId = c.req.param("userId");
|
||||
const impact = await authService.getUserDeletionImpact(userId);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ export const adminUsersResponse = type({
|
|||
email: "string",
|
||||
role: "string",
|
||||
banned: "boolean",
|
||||
accounts: type({
|
||||
id: "string",
|
||||
providerId: "string",
|
||||
}).array(),
|
||||
}).array(),
|
||||
total: "number",
|
||||
limit: "number",
|
||||
|
|
@ -173,6 +177,23 @@ export const deleteSsoInvitationDto = describeRoute({
|
|||
},
|
||||
});
|
||||
|
||||
export const deleteUserAccountDto = describeRoute({
|
||||
description: "Delete an account linked to a user",
|
||||
operationId: "deleteUserAccount",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Account deleted successfully",
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
409: {
|
||||
description: "Cannot delete the last account",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSsoProviderAutoLinkingBody = type({
|
||||
enabled: "boolean",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,6 +152,41 @@ export class AuthService {
|
|||
async deleteSsoInvitation(invitationId: string): Promise<void> {
|
||||
await db.delete(invitation).where(eq(invitation.id, invitationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch accounts for a list of users, keyed by userId
|
||||
*/
|
||||
async getUserAccounts(userIds: string[]): Promise<Record<string, { id: string; providerId: string }[]>> {
|
||||
if (userIds.length === 0) return {};
|
||||
|
||||
const accounts = await db
|
||||
.select({ id: account.id, providerId: account.providerId, userId: account.userId })
|
||||
.from(account)
|
||||
.where(inArray(account.userId, userIds));
|
||||
|
||||
const grouped: Record<string, { id: string; providerId: string }[]> = {};
|
||||
for (const row of accounts) {
|
||||
if (!grouped[row.userId]) {
|
||||
grouped[row.userId] = [];
|
||||
}
|
||||
grouped[row.userId].push({ id: row.id, providerId: row.providerId });
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single account for a user, refusing if it is the last one
|
||||
*/
|
||||
async deleteUserAccount(userId: string, accountId: string): Promise<{ lastAccount: boolean }> {
|
||||
const userAccounts = await db.select({ id: account.id }).from(account).where(eq(account.userId, userId));
|
||||
|
||||
if (userAccounts.length <= 1) {
|
||||
return { lastAccount: true };
|
||||
}
|
||||
|
||||
await db.delete(account).where(and(eq(account.id, accountId), eq(account.userId, userId)));
|
||||
return { lastAccount: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
|
|
|||
Loading…
Reference in a new issue