feat: link current account

This commit is contained in:
Nicolas Meienberger 2026-02-22 10:08:01 +01:00
parent a3af29ec4e
commit 4619d9d976
29 changed files with 2801 additions and 119 deletions

View file

@ -61,4 +61,7 @@ bunx oxfmt format --write <path>
bun run lint
```
The frontend has an automatic invalidation setup which runs after every mutation, do not write any invalidation logic in the frontend.
### Invalidation
The frontend has an automatic invalidation setup which runs after every mutation.
Do not implement any invalidation logic in the frontend.

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -111,6 +111,23 @@ export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoPro
return mutationOptions;
};
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateSsoProviderAutoLinking({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete an SSO invitation
*/

View file

@ -67,6 +67,7 @@ export {
updateRepository,
updateScheduleMirrors,
updateScheduleNotifications,
updateSsoProviderAutoLinking,
updateVolume,
} from "./sdk.gen";
export type {
@ -274,6 +275,9 @@ export type {
UpdateScheduleNotificationsData,
UpdateScheduleNotificationsResponse,
UpdateScheduleNotificationsResponses,
UpdateSsoProviderAutoLinkingData,
UpdateSsoProviderAutoLinkingErrors,
UpdateSsoProviderAutoLinkingResponses,
UpdateVolumeData,
UpdateVolumeErrors,
UpdateVolumeResponse,

View file

@ -3,7 +3,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, 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, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -39,6 +39,18 @@ export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: O
*/
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete an SSO invitation
*/

View file

@ -64,6 +64,7 @@ export type GetSsoSettingsResponses = {
status: string;
}>;
providers: Array<{
autoLinkMatchingEmails: boolean;
domain: string;
issuer: string;
organizationId: string | null;
@ -98,6 +99,35 @@ export type DeleteSsoProviderResponses = {
200: unknown;
};
export type UpdateSsoProviderAutoLinkingData = {
body?: {
enabled: boolean;
};
path: {
providerId: string;
};
query?: never;
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking';
};
export type UpdateSsoProviderAutoLinkingErrors = {
/**
* Forbidden
*/
403: unknown;
/**
* Provider not found
*/
404: unknown;
};
export type UpdateSsoProviderAutoLinkingResponses = {
/**
* SSO provider auto-linking setting updated successfully
*/
200: unknown;
};
export type DeleteSsoInvitationData = {
body?: never;
path: {
@ -134,6 +164,7 @@ export type GetAdminUsersResponses = {
*/
200: {
limit: number;
offset: number;
total: number;
users: Array<{
banned: boolean;

View file

@ -42,7 +42,11 @@ export function OrganizationSwitcher() {
},
});
if (organizations && organizations.length <= 1) {
if (organizations === undefined) {
return null;
}
if (organizations.length <= 1) {
return null;
}
@ -68,7 +72,7 @@ export function OrganizationSwitcher() {
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeOrganization?.name}</span>
<span className="truncate text-xs">{organizations?.length} organizations</span>
<span className="truncate text-xs">{organizations.length} organizations</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
@ -80,7 +84,7 @@ export function OrganizationSwitcher() {
sideOffset={4}
>
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
{organizations?.map((organization) => (
{organizations.map((organization) => (
<DropdownMenuItem
key={organization.id}
onClick={() => switchOrganizationMutation.mutate(organization.id)}

View file

@ -1,7 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { type } from "arktype";
import { Ban, Trash2 } from "lucide-react";
import { AlertTriangle, Ban, Trash2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@ -9,8 +9,8 @@ import {
deleteSsoInvitationMutation,
deleteSsoProviderMutation,
getSsoSettingsOptions,
updateSsoProviderAutoLinkingMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
@ -22,7 +22,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "~/client/components/ui/alert-dialog";
import { Badge } from "~/client/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
@ -44,6 +44,7 @@ import {
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Switch } from "~/client/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { authClient } from "~/client/lib/auth-client";
import { parseError } from "~/client/lib/errors";
@ -58,6 +59,7 @@ const ssoProviderSchema = type({
clientId: "string>=1",
clientSecret: "string>=1",
discoveryEndpoint: "string>=1",
linkMatchingEmails: "boolean",
});
type ProviderForm = typeof ssoProviderSchema.infer;
@ -79,6 +81,7 @@ export function SsoSettingsSection() {
clientId: "",
clientSecret: "",
discoveryEndpoint: "",
linkMatchingEmails: true,
},
});
@ -89,6 +92,10 @@ export function SsoSettingsSection() {
const providers = data.providers;
const invitations = data.invitations;
const updateProviderAutoLinkingMutation = useMutation({
...updateSsoProviderAutoLinkingMutation(),
});
const registerProviderMutation = useMutation({
mutationFn: async (formData: ProviderForm) => {
if (!activeOrganization) {
@ -109,6 +116,17 @@ export function SsoSettingsSection() {
});
if (error) throw error;
await updateProviderAutoLinkingMutation
.mutateAsync({
path: { providerId: formData.providerId },
body: { enabled: formData.linkMatchingEmails },
})
.catch((updateError) => {
throw new Error(
`The provider was created, but we could not save the auto-link setting. ${parseError(updateError)?.message ?? ""}`,
);
});
},
onSuccess: () => {
toast.success("SSO provider registered successfully");
@ -318,6 +336,32 @@ export function SsoSettingsSection() {
/>
</div>
<FormField
control={form.control}
name="linkMatchingEmails"
render={({ field }) => (
<FormItem className="rounded-md border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<FormLabel>Link matching emails to existing accounts</FormLabel>
<FormDescription>
If enabled, users who sign in with this provider will automatically access their existing
account when the email address matches.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={registerProviderMutation.isPending}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
{!activeOrganization ? (
<Alert variant="destructive">
<AlertDescription>
@ -337,6 +381,13 @@ export function SsoSettingsSection() {
</Dialog>
</div>
<Alert variant="warning">
<AlertDescription>
Only enable automatic account linking for identity providers you trust. You can change this per provider at
any time.
</AlertDescription>
</Alert>
<div className="rounded-md border">
<Table>
<TableHeader>
@ -345,6 +396,7 @@ export function SsoSettingsSection() {
<TableHead>Domain</TableHead>
<TableHead>Issuer</TableHead>
<TableHead>Type</TableHead>
<TableHead>Auto-link existing account</TableHead>
<TableHead>Callback URL</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
@ -358,9 +410,42 @@ export function SsoSettingsSection() {
<TableCell className="break-all">{provider.issuer}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant="outline" className="uppercase">
<span className="uppercase text-xs font-medium px-2 py-0.5 rounded border">
{provider.type}
</Badge>
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={provider.autoLinkMatchingEmails}
disabled={updateProviderAutoLinkingMutation.isPending}
onCheckedChange={(enabled) => {
updateProviderAutoLinkingMutation.mutate(
{
path: { providerId: provider.providerId },
body: { enabled },
},
{
onSuccess: () => {
toast.success(
enabled
? "Automatic account linking enabled"
: "Automatic account linking disabled",
);
},
onError: (error) => {
toast.error("Failed to update provider", {
description: parseError(error)?.message,
});
},
},
);
}}
/>
<span className="text-xs text-muted-foreground">
{provider.autoLinkMatchingEmails ? "On" : "Off"}
</span>
</div>
</TableCell>
<TableCell>
@ -415,7 +500,7 @@ export function SsoSettingsSection() {
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
<TableCell colSpan={7} className="text-center text-sm text-muted-foreground">
No providers registered yet.
</TableCell>
</TableRow>
@ -494,9 +579,15 @@ export function SsoSettingsSection() {
<TableCell className="font-medium">{invitation.email}</TableCell>
<TableCell className="uppercase">{invitation.role}</TableCell>
<TableCell>
<Badge variant={invitation.status === "pending" ? "default" : "outline"}>
<span
className={`text-xs font-medium px-2 py-0.5 rounded border ${
invitation.status === "pending"
? "bg-primary/10 border-primary/20"
: "bg-muted border-muted-foreground/20"
}`}
>
{invitation.status}
</Badge>
</span>
</TableCell>
<TableCell>{formatDateWithMonth(invitation.expiresAt)}</TableCell>
<TableCell className="text-right">

View file

@ -228,7 +228,11 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
<Button variant="outline" onClick={() => setUserToDelete(null)}>
Cancel
</Button>
<Button variant="destructive" disabled={isLoadingImpact} onClick={() => deleteUser.mutate(userToDelete!)}>
<Button
variant="destructive"
disabled={isLoadingImpact || deleteUser.isPending}
onClick={() => deleteUser.mutate(userToDelete!)}
>
Delete User
</Button>
</DialogFooter>

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, KeyRound, User, X, Users, Settings as SettingsIcon } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
downloadResticPasswordMutation,
@ -41,12 +41,34 @@ export function SettingsPage({ appContext }: Props) {
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
const { tab, ssoLinkStatus, ssoLinkMessage } = useSearch({ from: "/(dashboard)/settings/" });
const activeTab = tab || "account";
const navigate = useNavigate();
const isAdmin = appContext.user?.role === "admin";
useEffect(() => {
if (!ssoLinkStatus) {
return;
}
if (ssoLinkStatus === "success") {
toast.success("SSO account linked", {
description: ssoLinkMessage || "You can now sign in with this SSO provider.",
});
} else {
toast.error("SSO account linking failed", {
description: ssoLinkMessage || "Please try again.",
});
}
void navigate({
to: ".",
replace: true,
search: (prev) => ({ tab: prev.tab }),
});
}, [navigate, ssoLinkMessage, ssoLinkStatus]);
const registrationStatusQuery = useQuery({
...getRegistrationStatusOptions(),
enabled: isAdmin,

View file

@ -0,0 +1,25 @@
ALTER TABLE `sso_provider` ADD `auto_link_matching_emails` integer DEFAULT true NOT NULL;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_sso_provider` (
`id` text PRIMARY KEY,
`provider_id` text NOT NULL,
`organization_id` text NOT NULL,
`user_id` text,
`issuer` text NOT NULL,
`domain` text NOT NULL,
`auto_link_matching_emails` integer DEFAULT true NOT NULL,
`oidc_config` text,
`saml_config` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
CONSTRAINT `fk_sso_provider_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_sso_provider_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE SET NULL
);
--> statement-breakpoint
INSERT INTO `__new_sso_provider`(`id`, `provider_id`, `organization_id`, `user_id`, `issuer`, `domain`, `oidc_config`, `saml_config`, `created_at`, `updated_at`) SELECT `id`, `provider_id`, `organization_id`, `user_id`, `issuer`, `domain`, `oidc_config`, `saml_config`, `created_at`, `updated_at` FROM `sso_provider`;--> statement-breakpoint
DROP TABLE `sso_provider`;--> statement-breakpoint
ALTER TABLE `__new_sso_provider` RENAME TO `sso_provider`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `sso_provider_provider_id_uidx` ON `sso_provider` (`provider_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `sso_provider_organization_id_uidx` ON `sso_provider` (`organization_id`);--> statement-breakpoint
CREATE INDEX `sso_provider_domain_idx` ON `sso_provider` (`domain`);

File diff suppressed because it is too large Load diff

View file

@ -27,10 +27,11 @@ export const authMiddleware = createMiddleware().server(async ({ next, request }
}
if (session?.user?.id) {
if (!session.user.hasDownloadedResticPassword && pathname !== "/download-recovery-key") {
throw redirect({ to: "/download-recovery-key" });
}
if (isAuthRoute(pathname)) {
if (!session.user.hasDownloadedResticPassword) {
throw redirect({ to: "/download-recovery-key" });
}
throw redirect({ to: "/volumes" });
}
}

View file

@ -23,7 +23,6 @@ import { Route as dashboardNotificationsIndexRouteImport } from './routes/(dashb
import { Route as dashboardBackupsIndexRouteImport } from './routes/(dashboard)/backups/index'
import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create'
import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId'
import { Route as dashboardSettingsSsoLinkRouteImport } from './routes/(dashboard)/settings/sso-link'
import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create'
import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create'
import { Route as dashboardNotificationsNotificationIdRouteImport } from './routes/(dashboard)/notifications/$notificationId'
@ -31,7 +30,6 @@ import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)
import { Route as authLoginErrorRouteImport } from './routes/(auth)/login.error'
import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index'
import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/index'
import { Route as dashboardSettingsSsoLinkErrorRouteImport } from './routes/(dashboard)/settings/sso-link.error'
import { Route as dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
@ -108,12 +106,6 @@ const dashboardVolumesVolumeIdRoute =
path: '/volumes/$volumeId',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardSettingsSsoLinkRoute =
dashboardSettingsSsoLinkRouteImport.update({
id: '/settings/sso-link',
path: '/settings/sso-link',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesCreateRoute =
dashboardRepositoriesCreateRouteImport.update({
id: '/repositories/create',
@ -154,12 +146,6 @@ const dashboardBackupsBackupIdIndexRoute =
path: '/backups/$backupId/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardSettingsSsoLinkErrorRoute =
dashboardSettingsSsoLinkErrorRouteImport.update({
id: '/error',
path: '/error',
getParentRoute: () => dashboardSettingsSsoLinkRoute,
} as any)
const dashboardRepositoriesRepositoryIdEditRoute =
dashboardRepositoriesRepositoryIdEditRouteImport.update({
id: '/repositories/$repositoryId/edit',
@ -196,7 +182,6 @@ export interface FileRoutesByFullPath {
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
'/notifications/create': typeof dashboardNotificationsCreateRoute
'/repositories/create': typeof dashboardRepositoriesCreateRoute
'/settings/sso-link': typeof dashboardSettingsSsoLinkRouteWithChildren
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/volumes/create': typeof dashboardVolumesCreateRoute
'/backups/': typeof dashboardBackupsIndexRoute
@ -205,7 +190,6 @@ export interface FileRoutesByFullPath {
'/settings/': typeof dashboardSettingsIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/settings/sso-link/error': typeof dashboardSettingsSsoLinkErrorRoute
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
'/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -223,7 +207,6 @@ export interface FileRoutesByTo {
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
'/notifications/create': typeof dashboardNotificationsCreateRoute
'/repositories/create': typeof dashboardRepositoriesCreateRoute
'/settings/sso-link': typeof dashboardSettingsSsoLinkRouteWithChildren
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/volumes/create': typeof dashboardVolumesCreateRoute
'/backups': typeof dashboardBackupsIndexRoute
@ -232,7 +215,6 @@ export interface FileRoutesByTo {
'/settings': typeof dashboardSettingsIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/settings/sso-link/error': typeof dashboardSettingsSsoLinkErrorRoute
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -253,7 +235,6 @@ export interface FileRoutesById {
'/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
'/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute
'/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute
'/(dashboard)/settings/sso-link': typeof dashboardSettingsSsoLinkRouteWithChildren
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute
'/(dashboard)/backups/': typeof dashboardBackupsIndexRoute
@ -262,7 +243,6 @@ export interface FileRoutesById {
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/(dashboard)/settings/sso-link/error': typeof dashboardSettingsSsoLinkErrorRoute
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
'/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
@ -282,7 +262,6 @@ export interface FileRouteTypes {
| '/notifications/$notificationId'
| '/notifications/create'
| '/repositories/create'
| '/settings/sso-link'
| '/volumes/$volumeId'
| '/volumes/create'
| '/backups/'
@ -291,7 +270,6 @@ export interface FileRouteTypes {
| '/settings/'
| '/volumes/'
| '/repositories/$repositoryId/edit'
| '/settings/sso-link/error'
| '/backups/$backupId/'
| '/repositories/$repositoryId/'
| '/backups/$backupId/$snapshotId/restore'
@ -309,7 +287,6 @@ export interface FileRouteTypes {
| '/notifications/$notificationId'
| '/notifications/create'
| '/repositories/create'
| '/settings/sso-link'
| '/volumes/$volumeId'
| '/volumes/create'
| '/backups'
@ -318,7 +295,6 @@ export interface FileRouteTypes {
| '/settings'
| '/volumes'
| '/repositories/$repositoryId/edit'
| '/settings/sso-link/error'
| '/backups/$backupId'
| '/repositories/$repositoryId'
| '/backups/$backupId/$snapshotId/restore'
@ -338,7 +314,6 @@ export interface FileRouteTypes {
| '/(dashboard)/notifications/$notificationId'
| '/(dashboard)/notifications/create'
| '/(dashboard)/repositories/create'
| '/(dashboard)/settings/sso-link'
| '/(dashboard)/volumes/$volumeId'
| '/(dashboard)/volumes/create'
| '/(dashboard)/backups/'
@ -347,7 +322,6 @@ export interface FileRouteTypes {
| '/(dashboard)/settings/'
| '/(dashboard)/volumes/'
| '/(dashboard)/repositories/$repositoryId/edit'
| '/(dashboard)/settings/sso-link/error'
| '/(dashboard)/backups/$backupId/'
| '/(dashboard)/repositories/$repositoryId/'
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
@ -462,13 +436,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/settings/sso-link': {
id: '/(dashboard)/settings/sso-link'
path: '/settings/sso-link'
fullPath: '/settings/sso-link'
preLoaderRoute: typeof dashboardSettingsSsoLinkRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/create': {
id: '/(dashboard)/repositories/create'
path: '/repositories/create'
@ -518,13 +485,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/settings/sso-link/error': {
id: '/(dashboard)/settings/sso-link/error'
path: '/error'
fullPath: '/settings/sso-link/error'
preLoaderRoute: typeof dashboardSettingsSsoLinkErrorRouteImport
parentRoute: typeof dashboardSettingsSsoLinkRoute
}
'/(dashboard)/repositories/$repositoryId/edit': {
id: '/(dashboard)/repositories/$repositoryId/edit'
path: '/repositories/$repositoryId/edit'
@ -584,26 +544,11 @@ const authRouteRouteWithChildren = authRouteRoute._addFileChildren(
authRouteRouteChildren,
)
interface dashboardSettingsSsoLinkRouteChildren {
dashboardSettingsSsoLinkErrorRoute: typeof dashboardSettingsSsoLinkErrorRoute
}
const dashboardSettingsSsoLinkRouteChildren: dashboardSettingsSsoLinkRouteChildren =
{
dashboardSettingsSsoLinkErrorRoute: dashboardSettingsSsoLinkErrorRoute,
}
const dashboardSettingsSsoLinkRouteWithChildren =
dashboardSettingsSsoLinkRoute._addFileChildren(
dashboardSettingsSsoLinkRouteChildren,
)
interface dashboardRouteRouteChildren {
dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute
dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute
dashboardNotificationsCreateRoute: typeof dashboardNotificationsCreateRoute
dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute
dashboardSettingsSsoLinkRoute: typeof dashboardSettingsSsoLinkRouteWithChildren
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute
dashboardBackupsIndexRoute: typeof dashboardBackupsIndexRoute
@ -625,7 +570,6 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardNotificationsNotificationIdRoute,
dashboardNotificationsCreateRoute: dashboardNotificationsCreateRoute,
dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute,
dashboardSettingsSsoLinkRoute: dashboardSettingsSsoLinkRouteWithChildren,
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
dashboardVolumesCreateRoute: dashboardVolumesCreateRoute,
dashboardBackupsIndexRoute: dashboardBackupsIndexRoute,

View file

@ -7,7 +7,7 @@ import { getAdminUsersOptions, getSsoSettingsOptions } from "~/client/api-client
export const Route = createFileRoute("/(dashboard)/settings/")({
component: RouteComponent,
validateSearch: type({ tab: "string?" }),
validateSearch: type({ tab: "string?", ssoLinkStatus: "string?", ssoLinkMessage: "string?" }),
loader: async ({ context }) => {
const authContext = await fetchUser();

View file

@ -1,10 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/(dashboard)/settings/sso-link/error")({
beforeLoad: () => {
throw redirect({
to: "/settings",
search: () => ({ tab: "users" }),
});
},
});

View file

@ -1,10 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/(dashboard)/settings/sso-link")({
beforeLoad: () => {
throw redirect({
to: "/settings",
search: () => ({ tab: "users" }),
});
},
});

View file

@ -178,15 +178,14 @@ export const ssoProvider = sqliteTable(
"sso_provider",
{
id: text("id").primaryKey(),
providerId: text("provider_id").notNull().unique(),
providerId: text("provider_id").notNull(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => usersTable.id, { onDelete: "set null" }),
issuer: text("issuer").notNull(),
domain: text("domain").notNull(),
autoLinkMatchingEmails: int("auto_link_matching_emails", { mode: "boolean" }).notNull().default(true),
oidcConfig: text("oidc_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
samlConfig: text("saml_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
createdAt: integer("created_at", { mode: "timestamp_ms" })

View file

@ -18,6 +18,7 @@ import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user";
import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user";
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
import { ssoTrustedProviderLinkingPlugin } from "./auth/plugins/sso-trusted-provider-linking";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -50,9 +51,8 @@ export const auth = betterAuth({
},
create: {
before: async (user, ctx) => {
await requireSsoInvitation(user.email, ctx);
if (isSsoCallbackRequest(ctx)) {
await requireSsoInvitation(user.email, ctx);
user.hasDownloadedResticPassword = true;
}
@ -85,7 +85,7 @@ export const auth = betterAuth({
},
account: {
accountLinking: {
disableImplicitLinking: true,
enabled: true,
},
},
user: {
@ -123,6 +123,7 @@ export const auth = betterAuth({
defaultRole: "member",
},
}),
ssoTrustedProviderLinkingPlugin(),
twoFactor({
backupCodeOptions: {
storeBackupCodes: "encrypted",

View file

@ -0,0 +1,173 @@
import { beforeEach, describe, expect, test } from "bun:test";
import type { GenericEndpointContext } from "@better-auth/core";
import { db } from "~/server/db/db";
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../trust-sso-provider-for-linking";
function randomId() {
return Bun.randomUUIDv7();
}
function randomSlug(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
function createMockContext(options: {
path: string;
method?: string;
params?: Record<string, string>;
trustedProviders?: string[];
enabled?: boolean;
}): GenericEndpointContext {
const { path, method = "GET", params = {}, trustedProviders = [], enabled = true } = options;
const accountLinking = {
enabled,
trustedProviders,
};
const context = {
options: {
account: {
accountLinking,
},
},
};
return {
path,
body: {},
query: {},
headers: new Headers(),
request: new Request(`http://test.local${path}`, { method }),
params,
method,
context: context as GenericEndpointContext["context"],
} as GenericEndpointContext;
}
async function createSsoProviderRecord(providerId: string, autoLinkMatchingEmails: boolean) {
const userId = randomId();
const organizationId = randomId();
await db.insert(usersTable).values({
id: userId,
username: randomSlug("inviter"),
email: `${randomSlug("inviter")}@example.com`,
name: "Inviter",
});
await db.insert(organization).values({
id: organizationId,
name: "Acme",
slug: randomSlug("acme"),
createdAt: new Date(),
});
await db.insert(ssoProvider).values({
id: randomId(),
providerId,
organizationId,
userId,
issuer: "https://issuer.example.com",
domain: "example.com",
autoLinkMatchingEmails,
});
}
describe("isSsoCallbackPath", () => {
test("detects OIDC callback paths", () => {
expect(isSsoCallbackPath("/sso/callback/pocket-id")).toBe(true);
});
test("ignores non-callback paths", () => {
expect(isSsoCallbackPath("/sso/register")).toBe(false);
expect(isSsoCallbackPath("/sign-in/email")).toBe(false);
});
});
describe("trustSsoProviderForLinking", () => {
beforeEach(async () => {
await db.delete(member);
await db.delete(account);
await db.delete(ssoProvider);
await db.delete(organization);
await db.delete(usersTable);
});
test("adds callback provider to trusted providers", async () => {
await createSsoProviderRecord("pocket-id", true);
const ctx = createMockContext({
path: "/sso/callback/pocket-id",
params: { providerId: "pocket-id" },
});
await trustSsoProviderForLinking(ctx);
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toContain("pocket-id");
});
test("does not trust providers with disabled auto-linking", async () => {
await createSsoProviderRecord("pocket-id", false);
const ctx = createMockContext({
path: "/sso/callback/pocket-id",
params: { providerId: "pocket-id" },
});
await trustSsoProviderForLinking(ctx);
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
});
test("does not trust unknown providers", async () => {
const ctx = createMockContext({
path: "/sso/callback/missing-provider",
params: { providerId: "missing-provider" },
});
await trustSsoProviderForLinking(ctx);
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
});
test("does not duplicate an existing provider", async () => {
await createSsoProviderRecord("pocket-id", true);
const ctx = createMockContext({
path: "/sso/callback/pocket-id",
params: { providerId: "pocket-id" },
trustedProviders: ["pocket-id"],
});
await trustSsoProviderForLinking(ctx);
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
});
test("does nothing when account linking is disabled", async () => {
await createSsoProviderRecord("pocket-id", true);
const ctx = createMockContext({
path: "/sso/callback/pocket-id",
params: { providerId: "pocket-id" },
enabled: false,
});
await trustSsoProviderForLinking(ctx);
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
});
test("does nothing when provider id cannot be extracted", async () => {
const ctx = createMockContext({
path: "/sso/callback/",
params: {},
});
await trustSsoProviderForLinking(ctx);
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
});
});

View file

@ -6,12 +6,13 @@ import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
const { path } = ctx;
const existingUser = await db.query.usersTable.findFirst();
if (path !== "/sign-up/email") {
return;
}
const existingUser = await db.query.usersTable.findFirst();
const result = await db.query.appMetadataTable.findFirst({
where: { key: REGISTRATION_ENABLED_KEY },
});

View file

@ -16,12 +16,16 @@ export function isSsoCallbackRequest(ctx: GenericEndpointContext | null) {
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
if (!ctx) {
return;
throw new APIError("BAD_REQUEST", {
message: "Missing SSO context",
});
}
const providerId = extractProviderIdFromContext(ctx);
if (!providerId) {
return;
throw new APIError("BAD_REQUEST", {
message: "Missing providerId in context",
});
}
const provider = await db
@ -31,7 +35,9 @@ export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpoi
.limit(1);
if (provider.length === 0) {
return;
throw new APIError("NOT_FOUND", {
message: "SSO provider not found",
});
}
const normalizedEmail = normalizeEmail(userEmail);

View file

@ -0,0 +1,37 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { db } from "~/server/db/db";
import { extractProviderIdFromContext } from "../utils/sso-context";
export function isSsoCallbackPath(path?: string): boolean {
if (!path) {
return false;
}
return path.startsWith("/sso/callback/");
}
export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): Promise<void> {
const providerId = extractProviderIdFromContext(ctx);
if (!providerId) {
return;
}
const accountLinking = ctx.context.options.account?.accountLinking;
if (!accountLinking || accountLinking.enabled === false) {
return;
}
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
if (!provider || !provider.autoLinkMatchingEmails) {
return;
}
const trustedProviders = accountLinking.trustedProviders ?? [];
if (trustedProviders.includes(providerId)) {
return;
}
accountLinking.trustedProviders = [...trustedProviders, providerId];
}

View file

@ -0,0 +1,21 @@
import type { BetterAuthPlugin } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../middlewares/trust-sso-provider-for-linking";
export function ssoTrustedProviderLinkingPlugin(): BetterAuthPlugin {
return {
id: "sso-trusted-provider-linking",
hooks: {
before: [
{
matcher(context) {
return isSsoCallbackPath(context.path);
},
handler: createAuthMiddleware(async (ctx) => {
await trustSsoProviderForLinking(ctx);
}),
},
],
},
};
}

View file

@ -10,10 +10,14 @@ export function extractProviderIdFromContext(ctx: GenericEndpointContext): strin
}
if (ctx.request?.url) {
const pathname = new URL(ctx.request.url).pathname;
const ssoCallbackMatch = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/);
if (ssoCallbackMatch) {
return ssoCallbackMatch[1];
try {
const pathname = new URL(ctx.request.url, "http://localhost").pathname;
const ssoCallbackMatch = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/);
if (ssoCallbackMatch) {
return ssoCallbackMatch[1];
}
} catch {
return null;
}
}

View file

@ -10,9 +10,13 @@ export const getOrganizationContext = createServerFn({ method: "GET" }).handler(
});
const session = await auth.api.getSession({ headers: request.headers });
const activeOrganizationId = session?.session.activeOrganizationId;
const activeOrganizationId = session?.session?.activeOrganizationId;
const activeOrganization = data.find((org) => org.id === activeOrganizationId);
if (data.length === 0) {
throw new Error("No organizations found for user");
}
const member = await auth.api.getActiveMember({
headers: request.headers,
});

View file

@ -1,4 +1,5 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
type GetStatusDto,
getStatusDto,
@ -12,6 +13,8 @@ import {
type AdminUsersDto,
deleteSsoProviderDto,
deleteSsoInvitationDto,
updateSsoProviderAutoLinkingBody,
updateSsoProviderAutoLinkingDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { requireAdmin, requireAuth } from "./auth.middleware";
@ -34,12 +37,10 @@ export const authController = new Hono()
return c.json<SsoSettingsDto>({ providers: [], invitations: [] });
}
const [providersData, invitationsData] = await Promise.all([
const [providersData, invitationsData, autoLinkingSettings] = await Promise.all([
auth.api.listSSOProviders({ headers }),
auth.api.listInvitations({
headers,
query: { organizationId: activeOrganizationId },
}),
auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }),
authService.getSsoProviderAutoLinkingSettings(activeOrganizationId),
]);
return c.json<SsoSettingsDto>({
@ -48,6 +49,7 @@ export const authController = new Hono()
type: provider.type,
issuer: provider.issuer,
domain: provider.domain,
autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? true,
organizationId: provider.organizationId,
})),
invitations: invitationsData.map((invitation) => ({
@ -65,6 +67,25 @@ export const authController = new Hono()
return c.json({ success: true });
})
.patch(
"/sso-providers/:providerId/auto-linking",
requireAuth,
requireAdmin,
updateSsoProviderAutoLinkingDto,
validator("json", updateSsoProviderAutoLinkingBody),
async (c) => {
const providerId = c.req.param("providerId");
const { enabled } = c.req.valid("json");
const updated = await authService.updateSsoProviderAutoLinking(providerId, enabled);
if (!updated) {
return c.json({ message: "Provider not found" }, 404);
}
return c.json({ success: true });
},
)
.delete("/sso-invitations/:invitationId", requireAuth, requireAdmin, deleteSsoInvitationDto, async (c) => {
const invitationId = c.req.param("invitationId");
await authService.deleteSsoInvitation(invitationId);
@ -89,6 +110,7 @@ export const authController = new Hono()
})),
total: usersData.total,
limit: "limit" in usersData ? (usersData.limit ?? 100) : 100,
offset: "offset" in usersData ? (usersData.offset ?? 0) : 0,
});
})
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {

View file

@ -36,6 +36,7 @@ export const ssoSettingsResponse = type({
type: "string",
issuer: "string",
domain: "string",
autoLinkMatchingEmails: "boolean",
organizationId: "string | null",
}).array(),
invitations: type({
@ -75,6 +76,7 @@ export const adminUsersResponse = type({
}).array(),
total: "number",
limit: "number",
offset: "number",
});
export type AdminUsersDto = typeof adminUsersResponse.infer;
@ -170,3 +172,24 @@ export const deleteSsoInvitationDto = describeRoute({
},
},
});
export const updateSsoProviderAutoLinkingBody = type({
enabled: "boolean",
});
export const updateSsoProviderAutoLinkingDto = describeRoute({
description: "Update whether SSO sign-in can auto-link existing accounts by email",
operationId: "updateSsoProviderAutoLinking",
tags: ["Auth"],
responses: {
200: {
description: "SSO provider auto-linking setting updated successfully",
},
403: {
description: "Forbidden",
},
404: {
description: "Provider not found",
},
},
});

View file

@ -7,6 +7,7 @@ declare module "hono" {
interface ContextVariableMap {
user: {
id: string;
email: string;
username: string;
hasDownloadedResticPassword: boolean;
role?: string | null | undefined;

View file

@ -114,6 +114,38 @@ export class AuthService {
});
}
/**
* Get per-provider auto-linking setting for an organization
*/
async getSsoProviderAutoLinkingSettings(organizationId: string): Promise<Record<string, boolean>> {
const providers = await db.query.ssoProvider.findMany({
columns: { providerId: true, autoLinkMatchingEmails: true },
where: { organizationId },
});
return Object.fromEntries(providers.map((provider) => [provider.providerId, provider.autoLinkMatchingEmails]));
}
/**
* Update per-provider auto-linking setting
*/
async updateSsoProviderAutoLinking(providerId: string, enabled: boolean): Promise<boolean> {
const existingProvider = await db
.select({ id: ssoProvider.id })
.from(ssoProvider)
.where(eq(ssoProvider.providerId, providerId))
.limit(1)
.then((result) => result[0]);
if (!existingProvider) {
return false;
}
await db.update(ssoProvider).set({ autoLinkMatchingEmails: enabled }).where(eq(ssoProvider.providerId, providerId));
return true;
}
/**
* Delete an invitation
*/