feat: link current account
This commit is contained in:
parent
a3af29ec4e
commit
4619d9d976
29 changed files with 2801 additions and 119 deletions
|
|
@ -61,4 +61,7 @@ bunx oxfmt format --write <path>
|
|||
bun run lint
|
||||
```
|
||||
|
||||
The frontend has an automatic invalidation setup which runs after every mutation, do not write any invalidation logic in the frontend.
|
||||
### Invalidation
|
||||
|
||||
The frontend has an automatic invalidation setup which runs after every mutation.
|
||||
Do not implement any invalidation logic in the frontend.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
|
||||
export type QueryKey<TOptions extends Options> = [
|
||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||
|
|
@ -111,6 +111,23 @@ export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoPro
|
|||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update whether SSO sign-in can auto-link existing accounts by email
|
||||
*/
|
||||
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
|
||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await updateSsoProviderAutoLinking({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an SSO invitation
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -39,6 +39,18 @@ export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: O
|
|||
*/
|
||||
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
|
||||
|
||||
/**
|
||||
* Update whether SSO sign-in can auto-link existing accounts by email
|
||||
*/
|
||||
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
|
||||
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete an SSO invitation
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ export function OrganizationSwitcher() {
|
|||
},
|
||||
});
|
||||
|
||||
if (organizations && organizations.length <= 1) {
|
||||
if (organizations === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (organizations.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +72,7 @@ export function OrganizationSwitcher() {
|
|||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeOrganization?.name}</span>
|
||||
<span className="truncate text-xs">{organizations?.length} organizations</span>
|
||||
<span className="truncate text-xs">{organizations.length} organizations</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -80,7 +84,7 @@ export function OrganizationSwitcher() {
|
|||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
|
||||
{organizations?.map((organization) => (
|
||||
{organizations.map((organization) => (
|
||||
<DropdownMenuItem
|
||||
key={organization.id}
|
||||
onClick={() => switchOrganizationMutation.mutate(organization.id)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { Ban, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, Ban, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -9,8 +9,8 @@ import {
|
|||
deleteSsoInvitationMutation,
|
||||
deleteSsoProviderMutation,
|
||||
getSsoSettingsOptions,
|
||||
updateSsoProviderAutoLinkingMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -22,7 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Badge } from "~/client/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -44,6 +44,7 @@ import {
|
|||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Switch } from "~/client/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
|
|
@ -58,6 +59,7 @@ const ssoProviderSchema = type({
|
|||
clientId: "string>=1",
|
||||
clientSecret: "string>=1",
|
||||
discoveryEndpoint: "string>=1",
|
||||
linkMatchingEmails: "boolean",
|
||||
});
|
||||
|
||||
type ProviderForm = typeof ssoProviderSchema.infer;
|
||||
|
|
@ -79,6 +81,7 @@ export function SsoSettingsSection() {
|
|||
clientId: "",
|
||||
clientSecret: "",
|
||||
discoveryEndpoint: "",
|
||||
linkMatchingEmails: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -89,6 +92,10 @@ export function SsoSettingsSection() {
|
|||
const providers = data.providers;
|
||||
const invitations = data.invitations;
|
||||
|
||||
const updateProviderAutoLinkingMutation = useMutation({
|
||||
...updateSsoProviderAutoLinkingMutation(),
|
||||
});
|
||||
|
||||
const registerProviderMutation = useMutation({
|
||||
mutationFn: async (formData: ProviderForm) => {
|
||||
if (!activeOrganization) {
|
||||
|
|
@ -109,6 +116,17 @@ export function SsoSettingsSection() {
|
|||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
await updateProviderAutoLinkingMutation
|
||||
.mutateAsync({
|
||||
path: { providerId: formData.providerId },
|
||||
body: { enabled: formData.linkMatchingEmails },
|
||||
})
|
||||
.catch((updateError) => {
|
||||
throw new Error(
|
||||
`The provider was created, but we could not save the auto-link setting. ${parseError(updateError)?.message ?? ""}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("SSO provider registered successfully");
|
||||
|
|
@ -318,6 +336,32 @@ export function SsoSettingsSection() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="linkMatchingEmails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Link matching emails to existing accounts</FormLabel>
|
||||
<FormDescription>
|
||||
If enabled, users who sign in with this provider will automatically access their existing
|
||||
account when the email address matches.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={registerProviderMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!activeOrganization ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
|
|
@ -337,6 +381,13 @@ export function SsoSettingsSection() {
|
|||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
Only enable automatic account linking for identity providers you trust. You can change this per provider at
|
||||
any time.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -345,6 +396,7 @@ export function SsoSettingsSection() {
|
|||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Issuer</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Auto-link existing account</TableHead>
|
||||
<TableHead>Callback URL</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -358,9 +410,42 @@ export function SsoSettingsSection() {
|
|||
<TableCell className="break-all">{provider.issuer}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="uppercase">
|
||||
<span className="uppercase text-xs font-medium px-2 py-0.5 rounded border">
|
||||
{provider.type}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={provider.autoLinkMatchingEmails}
|
||||
disabled={updateProviderAutoLinkingMutation.isPending}
|
||||
onCheckedChange={(enabled) => {
|
||||
updateProviderAutoLinkingMutation.mutate(
|
||||
{
|
||||
path: { providerId: provider.providerId },
|
||||
body: { enabled },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
enabled
|
||||
? "Automatic account linking enabled"
|
||||
: "Automatic account linking disabled",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update provider", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.autoLinkMatchingEmails ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -415,7 +500,7 @@ export function SsoSettingsSection() {
|
|||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-sm text-muted-foreground">
|
||||
No providers registered yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -494,9 +579,15 @@ export function SsoSettingsSection() {
|
|||
<TableCell className="font-medium">{invitation.email}</TableCell>
|
||||
<TableCell className="uppercase">{invitation.role}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={invitation.status === "pending" ? "default" : "outline"}>
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded border ${
|
||||
invitation.status === "pending"
|
||||
? "bg-primary/10 border-primary/20"
|
||||
: "bg-muted border-muted-foreground/20"
|
||||
}`}
|
||||
>
|
||||
{invitation.status}
|
||||
</Badge>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateWithMonth(invitation.expiresAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
|
|
|||
|
|
@ -228,7 +228,11 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
|||
<Button variant="outline" onClick={() => setUserToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={isLoadingImpact} onClick={() => deleteUser.mutate(userToDelete!)}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isLoadingImpact || deleteUser.isPending}
|
||||
onClick={() => deleteUser.mutate(userToDelete!)}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
25
app/drizzle/20260222184329_lonely_blacklash/migration.sql
Normal file
25
app/drizzle/20260222184329_lonely_blacklash/migration.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
ALTER TABLE `sso_provider` ADD `auto_link_matching_emails` integer DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_sso_provider` (
|
||||
`id` text PRIMARY KEY,
|
||||
`provider_id` text NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`user_id` text,
|
||||
`issuer` text NOT NULL,
|
||||
`domain` text NOT NULL,
|
||||
`auto_link_matching_emails` integer DEFAULT true NOT NULL,
|
||||
`oidc_config` text,
|
||||
`saml_config` text,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
CONSTRAINT `fk_sso_provider_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_sso_provider_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE SET NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_sso_provider`(`id`, `provider_id`, `organization_id`, `user_id`, `issuer`, `domain`, `oidc_config`, `saml_config`, `created_at`, `updated_at`) SELECT `id`, `provider_id`, `organization_id`, `user_id`, `issuer`, `domain`, `oidc_config`, `saml_config`, `created_at`, `updated_at` FROM `sso_provider`;--> statement-breakpoint
|
||||
DROP TABLE `sso_provider`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_sso_provider` RENAME TO `sso_provider`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `sso_provider_provider_id_uidx` ON `sso_provider` (`provider_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `sso_provider_organization_id_uidx` ON `sso_provider` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `sso_provider_domain_idx` ON `sso_provider` (`domain`);
|
||||
2220
app/drizzle/20260222184329_lonely_blacklash/snapshot.json
Normal file
2220
app/drizzle/20260222184329_lonely_blacklash/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -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" }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -178,15 +178,14 @@ export const ssoProvider = sqliteTable(
|
|||
"sso_provider",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").references(() => usersTable.id, { onDelete: "set null" }),
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
autoLinkMatchingEmails: int("auto_link_matching_emails", { mode: "boolean" }).notNull().default(true),
|
||||
oidcConfig: text("oidc_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||
samlConfig: text("saml_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user";
|
|||
import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user";
|
||||
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
|
||||
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
|
||||
import { ssoTrustedProviderLinkingPlugin } from "./auth/plugins/sso-trusted-provider-linking";
|
||||
|
||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||
|
||||
|
|
@ -50,9 +51,8 @@ export const auth = betterAuth({
|
|||
},
|
||||
create: {
|
||||
before: async (user, ctx) => {
|
||||
await requireSsoInvitation(user.email, ctx);
|
||||
|
||||
if (isSsoCallbackRequest(ctx)) {
|
||||
await requireSsoInvitation(user.email, ctx);
|
||||
user.hasDownloadedResticPassword = true;
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ export const auth = betterAuth({
|
|||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
disableImplicitLinking: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
|
|
@ -123,6 +123,7 @@ export const auth = betterAuth({
|
|||
defaultRole: "member",
|
||||
},
|
||||
}),
|
||||
ssoTrustedProviderLinkingPlugin(),
|
||||
twoFactor({
|
||||
backupCodeOptions: {
|
||||
storeBackupCodes: "encrypted",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../trust-sso-provider-for-linking";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
function randomSlug(prefix: string) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createMockContext(options: {
|
||||
path: string;
|
||||
method?: string;
|
||||
params?: Record<string, string>;
|
||||
trustedProviders?: string[];
|
||||
enabled?: boolean;
|
||||
}): GenericEndpointContext {
|
||||
const { path, method = "GET", params = {}, trustedProviders = [], enabled = true } = options;
|
||||
|
||||
const accountLinking = {
|
||||
enabled,
|
||||
trustedProviders,
|
||||
};
|
||||
|
||||
const context = {
|
||||
options: {
|
||||
account: {
|
||||
accountLinking,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
body: {},
|
||||
query: {},
|
||||
headers: new Headers(),
|
||||
request: new Request(`http://test.local${path}`, { method }),
|
||||
params,
|
||||
method,
|
||||
context: context as GenericEndpointContext["context"],
|
||||
} as GenericEndpointContext;
|
||||
}
|
||||
|
||||
async function createSsoProviderRecord(providerId: string, autoLinkMatchingEmails: boolean) {
|
||||
const userId = randomId();
|
||||
const organizationId = randomId();
|
||||
|
||||
await db.insert(usersTable).values({
|
||||
id: userId,
|
||||
username: randomSlug("inviter"),
|
||||
email: `${randomSlug("inviter")}@example.com`,
|
||||
name: "Inviter",
|
||||
});
|
||||
|
||||
await db.insert(organization).values({
|
||||
id: organizationId,
|
||||
name: "Acme",
|
||||
slug: randomSlug("acme"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId,
|
||||
organizationId,
|
||||
userId,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
autoLinkMatchingEmails,
|
||||
});
|
||||
}
|
||||
|
||||
describe("isSsoCallbackPath", () => {
|
||||
test("detects OIDC callback paths", () => {
|
||||
expect(isSsoCallbackPath("/sso/callback/pocket-id")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores non-callback paths", () => {
|
||||
expect(isSsoCallbackPath("/sso/register")).toBe(false);
|
||||
expect(isSsoCallbackPath("/sign-in/email")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trustSsoProviderForLinking", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("adds callback provider to trusted providers", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toContain("pocket-id");
|
||||
});
|
||||
|
||||
test("does not trust providers with disabled auto-linking", async () => {
|
||||
await createSsoProviderRecord("pocket-id", false);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not trust unknown providers", async () => {
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/missing-provider",
|
||||
params: { providerId: "missing-provider" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not duplicate an existing provider", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
trustedProviders: ["pocket-id"],
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
||||
});
|
||||
|
||||
test("does nothing when account linking is disabled", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("does nothing when provider id cannot be extracted", async () => {
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/",
|
||||
params: {},
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { db } from "~/server/db/db";
|
||||
import { extractProviderIdFromContext } from "../utils/sso-context";
|
||||
|
||||
export function isSsoCallbackPath(path?: string): boolean {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return path.startsWith("/sso/callback/");
|
||||
}
|
||||
|
||||
export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): Promise<void> {
|
||||
const providerId = extractProviderIdFromContext(ctx);
|
||||
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountLinking = ctx.context.options.account?.accountLinking;
|
||||
|
||||
if (!accountLinking || accountLinking.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
|
||||
if (!provider || !provider.autoLinkMatchingEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedProviders = accountLinking.trustedProviders ?? [];
|
||||
if (trustedProviders.includes(providerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
accountLinking.trustedProviders = [...trustedProviders, providerId];
|
||||
}
|
||||
21
app/server/lib/auth/plugins/sso-trusted-provider-linking.ts
Normal file
21
app/server/lib/auth/plugins/sso-trusted-provider-linking.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../middlewares/trust-sso-provider-for-linking";
|
||||
|
||||
export function ssoTrustedProviderLinkingPlugin(): BetterAuthPlugin {
|
||||
return {
|
||||
id: "sso-trusted-provider-linking",
|
||||
hooks: {
|
||||
before: [
|
||||
{
|
||||
matcher(context) {
|
||||
return isSsoCallbackPath(context.path);
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Hono } from "hono";
|
||||
import { validator } from "hono-openapi";
|
||||
import {
|
||||
type GetStatusDto,
|
||||
getStatusDto,
|
||||
|
|
@ -12,6 +13,8 @@ import {
|
|||
type AdminUsersDto,
|
||||
deleteSsoProviderDto,
|
||||
deleteSsoInvitationDto,
|
||||
updateSsoProviderAutoLinkingBody,
|
||||
updateSsoProviderAutoLinkingDto,
|
||||
} from "./auth.dto";
|
||||
import { authService } from "./auth.service";
|
||||
import { requireAdmin, requireAuth } from "./auth.middleware";
|
||||
|
|
@ -34,12 +37,10 @@ export const authController = new Hono()
|
|||
return c.json<SsoSettingsDto>({ providers: [], invitations: [] });
|
||||
}
|
||||
|
||||
const [providersData, invitationsData] = await Promise.all([
|
||||
const [providersData, invitationsData, autoLinkingSettings] = await Promise.all([
|
||||
auth.api.listSSOProviders({ headers }),
|
||||
auth.api.listInvitations({
|
||||
headers,
|
||||
query: { organizationId: activeOrganizationId },
|
||||
}),
|
||||
auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }),
|
||||
authService.getSsoProviderAutoLinkingSettings(activeOrganizationId),
|
||||
]);
|
||||
|
||||
return c.json<SsoSettingsDto>({
|
||||
|
|
@ -48,6 +49,7 @@ export const authController = new Hono()
|
|||
type: provider.type,
|
||||
issuer: provider.issuer,
|
||||
domain: provider.domain,
|
||||
autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? true,
|
||||
organizationId: provider.organizationId,
|
||||
})),
|
||||
invitations: invitationsData.map((invitation) => ({
|
||||
|
|
@ -65,6 +67,25 @@ export const authController = new Hono()
|
|||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.patch(
|
||||
"/sso-providers/:providerId/auto-linking",
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
updateSsoProviderAutoLinkingDto,
|
||||
validator("json", updateSsoProviderAutoLinkingBody),
|
||||
async (c) => {
|
||||
const providerId = c.req.param("providerId");
|
||||
const { enabled } = c.req.valid("json");
|
||||
|
||||
const updated = await authService.updateSsoProviderAutoLinking(providerId, enabled);
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ message: "Provider not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
},
|
||||
)
|
||||
.delete("/sso-invitations/:invitationId", requireAuth, requireAdmin, deleteSsoInvitationDto, async (c) => {
|
||||
const invitationId = c.req.param("invitationId");
|
||||
await authService.deleteSsoInvitation(invitationId);
|
||||
|
|
@ -89,6 +110,7 @@ export const authController = new Hono()
|
|||
})),
|
||||
total: usersData.total,
|
||||
limit: "limit" in usersData ? (usersData.limit ?? 100) : 100,
|
||||
offset: "offset" in usersData ? (usersData.offset ?? 0) : 0,
|
||||
});
|
||||
})
|
||||
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ declare module "hono" {
|
|||
interface ContextVariableMap {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
role?: string | null | undefined;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,38 @@ export class AuthService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-provider auto-linking setting for an organization
|
||||
*/
|
||||
async getSsoProviderAutoLinkingSettings(organizationId: string): Promise<Record<string, boolean>> {
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
columns: { providerId: true, autoLinkMatchingEmails: true },
|
||||
where: { organizationId },
|
||||
});
|
||||
|
||||
return Object.fromEntries(providers.map((provider) => [provider.providerId, provider.autoLinkMatchingEmails]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update per-provider auto-linking setting
|
||||
*/
|
||||
async updateSsoProviderAutoLinking(providerId: string, enabled: boolean): Promise<boolean> {
|
||||
const existingProvider = await db
|
||||
.select({ id: ssoProvider.id })
|
||||
.from(ssoProvider)
|
||||
.where(eq(ssoProvider.providerId, providerId))
|
||||
.limit(1)
|
||||
.then((result) => result[0]);
|
||||
|
||||
if (!existingProvider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db.update(ssoProvider).set({ autoLinkMatchingEmails: enabled }).where(eq(ssoProvider.providerId, providerId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an invitation
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue