From 200c4bc0ae05d8cb64572a21e63b9c5a6500f942 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 22 Feb 2026 21:32:53 +0100 Subject: [PATCH] feat: per-user account management --- .../api-client/@tanstack/react-query.gen.ts | 17 +++ app/client/api-client/index.ts | 4 + app/client/api-client/sdk.gen.ts | 5 + app/client/api-client/types.gen.ts | 32 ++++++ app/client/lib/auth-errors.ts | 2 +- .../settings/components/user-management.tsx | 102 ++++++++++++++++-- app/server/modules/auth/auth.controller.ts | 17 +++ app/server/modules/auth/auth.dto.ts | 21 ++++ app/server/modules/auth/auth.service.ts | 35 ++++++ 9 files changed, 226 insertions(+), 9 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 90d0d8e9..c40cca4b 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -349,6 +349,23 @@ export const getAdminUsersOptions = (options?: Options) => qu queryKey: getAdminUsersQueryKey(options) }); +/** + * Delete an account linked to a user + */ +export const deleteUserAccountMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteUserAccount({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getUserDeletionImpactQueryKey = (options: Options) => createQueryKey('getUserDeletionImpact', options); /** diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 3f25635a..36f79e23 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -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, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index c9f9363d..6d8a345e 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -196,6 +196,11 @@ export const deleteSsoInvitation = (option */ export const getAdminUsers = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/admin-users', ...options }); +/** + * Delete an account linked to a user + */ +export const deleteUserAccount = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options }); + /** * Get impact of deleting a user */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 06054baa..c40afd49 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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: { diff --git a/app/client/lib/auth-errors.ts b/app/client/lib/auth-errors.ts index c95f78dc..e672d52a 100644 --- a/app/client/lib/auth-errors.ts +++ b/app/client/lib/auth-errors.ts @@ -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": diff --git a/app/client/modules/settings/components/user-management.tsx b/app/client/modules/settings/components/user-management.tsx index ea4a4da6..719319ee 100644 --- a/app/client/modules/settings/components/user-management.tsx +++ b/app/client/modules/settings/components/user-management.tsx @@ -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(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 } | -
+
- + +
@@ -271,6 +318,45 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } | + + !open && setUserToManageAccounts(null)}> + + + Manage Accounts + Linked authentication accounts for {userToManageAccounts?.name}. + + +
+ {userToManageAccounts?.accounts.length === 0 ? ( +

No accounts linked.

+ ) : null} + {userToManageAccounts?.accounts.map((account) => ( +
+ {account.providerId} + +
+ ))} +
+ + + + +
+
); } diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index b8113cfe..de852d7d 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -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({ 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); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index d8a850fb..c9d9db89 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -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", }); diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index a88e00e3..5411091c 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -152,6 +152,41 @@ export class AuthService { async deleteSsoInvitation(invitationId: string): Promise { await db.delete(invitation).where(eq(invitation.id, invitationId)); } + + /** + * Fetch accounts for a list of users, keyed by userId + */ + async getUserAccounts(userIds: string[]): Promise> { + 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 = {}; + 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();