diff --git a/AGENTS.md b/AGENTS.md index 52f3e8fc..76c87878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,4 +61,7 @@ bunx oxfmt format --write 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. diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 73fba713..533b2bd0 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -4,8 +4,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, 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 = [ Pick & { @@ -111,6 +111,23 @@ export const deleteSsoProviderMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateSsoProviderAutoLinking({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + /** * Delete an SSO invitation */ diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index b93d38d8..7c4f4ed9 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -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, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 4132f10f..caf9ea7b 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, 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 = Options2 & { /** @@ -39,6 +39,18 @@ export const getSsoSettings = (options?: O */ export const deleteSsoProvider = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/sso-providers/{providerId}', ...options }); +/** + * Update whether SSO sign-in can auto-link existing accounts by email + */ +export const updateSsoProviderAutoLinking = (options: Options) => (options.client ?? client).patch({ + url: '/api/v1/auth/sso-providers/{providerId}/auto-linking', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Delete an SSO invitation */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 60076839..7fa809b8 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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; diff --git a/app/client/components/organization-switcher.tsx b/app/client/components/organization-switcher.tsx index ae3fdf55..954a8f01 100644 --- a/app/client/components/organization-switcher.tsx +++ b/app/client/components/organization-switcher.tsx @@ -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() {
{activeOrganization?.name} - {organizations?.length} organizations + {organizations.length} organizations
@@ -80,7 +84,7 @@ export function OrganizationSwitcher() { sideOffset={4} > Organizations - {organizations?.map((organization) => ( + {organizations.map((organization) => ( switchOrganizationMutation.mutate(organization.id)} diff --git a/app/client/modules/settings/components/sso/sso-settings-section.tsx b/app/client/modules/settings/components/sso/sso-settings-section.tsx index 31186940..3d7f4a3b 100644 --- a/app/client/modules/settings/components/sso/sso-settings-section.tsx +++ b/app/client/modules/settings/components/sso/sso-settings-section.tsx @@ -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() { /> + ( + +
+
+ Link matching emails to existing accounts + + If enabled, users who sign in with this provider will automatically access their existing + account when the email address matches. + +
+ + + +
+ +
+ )} + /> + {!activeOrganization ? ( @@ -337,6 +381,13 @@ export function SsoSettingsSection() { + + + Only enable automatic account linking for identity providers you trust. You can change this per provider at + any time. + + +
@@ -345,6 +396,7 @@ export function SsoSettingsSection() { Domain Issuer Type + Auto-link existing account Callback URL Actions @@ -358,9 +410,42 @@ export function SsoSettingsSection() { {provider.issuer}
- + {provider.type} - + +
+
+ +
+ { + 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, + }); + }, + }, + ); + }} + /> + + {provider.autoLinkMatchingEmails ? "On" : "Off"} +
@@ -415,7 +500,7 @@ export function SsoSettingsSection() { )) ) : ( - + No providers registered yet. @@ -494,9 +579,15 @@ export function SsoSettingsSection() { {invitation.email} {invitation.role} - + {invitation.status} - + {formatDateWithMonth(invitation.expiresAt)} diff --git a/app/client/modules/settings/components/user-management.tsx b/app/client/modules/settings/components/user-management.tsx index 01797052..3b7d3b0b 100644 --- a/app/client/modules/settings/components/user-management.tsx +++ b/app/client/modules/settings/components/user-management.tsx @@ -228,7 +228,11 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } | - diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 6b74edcd..eb916694 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -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, diff --git a/app/drizzle/20260222184329_lonely_blacklash/migration.sql b/app/drizzle/20260222184329_lonely_blacklash/migration.sql new file mode 100644 index 00000000..a94d63a1 --- /dev/null +++ b/app/drizzle/20260222184329_lonely_blacklash/migration.sql @@ -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`); \ No newline at end of file diff --git a/app/drizzle/20260222184329_lonely_blacklash/snapshot.json b/app/drizzle/20260222184329_lonely_blacklash/snapshot.json new file mode 100644 index 00000000..3651a03c --- /dev/null +++ b/app/drizzle/20260222184329_lonely_blacklash/snapshot.json @@ -0,0 +1,2220 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "93504601-a3b2-4f09-92ca-16f132bcdd3a", + "prevIds": ["ab028cd7-c0cb-44f4-843b-37e95ab3c667"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "app_metadata", + "entityType": "tables" + }, + { + "name": "backup_schedule_mirrors_table", + "entityType": "tables" + }, + { + "name": "backup_schedule_notifications_table", + "entityType": "tables" + }, + { + "name": "backup_schedules_table", + "entityType": "tables" + }, + { + "name": "invitation", + "entityType": "tables" + }, + { + "name": "member", + "entityType": "tables" + }, + { + "name": "notification_destinations_table", + "entityType": "tables" + }, + { + "name": "organization", + "entityType": "tables" + }, + { + "name": "repositories_table", + "entityType": "tables" + }, + { + "name": "sessions_table", + "entityType": "tables" + }, + { + "name": "sso_provider", + "entityType": "tables" + }, + { + "name": "two_factor", + "entityType": "tables" + }, + { + "name": "users_table", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "name": "volumes_table", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_status", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_error", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "destination_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_start", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_success", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_warning", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_failure", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "volume_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cron_expression", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retention_policy", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_if_present", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_status", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_error", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "next_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "one_file_system", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "sort_order", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inviter_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'member'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo", + "entityType": "columns", + "table": "organization" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'auto'", + "generated": null, + "name": "compression_mode", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "doctor_result", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "upload_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "upload_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "upload_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "download_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "download_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "download_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ip_address", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_agent", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonated_by", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_organization_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "issuer", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "auto_link_matching_emails", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "oidc_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "saml_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backup_codes", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password_hash", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "has_downloaded_restic_password", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "email_verified", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "two_factor_enabled", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_reason", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_expires", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'unmounted'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "last_health_check", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "auto_remount", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "account_user_id_users_table_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["destination_id"], + "tableTo": "notification_destinations_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["volume_id"], + "tableTo": "volumes_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_organization_id_organization_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["inviter_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_inviter_id_users_table_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_organization_id_organization_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_user_id_users_table_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "notification_destinations_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "notification_destinations_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "repositories_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "repositories_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "sessions_table_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sessions_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_sso_provider_organization_id_organization_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_sso_provider_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "two_factor_user_id_users_table_id_fk", + "entityType": "fks", + "table": "two_factor" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "volumes_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "destination_id"], + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk", + "entityType": "pks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["key"], + "nameExplicit": false, + "name": "app_metadata_pk", + "table": "app_metadata", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_pk", + "table": "backup_schedule_mirrors_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedules_table_pk", + "table": "backup_schedules_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "invitation_pk", + "table": "invitation", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_pk", + "table": "member", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "notification_destinations_table_pk", + "table": "notification_destinations_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "organization_pk", + "table": "organization", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repositories_table_pk", + "table": "repositories_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_table_pk", + "table": "sessions_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sso_provider_pk", + "table": "sso_provider", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "two_factor_pk", + "table": "two_factor", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_table_pk", + "table": "users_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "volumes_table_pk", + "table": "volumes_table", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "account_userId_idx", + "entityType": "indexes", + "table": "account" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_organizationId_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_email_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_organizationId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_userId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "member_org_user_uidx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "organization_slug_uidx", + "entityType": "indexes", + "table": "organization" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessionsTable_userId_idx", + "entityType": "indexes", + "table": "sessions_table" + }, + { + "columns": [ + { + "value": "provider_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "sso_provider_provider_id_uidx", + "entityType": "indexes", + "table": "sso_provider" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "sso_provider_organization_id_uidx", + "entityType": "indexes", + "table": "sso_provider" + }, + { + "columns": [ + { + "value": "domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sso_provider_domain_idx", + "entityType": "indexes", + "table": "sso_provider" + }, + { + "columns": [ + { + "value": "secret", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_secret_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_userId_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "identifier", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "verification_identifier_idx", + "entityType": "indexes", + "table": "verification" + }, + { + "columns": ["schedule_id", "repository_id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "entityType": "uniques", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["name", "organization_id"], + "nameExplicit": false, + "name": "volumes_table_name_organization_id_unique", + "entityType": "uniques", + "table": "volumes_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "backup_schedules_table_short_id_unique", + "entityType": "uniques", + "table": "backup_schedules_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "repositories_table_short_id_unique", + "entityType": "uniques", + "table": "repositories_table" + }, + { + "columns": ["token"], + "nameExplicit": false, + "name": "sessions_table_token_unique", + "entityType": "uniques", + "table": "sessions_table" + }, + { + "columns": ["username"], + "nameExplicit": false, + "name": "users_table_username_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["email"], + "nameExplicit": false, + "name": "users_table_email_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "volumes_table_short_id_unique", + "entityType": "uniques", + "table": "volumes_table" + } + ], + "renames": [] +} diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts index dbdda8e9..94e49201 100644 --- a/app/middleware/auth.ts +++ b/app/middleware/auth.ts @@ -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" }); } } diff --git a/app/routeTree.gen.ts b/app/routeTree.gen.ts index d771c908..27892183 100644 --- a/app/routeTree.gen.ts +++ b/app/routeTree.gen.ts @@ -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, diff --git a/app/routes/(dashboard)/settings/index.tsx b/app/routes/(dashboard)/settings/index.tsx index fa491317..15bfed32 100644 --- a/app/routes/(dashboard)/settings/index.tsx +++ b/app/routes/(dashboard)/settings/index.tsx @@ -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(); diff --git a/app/routes/(dashboard)/settings/sso-link.error.tsx b/app/routes/(dashboard)/settings/sso-link.error.tsx deleted file mode 100644 index 8e10c7c7..00000000 --- a/app/routes/(dashboard)/settings/sso-link.error.tsx +++ /dev/null @@ -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" }), - }); - }, -}); diff --git a/app/routes/(dashboard)/settings/sso-link.tsx b/app/routes/(dashboard)/settings/sso-link.tsx deleted file mode 100644 index 5ffd12c8..00000000 --- a/app/routes/(dashboard)/settings/sso-link.tsx +++ /dev/null @@ -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" }), - }); - }, -}); diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 5685340c..ed09d2d2 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -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 | null>(), samlConfig: text("saml_config", { mode: "json" }).$type | null>(), createdAt: integer("created_at", { mode: "timestamp_ms" }) diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 5f47e096..2760973c 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -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>; @@ -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", diff --git a/app/server/lib/auth/middlewares/__tests__/trust-sso-provider-for-linking.test.ts b/app/server/lib/auth/middlewares/__tests__/trust-sso-provider-for-linking.test.ts new file mode 100644 index 00000000..d4741e47 --- /dev/null +++ b/app/server/lib/auth/middlewares/__tests__/trust-sso-provider-for-linking.test.ts @@ -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; + 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([]); + }); +}); diff --git a/app/server/lib/auth/middlewares/only-one-user.ts b/app/server/lib/auth/middlewares/only-one-user.ts index 27f11d89..d4fc2ca9 100644 --- a/app/server/lib/auth/middlewares/only-one-user.ts +++ b/app/server/lib/auth/middlewares/only-one-user.ts @@ -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 }, }); diff --git a/app/server/lib/auth/middlewares/require-sso-invitation.ts b/app/server/lib/auth/middlewares/require-sso-invitation.ts index 76d2216a..5cc24fb3 100644 --- a/app/server/lib/auth/middlewares/require-sso-invitation.ts +++ b/app/server/lib/auth/middlewares/require-sso-invitation.ts @@ -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); diff --git a/app/server/lib/auth/middlewares/trust-sso-provider-for-linking.ts b/app/server/lib/auth/middlewares/trust-sso-provider-for-linking.ts new file mode 100644 index 00000000..35aebb3a --- /dev/null +++ b/app/server/lib/auth/middlewares/trust-sso-provider-for-linking.ts @@ -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 { + 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]; +} diff --git a/app/server/lib/auth/plugins/sso-trusted-provider-linking.ts b/app/server/lib/auth/plugins/sso-trusted-provider-linking.ts new file mode 100644 index 00000000..196dc03b --- /dev/null +++ b/app/server/lib/auth/plugins/sso-trusted-provider-linking.ts @@ -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); + }), + }, + ], + }, + }; +} diff --git a/app/server/lib/auth/utils/sso-context.ts b/app/server/lib/auth/utils/sso-context.ts index 41364808..ef4a8920 100644 --- a/app/server/lib/auth/utils/sso-context.ts +++ b/app/server/lib/auth/utils/sso-context.ts @@ -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; } } diff --git a/app/server/lib/functions/organization-context.ts b/app/server/lib/functions/organization-context.ts index 3bd81680..5a09765c 100644 --- a/app/server/lib/functions/organization-context.ts +++ b/app/server/lib/functions/organization-context.ts @@ -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, }); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index 562e260d..b8113cfe 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -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({ 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({ @@ -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) => { diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index fd0e15a4..d8a850fb 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -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", + }, + }, +}); diff --git a/app/server/modules/auth/auth.middleware.ts b/app/server/modules/auth/auth.middleware.ts index feb606cd..1311f031 100644 --- a/app/server/modules/auth/auth.middleware.ts +++ b/app/server/modules/auth/auth.middleware.ts @@ -7,6 +7,7 @@ declare module "hono" { interface ContextVariableMap { user: { id: string; + email: string; username: string; hasDownloadedResticPassword: boolean; role?: string | null | undefined; diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index c2f90211..a88e00e3 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -114,6 +114,38 @@ export class AuthService { }); } + /** + * Get per-provider auto-linking setting for an organization + */ + async getSsoProviderAutoLinkingSettings(organizationId: string): Promise> { + 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 { + 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 */