From f686c1aa16d5aee58b74014f01a2cd964a9cdf7b Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:32:43 +0100 Subject: [PATCH] feat: separate global admin page (#595) --- .../api-client/@tanstack/react-query.gen.ts | 56 +++++- app/client/api-client/index.ts | 12 ++ app/client/api-client/sdk.gen.ts | 24 ++- app/client/api-client/types.gen.ts | 83 +++++++++ app/client/components/app-sidebar.tsx | 66 ++++++- app/client/components/layout.tsx | 2 +- app/client/components/ui/sidebar.tsx | 2 +- .../modules/admin/routes/admin-page.tsx | 100 ++++++++++ .../components/org-members-section.tsx | 173 ++++++++++++++++++ .../components/sso/sso-settings-section.tsx | 8 +- .../settings/routes/create-sso-provider.tsx | 4 +- .../modules/settings/routes/settings.tsx | 84 ++------- app/routeTree.gen.ts | 21 +++ app/routes/(dashboard)/admin/index.tsx | 30 +++ app/routes/(dashboard)/settings/index.tsx | 11 +- app/routes/(dashboard)/settings/sso/new.tsx | 9 +- app/server/lib/auth.ts | 8 +- app/server/modules/auth/auth.controller.ts | 60 +++++- app/server/modules/auth/auth.dto.ts | 69 +++++++ app/server/modules/auth/auth.service.ts | 80 ++++++++ e2e/0003-oidc.spec.ts | 2 +- 21 files changed, 796 insertions(+), 108 deletions(-) create mode 100644 app/client/modules/admin/routes/admin-page.tsx create mode 100644 app/client/modules/settings/components/org-members-section.tsx create mode 100644 app/routes/(dashboard)/admin/index.tsx diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index b5d7343b..182d913b 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, deleteUserAccount, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, 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, GetBackupProgressData, GetBackupProgressResponse, 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, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, 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, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, 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, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, 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, RemoveOrgMemberData, 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, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -198,6 +198,58 @@ export const getUserDeletionImpactOptions = (options: Options) => createQueryKey('getOrgMembers', options); + +/** + * Get members of the active organization + */ +export const getOrgMembersOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getOrgMembers({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getOrgMembersQueryKey(options) +}); + +/** + * Update a member's role in the active organization + */ +export const updateMemberRoleMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateMemberRole({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Remove a member from the active organization + */ +export const removeOrgMemberMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await removeOrgMember({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listVolumesQueryKey = (options?: Options) => createQueryKey('listVolumes', options); /** diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 9435f1ab..cef3e578 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -27,6 +27,7 @@ export { getDevPanel, getMirrorCompatibility, getNotificationDestination, + getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, @@ -52,6 +53,7 @@ export { mountVolume, type Options, refreshSnapshots, + removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, @@ -65,6 +67,7 @@ export { unlockRepository, unmountVolume, updateBackupSchedule, + updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, @@ -153,6 +156,9 @@ export type { GetNotificationDestinationErrors, GetNotificationDestinationResponse, GetNotificationDestinationResponses, + GetOrgMembersData, + GetOrgMembersResponse, + GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetPublicSsoProvidersResponses, @@ -227,6 +233,9 @@ export type { RefreshSnapshotsData, RefreshSnapshotsResponse, RefreshSnapshotsResponses, + RemoveOrgMemberData, + RemoveOrgMemberErrors, + RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, ReorderBackupSchedulesResponses, @@ -269,6 +278,9 @@ export type { UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateBackupScheduleResponses, + UpdateMemberRoleData, + UpdateMemberRoleErrors, + UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponse, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 21199845..3f2d16ee 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, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, 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, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, 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, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, 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, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -71,6 +71,28 @@ export const deleteUserAccount = (options: */ export const getUserDeletionImpact = (options: Options) => (options.client ?? client).get({ url: '/api/v1/auth/deletion-impact/{userId}', ...options }); +/** + * Get members of the active organization + */ +export const getOrgMembers = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/org-members', ...options }); + +/** + * Update a member's role in the active organization + */ +export const updateMemberRole = (options: Options) => (options.client ?? client).patch({ + url: '/api/v1/auth/org-members/{memberId}/role', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Remove a member from the active organization + */ +export const removeOrgMember = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/org-members/{memberId}', ...options }); + /** * List all volumes */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index c3bb01dc..1e8e5af4 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -240,6 +240,89 @@ export type GetUserDeletionImpactResponses = { export type GetUserDeletionImpactResponse = GetUserDeletionImpactResponses[keyof GetUserDeletionImpactResponses]; +export type GetOrgMembersData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/auth/org-members'; +}; + +export type GetOrgMembersResponses = { + /** + * List of organization members + */ + 200: { + members: Array<{ + createdAt: string; + id: string; + role: string; + user: { + email: string; + name: string | null; + }; + userId: string; + }>; + }; +}; + +export type GetOrgMembersResponse = GetOrgMembersResponses[keyof GetOrgMembersResponses]; + +export type UpdateMemberRoleData = { + body?: { + role: 'admin' | 'member'; + }; + path: { + memberId: string; + }; + query?: never; + url: '/api/v1/auth/org-members/{memberId}/role'; +}; + +export type UpdateMemberRoleErrors = { + /** + * Forbidden + */ + 403: unknown; + /** + * Member not found + */ + 404: unknown; +}; + +export type UpdateMemberRoleResponses = { + /** + * Member role updated successfully + */ + 200: unknown; +}; + +export type RemoveOrgMemberData = { + body?: never; + path: { + memberId: string; + }; + query?: never; + url: '/api/v1/auth/org-members/{memberId}'; +}; + +export type RemoveOrgMemberErrors = { + /** + * Forbidden + */ + 403: unknown; + /** + * Member not found + */ + 404: unknown; +}; + +export type RemoveOrgMemberResponses = { + /** + * Member removed successfully + */ + 200: unknown; +}; + export type ListVolumesData = { body?: never; path?: never; diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 14e2d700..5bb686ec 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react"; +import { Bell, CalendarClock, Database, HardDrive, Settings, ShieldCheck } from "lucide-react"; import { useState } from "react"; import { Sidebar, @@ -10,6 +10,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarSeparator, useSidebar, } from "~/client/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; @@ -49,7 +50,11 @@ const items = [ }, ]; -export function AppSidebar() { +type Props = { + isInstanceAdmin: boolean; +}; + +export function AppSidebar({ isInstanceAdmin }: Props) { const { state, isMobile, setOpenMobile } = useSidebar(); const { updates, hasUpdate } = useUpdates(); const [showReleaseNotes, setShowReleaseNotes] = useState(false); @@ -131,6 +136,63 @@ export function AppSidebar() { + {isInstanceAdmin && ( + <> + + + + + + + + + + isMobile && setOpenMobile(false)} + activeProps={{ className: "bg-strong-accent/10" }} + className="w-full flex items-center gap-2" + > + {({ isActive }) => ( + <> + {isActive && ( +
+ )} + + + Administration + + + )} + + + + +

