feat: separate global admin page (#595)

This commit is contained in:
Nico 2026-02-28 12:32:43 +01:00 committed by GitHub
parent e6a8b83900
commit f686c1aa16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 796 additions and 108 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, 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<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -198,6 +198,58 @@ export const getUserDeletionImpactOptions = (options: Options<GetUserDeletionImp
queryKey: getUserDeletionImpactQueryKey(options)
});
export const getOrgMembersQueryKey = (options?: Options<GetOrgMembersData>) => createQueryKey('getOrgMembers', options);
/**
* Get members of the active organization
*/
export const getOrgMembersOptions = (options?: Options<GetOrgMembersData>) => queryOptions<GetOrgMembersResponse, DefaultError, GetOrgMembersResponse, ReturnType<typeof getOrgMembersQueryKey>>({
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<Options<UpdateMemberRoleData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateMemberRoleData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateMemberRoleData>> = {
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<Options<RemoveOrgMemberData>>): UseMutationOptions<unknown, DefaultError, Options<RemoveOrgMemberData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<RemoveOrgMemberData>> = {
mutationFn: async (fnOptions) => {
const { data } = await removeOrgMember({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/**

View file

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

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, 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -71,6 +71,28 @@ export const deleteUserAccount = <ThrowOnError extends boolean = false>(options:
*/
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(options: Options<GetUserDeletionImpactData, ThrowOnError>) => (options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/deletion-impact/{userId}', ...options });
/**
* Get members of the active organization
*/
export const getOrgMembers = <ThrowOnError extends boolean = false>(options?: Options<GetOrgMembersData, ThrowOnError>) => (options?.client ?? client).get<GetOrgMembersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/org-members', ...options });
/**
* Update a member's role in the active organization
*/
export const updateMemberRole = <ThrowOnError extends boolean = false>(options: Options<UpdateMemberRoleData, ThrowOnError>) => (options.client ?? client).patch<UpdateMemberRoleResponses, UpdateMemberRoleErrors, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
/**
* List all volumes
*/

View file

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

View file

@ -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() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{isInstanceAdmin && (
<>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton asChild className="relative overflow-hidden">
<Link
to="/admin"
onClick={() => isMobile && setOpenMobile(false)}
activeProps={{ className: "bg-strong-accent/10" }}
className="w-full flex items-center gap-2"
>
{({ isActive }) => (
<>
{isActive && (
<div
className={cn("absolute left-0 top-0 h-full w-0.75 bg-strong-accent mr-2", {
hidden: isCollapsed,
})}
/>
)}
<ShieldCheck
className={cn("transition-all duration-200", {
"text-strong-accent": isActive,
"ml-1": isActive && !isCollapsed,
"text-muted-foreground": !isActive,
})}
/>
<span
className={cn({
"text-white font-medium": isActive,
"text-muted-foreground": !isActive,
})}
>
Administration
</span>
</>
)}
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
<p>Administration</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)}
</SidebarContent>
<SidebarFooter className="p-4 border-r border-border/50">
<OrganizationSwitcher />

View file

@ -33,7 +33,7 @@ export function Layout({ loaderData }: Props) {
return (
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
<AppSidebar />
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0 h-16.25">
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">

View file

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

View file

@ -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 (
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
<TabsList>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="system">System</TabsTrigger>
</TabsList>
<div className="mt-2">
<TabsContent value="users" className="mt-0">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Users className="size-5" />
User Management
</CardTitle>
<CardDescription className="mt-1.5">Manage users, roles and permissions</CardDescription>
</div>
<UserManagement currentUser={appContext.user} />
</Card>
</TabsContent>
<TabsContent value="system" className="mt-0">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="size-5" />
System Settings
</CardTitle>
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
</div>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enable-registrations" className="text-base">
Enable new user registrations
</Label>
<p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
</div>
<Switch
id="enable-registrations"
checked={registrationStatus.data.enabled}
onCheckedChange={(checked) =>
updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })
}
disabled={updateRegistrationStatusMutation.isPending}
/>
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View file

@ -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 (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className={cn({ hidden: members.length > 0 })}>
<TableCell colSpan={3} className="text-center text-sm text-muted-foreground">
No members found.
</TableCell>
</TableRow>
{members.map((member) => {
const isOwner = member.role === "owner";
const isSelf = member.id === activeMember?.id;
return (
<TableRow key={member.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{member.user.name ?? member.user.email}</span>
<span className="text-sm text-muted-foreground">{member.user.email}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{member.role}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
title="Demote to Member"
className={cn({ hidden: member.role !== "admin" || isSelf })}
disabled={updateRole.isPending}
onClick={() =>
updateRole.mutate({
path: { memberId: member.id },
body: { role: "member" },
})
}
>
<ShieldAlert className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Promote to Admin"
className={cn({ hidden: member.role !== "member" || isSelf })}
disabled={updateRole.isPending}
onClick={() =>
updateRole.mutate({
path: { memberId: member.id },
body: { role: "admin" },
})
}
>
<Shield className="h-4 w-4" />
</Button>
<AlertDialog
open={memberToRemove?.id === member.id}
onOpenChange={(open) => !open && setMemberToRemove(null)}
>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
title="Remove member"
className={cn({ hidden: isOwner || isSelf })}
onClick={() =>
setMemberToRemove({
id: member.id,
name: member.user.name ?? member.user.email,
})
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove <strong>{memberToRemove?.name}</strong> from this
organization? They will lose access to all organization resources.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={removeMember.isPending}
onClick={() => removeMember.mutate({ path: { memberId: memberToRemove!.id } })}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -42,13 +42,10 @@ export function SsoSettingsSection() {
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<InvitationRole>("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 (
<div className="space-y-6">
<div className="space-y-3">

View file

@ -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() {
<Button
type="button"
variant="secondary"
onClick={() => void navigate({ to: "/settings", search: { tab: "users" } })}
onClick={() => void navigate({ to: "/settings", search: { tab: "organization" } })}
>
Cancel
</Button>

View file

@ -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) {
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
{isAdmin && <TabsTrigger value="users">Users & Authentication</TabsTrigger>}
{isAdmin && <TabsTrigger value="system">System</TabsTrigger>}
{isOrgAdmin && <TabsTrigger value="organization">Organization</TabsTrigger>}
</TabsList>
<div className="mt-2">
@ -307,17 +285,19 @@ export function SettingsPage({ appContext }: Props) {
</Card>
</TabsContent>
{isAdmin && (
<TabsContent value="users" className="mt-0 space-y-4">
{isOrgAdmin && (
<TabsContent value="organization" className="mt-0 space-y-4">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Users className="size-5" />
User Management
<Building2 className="size-5" />
Members
</CardTitle>
<CardDescription className="mt-1.5">Manage users, roles and permissions</CardDescription>
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription>
</div>
<UserManagement currentUser={appContext.user} />
<CardContent className="p-6">
<OrgMembersSection />
</CardContent>
</Card>
<Card className="p-0 gap-0">
@ -334,38 +314,6 @@ export function SettingsPage({ appContext }: Props) {
</Card>
</TabsContent>
)}
{isAdmin && (
<TabsContent value="system" className="mt-0">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="size-5" />
System Settings
</CardTitle>
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
</div>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enable-registrations" className="text-base">
Enable new user registrations
</Label>
<p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
</div>
<Switch
id="enable-registrations"
checked={registrationStatusQuery.data?.enabled ?? false}
onCheckedChange={(checked) =>
updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })
}
disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending}
/>
</div>
</CardContent>
</Card>
</TabsContent>
)}
</div>
</Tabs>
</div>

View file

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

View file

@ -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 <AdminPage appContext={appContext} />;
}

View file

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

View file

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

View file

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

View file

@ -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<PublicSsoProvidersDto>(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<UserDeletionImpactDto>(impact);
})
.get("/org-members", requireAuth, requireOrgAdmin, getOrgMembersDto, async (c) => {
const organizationId = c.get("organizationId");
const result = await authService.getOrgMembers(organizationId);
return c.json<OrgMembersDto>(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 });
});

View file

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

View file

@ -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
*/

View file

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