feat: per-user account management

This commit is contained in:
Nicolas Meienberger 2026-02-22 21:32:53 +01:00
parent 0bf0094dbd
commit de34fd841c
9 changed files with 229 additions and 12 deletions

View file

@ -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<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -163,6 +163,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);
/**

View file

@ -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,

View file

@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -61,6 +61,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
*/

View file

@ -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: {

View file

@ -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":

View file

@ -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>
);
}

View file

@ -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);

View file

@ -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",
});

View file

@ -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();