From de34fd841c7019a3c004773313cdcb3a0c17eb03 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 | 21 +++- app/client/api-client/index.ts | 4 + app/client/api-client/sdk.gen.ts | 7 +- 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, 229 insertions(+), 12 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 533b2bd0..3c1f611c 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -4,8 +4,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -163,6 +163,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 7c4f4ed9..4ca37df2 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 caf9ea7b..1d57c9ef 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -61,6 +61,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 7fa809b8..b55f624d 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 3b7d3b0b..11012c0e 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();