Administration

+
+ + + + + + + + )} diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx index f7c0a7a5..82600947 100644 --- a/app/client/components/layout.tsx +++ b/app/client/components/layout.tsx @@ -33,7 +33,7 @@ export function Layout({ loaderData }: Props) { return ( - +
diff --git a/app/client/components/ui/sidebar.tsx b/app/client/components/ui/sidebar.tsx index 82402e17..8de08d06 100644 --- a/app/client/components/ui/sidebar.tsx +++ b/app/client/components/ui/sidebar.tsx @@ -344,7 +344,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-content" data-sidebar="content" className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden", className, )} {...props} diff --git a/app/client/modules/admin/routes/admin-page.tsx b/app/client/modules/admin/routes/admin-page.tsx new file mode 100644 index 00000000..a4348f86 --- /dev/null +++ b/app/client/modules/admin/routes/admin-page.tsx @@ -0,0 +1,100 @@ +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Users, Settings as SettingsIcon } from "lucide-react"; +import { toast } from "sonner"; +import { + getRegistrationStatusOptions, + setRegistrationStatusMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; +import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; +import { Label } from "~/client/components/ui/label"; +import { Switch } from "~/client/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; +import type { AppContext } from "~/context"; +import { UserManagement } from "~/client/modules/settings/components/user-management"; + +type Props = { + appContext: AppContext; +}; + +export function AdminPage({ appContext }: Props) { + const { tab } = useSearch({ from: "/(dashboard)/admin/" }); + const activeTab = tab || "users"; + const navigate = useNavigate(); + + const registrationStatus = useSuspenseQuery({ + ...getRegistrationStatusOptions(), + }); + + const updateRegistrationStatusMutation = useMutation({ + ...setRegistrationStatusMutation(), + onSuccess: () => { + toast.success("Registration settings updated"); + }, + onError: (error) => { + toast.error("Failed to update registration settings", { + description: error.message, + }); + }, + }); + + const onTabChange = (value: string) => { + void navigate({ to: ".", search: () => ({ tab: value }) }); + }; + + return ( +
+ + + Users + System + + +
+ + +
+ + + User Management + + Manage users, roles and permissions +
+ +
+
+ + + +
+ + + System Settings + + Manage system-wide settings +
+ +
+
+ +

When enabled, new users can sign up

+
+ + updateRegistrationStatusMutation.mutate({ body: { enabled: checked } }) + } + disabled={updateRegistrationStatusMutation.isPending} + /> +
+
+
+
+
+
+
+ ); +} diff --git a/app/client/modules/settings/components/org-members-section.tsx b/app/client/modules/settings/components/org-members-section.tsx new file mode 100644 index 00000000..7eaa4bf7 --- /dev/null +++ b/app/client/modules/settings/components/org-members-section.tsx @@ -0,0 +1,173 @@ +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { Shield, ShieldAlert, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + getOrgMembersOptions, + removeOrgMemberMutation, + updateMemberRoleMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/client/components/ui/alert-dialog"; +import { Badge } from "~/client/components/ui/badge"; +import { Button } from "~/client/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; +import { useOrganizationContext } from "~/client/hooks/use-org-context"; +import { cn } from "~/client/lib/utils"; + +export function OrgMembersSection() { + const { activeMember } = useOrganizationContext(); + const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null); + + const orgMembersQuery = useSuspenseQuery({ + ...getOrgMembersOptions(), + }); + + const updateRole = useMutation({ + ...updateMemberRoleMutation(), + onSuccess: () => { + toast.success("Member role updated"); + }, + onError: (error) => { + toast.error("Failed to update role", { description: error.message }); + }, + }); + + const removeMember = useMutation({ + ...removeOrgMemberMutation(), + onSuccess: () => { + toast.success("Member removed from organization"); + setMemberToRemove(null); + }, + onError: (error) => { + toast.error("Failed to remove member", { description: error.message }); + }, + }); + + const members = orgMembersQuery.data.members; + + return ( +
+
+ + + + Member + Role + Actions + + + + 0 })}> + + No members found. + + + {members.map((member) => { + const isOwner = member.role === "owner"; + const isSelf = member.id === activeMember?.id; + + return ( + + +
+ {member.user.name ?? member.user.email} + {member.user.email} +
+
+ + {member.role} + + +
+ + + + !open && setMemberToRemove(null)} + > + + + + + + Remove member + + Are you sure you want to remove {memberToRemove?.name} from this + organization? They will lose access to all organization resources. + + + + Cancel + removeMember.mutate({ path: { memberId: memberToRemove!.id } })} + > + Remove + + + + +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/app/client/modules/settings/components/sso/sso-settings-section.tsx b/app/client/modules/settings/components/sso/sso-settings-section.tsx index b028c481..d9f417fe 100644 --- a/app/client/modules/settings/components/sso/sso-settings-section.tsx +++ b/app/client/modules/settings/components/sso/sso-settings-section.tsx @@ -42,13 +42,10 @@ export function SsoSettingsSection() { const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("member"); - const { data } = useSuspenseQuery({ + const ssoSettingsQuery = useSuspenseQuery({ ...getSsoSettingsOptions(), }); - const providers = data.providers; - const invitations = data.invitations; - const updateProviderAutoLinkingMutation = useMutation({ ...updateSsoProviderAutoLinkingMutation(), onSuccess: (_, v) => { @@ -125,6 +122,9 @@ export function SsoSettingsSection() { }, }); + const providers = ssoSettingsQuery.data.providers; + const invitations = ssoSettingsQuery.data.invitations; + return (
diff --git a/app/client/modules/settings/routes/create-sso-provider.tsx b/app/client/modules/settings/routes/create-sso-provider.tsx index d200da4c..8b13f41a 100644 --- a/app/client/modules/settings/routes/create-sso-provider.tsx +++ b/app/client/modules/settings/routes/create-sso-provider.tsx @@ -88,7 +88,7 @@ export function CreateSsoProviderPage() { }, onSuccess: () => { toast.success("SSO provider registered successfully"); - void navigate({ to: "/settings", search: { tab: "users" } }); + void navigate({ to: "/settings", search: { tab: "organization" } }); }, onError: (error) => { toast.error("Failed to register provider", { description: error.message }); @@ -242,7 +242,7 @@ export function CreateSsoProviderPage() { diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 6b74edcd..b6b2835f 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -1,12 +1,8 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Download, KeyRound, User, X, Users, Settings as SettingsIcon } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { Download, KeyRound, User, X, Settings as SettingsIcon, Building2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import { - downloadResticPasswordMutation, - setRegistrationStatusMutation, - getRegistrationStatusOptions, -} from "~/client/api-client/@tanstack/react-query.gen"; +import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { @@ -20,14 +16,14 @@ import { } from "~/client/components/ui/dialog"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; -import { Switch } from "~/client/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { authClient } from "~/client/lib/auth-client"; import { type AppContext } from "~/context"; import { TwoFactorSection } from "../components/two-factor-section"; -import { UserManagement } from "../components/user-management"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { SsoSettingsSection } from "../components/sso/sso-settings-section"; +import { OrgMembersSection } from "../components/org-members-section"; +import { useOrganizationContext } from "~/client/hooks/use-org-context"; type Props = { appContext: AppContext; @@ -45,25 +41,8 @@ export function SettingsPage({ appContext }: Props) { const activeTab = tab || "account"; const navigate = useNavigate(); - const isAdmin = appContext.user?.role === "admin"; - - const registrationStatusQuery = useQuery({ - ...getRegistrationStatusOptions(), - enabled: isAdmin, - }); - - const updateRegistrationStatusMutation = useMutation({ - ...setRegistrationStatusMutation(), - onSuccess: () => { - toast.success("Registration settings updated"); - void registrationStatusQuery.refetch(); - }, - onError: (error) => { - toast.error("Failed to update registration settings", { - description: error.message, - }); - }, - }); + const { activeMember } = useOrganizationContext(); + const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin"; const handleLogout = async () => { await authClient.signOut({ @@ -166,8 +145,7 @@ export function SettingsPage({ appContext }: Props) { Account - {isAdmin && Users & Authentication} - {isAdmin && System} + {isOrgAdmin && Organization}
@@ -307,17 +285,19 @@ export function SettingsPage({ appContext }: Props) { - {isAdmin && ( - + {isOrgAdmin && ( +
- - User Management + + Members - Manage users, roles and permissions + Manage organization members and roles
- + + +
@@ -334,38 +314,6 @@ export function SettingsPage({ appContext }: Props) {
)} - - {isAdmin && ( - - -
- - - System Settings - - Manage system-wide settings -
- -
-
- -

When enabled, new users can sign up

-
- - updateRegistrationStatusMutation.mutate({ body: { enabled: checked } }) - } - disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending} - /> -
-
-
-
- )}
diff --git a/app/routeTree.gen.ts b/app/routeTree.gen.ts index 696df13b..e0a737d3 100644 --- a/app/routeTree.gen.ts +++ b/app/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as dashboardSettingsIndexRouteImport } from './routes/(dashboard) import { Route as dashboardRepositoriesIndexRouteImport } from './routes/(dashboard)/repositories/index' import { Route as dashboardNotificationsIndexRouteImport } from './routes/(dashboard)/notifications/index' import { Route as dashboardBackupsIndexRouteImport } from './routes/(dashboard)/backups/index' +import { Route as dashboardAdminIndexRouteImport } from './routes/(dashboard)/admin/index' import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create' import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId' import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create' @@ -96,6 +97,11 @@ const dashboardBackupsIndexRoute = dashboardBackupsIndexRouteImport.update({ path: '/backups/', getParentRoute: () => dashboardRouteRoute, } as any) +const dashboardAdminIndexRoute = dashboardAdminIndexRouteImport.update({ + id: '/admin/', + path: '/admin/', + getParentRoute: () => dashboardRouteRoute, +} as any) const dashboardVolumesCreateRoute = dashboardVolumesCreateRouteImport.update({ id: '/volumes/create', path: '/volumes/create', @@ -190,6 +196,7 @@ export interface FileRoutesByFullPath { '/repositories/create': typeof dashboardRepositoriesCreateRoute '/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/volumes/create': typeof dashboardVolumesCreateRoute + '/admin/': typeof dashboardAdminIndexRoute '/backups/': typeof dashboardBackupsIndexRoute '/notifications/': typeof dashboardNotificationsIndexRoute '/repositories/': typeof dashboardRepositoriesIndexRoute @@ -216,6 +223,7 @@ export interface FileRoutesByTo { '/repositories/create': typeof dashboardRepositoriesCreateRoute '/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/volumes/create': typeof dashboardVolumesCreateRoute + '/admin': typeof dashboardAdminIndexRoute '/backups': typeof dashboardBackupsIndexRoute '/notifications': typeof dashboardNotificationsIndexRoute '/repositories': typeof dashboardRepositoriesIndexRoute @@ -245,6 +253,7 @@ export interface FileRoutesById { '/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute '/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute + '/(dashboard)/admin/': typeof dashboardAdminIndexRoute '/(dashboard)/backups/': typeof dashboardBackupsIndexRoute '/(dashboard)/notifications/': typeof dashboardNotificationsIndexRoute '/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute @@ -273,6 +282,7 @@ export interface FileRouteTypes { | '/repositories/create' | '/volumes/$volumeId' | '/volumes/create' + | '/admin/' | '/backups/' | '/notifications/' | '/repositories/' @@ -299,6 +309,7 @@ export interface FileRouteTypes { | '/repositories/create' | '/volumes/$volumeId' | '/volumes/create' + | '/admin' | '/backups' | '/notifications' | '/repositories' @@ -327,6 +338,7 @@ export interface FileRouteTypes { | '/(dashboard)/repositories/create' | '/(dashboard)/volumes/$volumeId' | '/(dashboard)/volumes/create' + | '/(dashboard)/admin/' | '/(dashboard)/backups/' | '/(dashboard)/notifications/' | '/(dashboard)/repositories/' @@ -434,6 +446,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof dashboardBackupsIndexRouteImport parentRoute: typeof dashboardRouteRoute } + '/(dashboard)/admin/': { + id: '/(dashboard)/admin/' + path: '/admin' + fullPath: '/admin/' + preLoaderRoute: typeof dashboardAdminIndexRouteImport + parentRoute: typeof dashboardRouteRoute + } '/(dashboard)/volumes/create': { id: '/(dashboard)/volumes/create' path: '/volumes/create' @@ -570,6 +589,7 @@ interface dashboardRouteRouteChildren { dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute + dashboardAdminIndexRoute: typeof dashboardAdminIndexRoute dashboardBackupsIndexRoute: typeof dashboardBackupsIndexRoute dashboardNotificationsIndexRoute: typeof dashboardNotificationsIndexRoute dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute @@ -592,6 +612,7 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = { dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute, dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute, dashboardVolumesCreateRoute: dashboardVolumesCreateRoute, + dashboardAdminIndexRoute: dashboardAdminIndexRoute, dashboardBackupsIndexRoute: dashboardBackupsIndexRoute, dashboardNotificationsIndexRoute: dashboardNotificationsIndexRoute, dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute, diff --git a/app/routes/(dashboard)/admin/index.tsx b/app/routes/(dashboard)/admin/index.tsx new file mode 100644 index 00000000..a44aca04 --- /dev/null +++ b/app/routes/(dashboard)/admin/index.tsx @@ -0,0 +1,30 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { type } from "arktype"; +import { fetchUser } from "../route"; +import type { AppContext } from "~/context"; +import { AdminPage } from "~/client/modules/admin/routes/admin-page"; +import { getAdminUsersOptions } from "~/client/api-client/@tanstack/react-query.gen"; + +export const Route = createFileRoute("/(dashboard)/admin/")({ + validateSearch: type({ tab: "string?" }), + component: RouteComponent, + loader: async ({ context }) => { + const authContext = await fetchUser(); + + if (authContext.user?.role !== "admin") { + throw redirect({ to: "/settings" }); + } + + await context.queryClient.ensureQueryData(getAdminUsersOptions()); + + return authContext as AppContext; + }, + staticData: { + breadcrumb: () => [{ label: "Administration" }], + }, +}); + +function RouteComponent() { + const appContext = Route.useLoaderData(); + return ; +} diff --git a/app/routes/(dashboard)/settings/index.tsx b/app/routes/(dashboard)/settings/index.tsx index fa491317..b76fc716 100644 --- a/app/routes/(dashboard)/settings/index.tsx +++ b/app/routes/(dashboard)/settings/index.tsx @@ -3,21 +3,12 @@ import { fetchUser } from "../route"; import type { AppContext } from "~/context"; import { SettingsPage } from "~/client/modules/settings/routes/settings"; import { type } from "arktype"; -import { getAdminUsersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen"; export const Route = createFileRoute("/(dashboard)/settings/")({ component: RouteComponent, validateSearch: type({ tab: "string?" }), - loader: async ({ context }) => { + loader: async () => { const authContext = await fetchUser(); - - if (authContext.user?.role === "admin") { - await Promise.all([ - context.queryClient.ensureQueryData(getSsoSettingsOptions()), - context.queryClient.ensureQueryData(getAdminUsersOptions()), - ]); - } - return authContext as AppContext; }, staticData: { diff --git a/app/routes/(dashboard)/settings/sso/new.tsx b/app/routes/(dashboard)/settings/sso/new.tsx index 2ec2907e..e77c23d6 100644 --- a/app/routes/(dashboard)/settings/sso/new.tsx +++ b/app/routes/(dashboard)/settings/sso/new.tsx @@ -1,17 +1,16 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider"; -import { fetchUser } from "../../route"; +import { getOrganizationContext } from "~/server/lib/functions/organization-context"; export const Route = createFileRoute("/(dashboard)/settings/sso/new")({ component: RouteComponent, loader: async () => { - const authContext = await fetchUser(); + const orgContext = await getOrganizationContext(); + const role = orgContext.activeMember?.role; - if (authContext.user?.role !== "admin") { + if (role !== "owner" && role !== "admin") { throw redirect({ to: "/settings" }); } - - return authContext; }, staticData: { breadcrumb: () => [{ label: "Settings", href: "/settings" }, { label: "Register SSO Provider" }], diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 5fe675b3..466557c1 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -124,12 +124,8 @@ export const auth = betterAuth({ sso({ trustEmailVerified: false, providersLimit: async (user: User) => { - const existingUser = await db.query.usersTable.findFirst({ - columns: { role: true }, - where: { id: user.id }, - }); - - return existingUser?.role === "admin" ? 10 : 0; + const isOrgAdmin = await authService.isOrgAdminAnywhere(user.id); + return isOrgAdmin ? 10 : 0; }, organizationProvisioning: { disabled: false, diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index b3f9c675..dac195f9 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -16,9 +16,14 @@ import { updateSsoProviderAutoLinkingBody, updateSsoProviderAutoLinkingDto, deleteUserAccountDto, + getOrgMembersDto, + type OrgMembersDto, + updateMemberRoleBody, + updateMemberRoleDto, + removeOrgMemberDto, } from "./auth.dto"; import { authService } from "./auth.service"; -import { requireAdmin, requireAuth } from "./auth.middleware"; +import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware"; import { auth } from "~/server/lib/auth"; export const authController = new Hono() @@ -30,7 +35,7 @@ export const authController = new Hono() const providers = await authService.getPublicSsoProviders(); return c.json(providers); }) - .get("/sso-settings", requireAuth, requireAdmin, getSsoSettingsDto, async (c) => { + .get("/sso-settings", requireAuth, requireOrgAdmin, getSsoSettingsDto, async (c) => { const headers = c.req.raw.headers; const activeOrganizationId = c.get("organizationId"); @@ -62,7 +67,7 @@ export const authController = new Hono() })), }); }) - .delete("/sso-providers/:providerId", requireAuth, requireAdmin, deleteSsoProviderDto, async (c) => { + .delete("/sso-providers/:providerId", requireAuth, requireOrgAdmin, deleteSsoProviderDto, async (c) => { const providerId = c.req.param("providerId"); const organizationId = c.get("organizationId"); @@ -77,7 +82,7 @@ export const authController = new Hono() .patch( "/sso-providers/:providerId/auto-linking", requireAuth, - requireAdmin, + requireOrgAdmin, updateSsoProviderAutoLinkingDto, validator("json", updateSsoProviderAutoLinkingBody), async (c) => { @@ -94,7 +99,7 @@ export const authController = new Hono() return c.json({ success: true }); }, ) - .delete("/sso-invitations/:invitationId", requireAuth, requireAdmin, deleteSsoInvitationDto, async (c) => { + .delete("/sso-invitations/:invitationId", requireAuth, requireOrgAdmin, deleteSsoInvitationDto, async (c) => { const invitationId = c.req.param("invitationId"); const organizationId = c.get("organizationId"); @@ -151,4 +156,49 @@ export const authController = new Hono() const userId = c.req.param("userId"); const impact = await authService.getUserDeletionImpact(userId); return c.json(impact); + }) + .get("/org-members", requireAuth, requireOrgAdmin, getOrgMembersDto, async (c) => { + const organizationId = c.get("organizationId"); + const result = await authService.getOrgMembers(organizationId); + return c.json(result); + }) + .patch( + "/org-members/:memberId/role", + requireAuth, + requireOrgAdmin, + updateMemberRoleDto, + validator("json", updateMemberRoleBody), + async (c) => { + const memberId = c.req.param("memberId"); + const organizationId = c.get("organizationId"); + const { role } = c.req.valid("json"); + + const result = await authService.updateMemberRole(memberId, organizationId, role); + + if (!result.found) { + return c.json({ message: "Member not found" }, 404); + } + + if (result.isOwner) { + return c.json({ message: "Cannot change the role of the organization owner" }, 403); + } + + return c.json({ success: true }); + }, + ) + .delete("/org-members/:memberId", requireAuth, requireOrgAdmin, removeOrgMemberDto, async (c) => { + const memberId = c.req.param("memberId"); + const organizationId = c.get("organizationId"); + + const result = await authService.removeOrgMember(memberId, organizationId); + + if (!result.found) { + return c.json({ message: "Member not found" }, 404); + } + + if (result.isOwner) { + return c.json({ message: "Cannot remove the organization owner" }, 403); + } + + return c.json({ success: true }); }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index de2f274e..421b939e 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -197,6 +197,75 @@ export const deleteUserAccountDto = describeRoute({ }, }); +export const orgMembersResponse = type({ + members: type({ + id: "string", + userId: "string", + role: "string", + createdAt: "string", + user: { + name: "string | null", + email: "string", + }, + }).array(), +}); + +export type OrgMembersDto = typeof orgMembersResponse.infer; + +export const getOrgMembersDto = describeRoute({ + description: "Get members of the active organization", + operationId: "getOrgMembers", + tags: ["Auth"], + responses: { + 200: { + description: "List of organization members", + content: { + "application/json": { + schema: resolver(orgMembersResponse), + }, + }, + }, + }, +}); + +export const updateMemberRoleBody = type({ + role: "'member' | 'admin'", +}); + +export const updateMemberRoleDto = describeRoute({ + description: "Update a member's role in the active organization", + operationId: "updateMemberRole", + tags: ["Auth"], + responses: { + 200: { + description: "Member role updated successfully", + }, + 403: { + description: "Forbidden", + }, + 404: { + description: "Member not found", + }, + }, +}); + +export const removeOrgMemberDto = describeRoute({ + description: "Remove a member from the active organization", + operationId: "removeOrgMember", + tags: ["Auth"], + responses: { + 200: { + description: "Member removed successfully", + }, + 403: { + description: "Forbidden", + }, + 404: { + description: "Member not found", + }, + }, +}); + 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 6e32d024..bdb7a2f9 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -212,6 +212,86 @@ export class AuthService { return grouped; } + /** + * Get all members of an organization with their user data + */ + async getOrgMembers(organizationId: string) { + const members = await db.query.member.findMany({ + where: { organizationId }, + with: { user: true }, + }); + + return { + members: members.map((m) => ({ + id: m.id, + userId: m.userId, + role: m.role, + createdAt: new Date(m.createdAt).toISOString(), + user: { + name: m.user.name, + email: m.user.email, + }, + })), + }; + } + + /** + * Update a member's role in an organization. + * Cannot change the role of an owner. + */ + async updateMemberRole(memberId: string, organizationId: string, role: "member" | "admin") { + const targetMember = await db.query.member.findFirst({ + where: { AND: [{ id: memberId }, { organizationId }] }, + }); + + if (!targetMember) { + return { found: false, isOwner: false } as const; + } + + if (targetMember.role === "owner") { + return { found: true, isOwner: true } as const; + } + + await db.update(member).set({ role }).where(eq(member.id, memberId)); + + return { found: true, isOwner: false } as const; + } + + /** + * Remove a member from an organization. + * Cannot remove an owner. + */ + async removeOrgMember(memberId: string, organizationId: string) { + const targetMember = await db.query.member.findFirst({ + where: { AND: [{ id: memberId }, { organizationId }] }, + }); + + if (!targetMember) { + return { found: false, isOwner: false } as const; + } + + if (targetMember.role === "owner") { + return { found: true, isOwner: true } as const; + } + + await db.delete(member).where(eq(member.id, memberId)); + + return { found: true, isOwner: false } as const; + } + + /** + * Check if a user is an owner or admin in any organization + */ + async isOrgAdminAnywhere(userId: string) { + const membership = await db.query.member.findFirst({ + where: { + AND: [{ userId }, { role: { in: ["owner", "admin"] } }], + }, + }); + + return !!membership; + } + /** * Delete a single account for a user, refusing if it is the last one */ diff --git a/e2e/0003-oidc.spec.ts b/e2e/0003-oidc.spec.ts index 65fa16a1..2dde1c09 100644 --- a/e2e/0003-oidc.spec.ts +++ b/e2e/0003-oidc.spec.ts @@ -22,7 +22,7 @@ const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; async function openSsoSettings(page: Page) { - await gotoAndWaitForAppReady(page, "/settings?tab=users"); + await gotoAndWaitForAppReady(page, "/settings?tab=organization"); await expect(page.getByText("Single Sign-On")).toBeVisible(); }