feat: link current account
This commit is contained in:
parent
0811212fc3
commit
1fa08dc432
29 changed files with 2798 additions and 116 deletions
|
|
@ -63,4 +63,7 @@ bunx oxfmt format --write <path>
|
||||||
bun run lint
|
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.
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,23 @@ export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoPro
|
||||||
return mutationOptions;
|
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
|
* Delete an SSO invitation
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export {
|
||||||
updateRepository,
|
updateRepository,
|
||||||
updateScheduleMirrors,
|
updateScheduleMirrors,
|
||||||
updateScheduleNotifications,
|
updateScheduleNotifications,
|
||||||
|
updateSsoProviderAutoLinking,
|
||||||
updateVolume,
|
updateVolume,
|
||||||
} from "./sdk.gen";
|
} from "./sdk.gen";
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -274,6 +275,9 @@ export type {
|
||||||
UpdateScheduleNotificationsData,
|
UpdateScheduleNotificationsData,
|
||||||
UpdateScheduleNotificationsResponse,
|
UpdateScheduleNotificationsResponse,
|
||||||
UpdateScheduleNotificationsResponses,
|
UpdateScheduleNotificationsResponses,
|
||||||
|
UpdateSsoProviderAutoLinkingData,
|
||||||
|
UpdateSsoProviderAutoLinkingErrors,
|
||||||
|
UpdateSsoProviderAutoLinkingResponses,
|
||||||
UpdateVolumeData,
|
UpdateVolumeData,
|
||||||
UpdateVolumeErrors,
|
UpdateVolumeErrors,
|
||||||
UpdateVolumeResponse,
|
UpdateVolumeResponse,
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,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 });
|
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
|
* Delete an SSO invitation
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export type GetSsoSettingsResponses = {
|
||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
providers: Array<{
|
providers: Array<{
|
||||||
|
autoLinkMatchingEmails: boolean;
|
||||||
domain: string;
|
domain: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
organizationId: string | null;
|
organizationId: string | null;
|
||||||
|
|
@ -98,6 +99,35 @@ export type DeleteSsoProviderResponses = {
|
||||||
200: unknown;
|
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 = {
|
export type DeleteSsoInvitationData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -134,6 +164,7 @@ export type GetAdminUsersResponses = {
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
offset: number;
|
||||||
total: number;
|
total: number;
|
||||||
users: Array<{
|
users: Array<{
|
||||||
banned: boolean;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +72,7 @@ export function OrganizationSwitcher() {
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{activeOrganization?.name}</span>
|
<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>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto" />
|
<ChevronsUpDown className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|
@ -80,7 +84,7 @@ export function OrganizationSwitcher() {
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
|
||||||
{organizations?.map((organization) => (
|
{organizations.map((organization) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={organization.id}
|
key={organization.id}
|
||||||
onClick={() => switchOrganizationMutation.mutate(organization.id)}
|
onClick={() => switchOrganizationMutation.mutate(organization.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { Ban, Trash2 } from "lucide-react";
|
import { AlertTriangle, Ban, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -9,8 +9,8 @@ import {
|
||||||
deleteSsoInvitationMutation,
|
deleteSsoInvitationMutation,
|
||||||
deleteSsoProviderMutation,
|
deleteSsoProviderMutation,
|
||||||
getSsoSettingsOptions,
|
getSsoSettingsOptions,
|
||||||
|
updateSsoProviderAutoLinkingMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} 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 { Button } from "~/client/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
@ -58,6 +59,7 @@ const ssoProviderSchema = type({
|
||||||
clientId: "string>=1",
|
clientId: "string>=1",
|
||||||
clientSecret: "string>=1",
|
clientSecret: "string>=1",
|
||||||
discoveryEndpoint: "string>=1",
|
discoveryEndpoint: "string>=1",
|
||||||
|
linkMatchingEmails: "boolean",
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProviderForm = typeof ssoProviderSchema.infer;
|
type ProviderForm = typeof ssoProviderSchema.infer;
|
||||||
|
|
@ -79,6 +81,7 @@ export function SsoSettingsSection() {
|
||||||
clientId: "",
|
clientId: "",
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
discoveryEndpoint: "",
|
discoveryEndpoint: "",
|
||||||
|
linkMatchingEmails: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -89,6 +92,10 @@ export function SsoSettingsSection() {
|
||||||
const providers = data.providers;
|
const providers = data.providers;
|
||||||
const invitations = data.invitations;
|
const invitations = data.invitations;
|
||||||
|
|
||||||
|
const updateProviderAutoLinkingMutation = useMutation({
|
||||||
|
...updateSsoProviderAutoLinkingMutation(),
|
||||||
|
});
|
||||||
|
|
||||||
const registerProviderMutation = useMutation({
|
const registerProviderMutation = useMutation({
|
||||||
mutationFn: async (formData: ProviderForm) => {
|
mutationFn: async (formData: ProviderForm) => {
|
||||||
if (!activeOrganization) {
|
if (!activeOrganization) {
|
||||||
|
|
@ -109,6 +116,17 @@ export function SsoSettingsSection() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success("SSO provider registered successfully");
|
toast.success("SSO provider registered successfully");
|
||||||
|
|
@ -318,6 +336,32 @@ export function SsoSettingsSection() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{!activeOrganization ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
|
@ -337,6 +381,13 @@ export function SsoSettingsSection() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</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">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -345,6 +396,7 @@ export function SsoSettingsSection() {
|
||||||
<TableHead>Domain</TableHead>
|
<TableHead>Domain</TableHead>
|
||||||
<TableHead>Issuer</TableHead>
|
<TableHead>Issuer</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Auto-link existing account</TableHead>
|
||||||
<TableHead>Callback URL</TableHead>
|
<TableHead>Callback URL</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -358,9 +410,42 @@ export function SsoSettingsSection() {
|
||||||
<TableCell className="break-all">{provider.issuer}</TableCell>
|
<TableCell className="break-all">{provider.issuer}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<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}
|
{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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -415,7 +500,7 @@ export function SsoSettingsSection() {
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<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.
|
No providers registered yet.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -494,9 +579,15 @@ export function SsoSettingsSection() {
|
||||||
<TableCell className="font-medium">{invitation.email}</TableCell>
|
<TableCell className="font-medium">{invitation.email}</TableCell>
|
||||||
<TableCell className="uppercase">{invitation.role}</TableCell>
|
<TableCell className="uppercase">{invitation.role}</TableCell>
|
||||||
<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}
|
{invitation.status}
|
||||||
</Badge>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatDateWithMonth(invitation.expiresAt)}</TableCell>
|
<TableCell>{formatDateWithMonth(invitation.expiresAt)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,11 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
||||||
<Button variant="outline" onClick={() => setUserToDelete(null)}>
|
<Button variant="outline" onClick={() => setUserToDelete(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" disabled={isLoadingImpact} onClick={() => deleteUser.mutate(userToDelete!)}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isLoadingImpact || deleteUser.isPending}
|
||||||
|
onClick={() => deleteUser.mutate(userToDelete!)}
|
||||||
|
>
|
||||||
Delete User
|
Delete User
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Download, KeyRound, User, X, Users, Settings as SettingsIcon } from "lucide-react";
|
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 { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
downloadResticPasswordMutation,
|
downloadResticPasswordMutation,
|
||||||
|
|
@ -41,12 +41,34 @@ export function SettingsPage({ appContext }: Props) {
|
||||||
const [downloadPassword, setDownloadPassword] = useState("");
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
|
|
||||||
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
|
const { tab, ssoLinkStatus, ssoLinkMessage } = useSearch({ from: "/(dashboard)/settings/" });
|
||||||
const activeTab = tab || "account";
|
const activeTab = tab || "account";
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAdmin = appContext.user?.role === "admin";
|
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({
|
const registrationStatusQuery = useQuery({
|
||||||
...getRegistrationStatusOptions(),
|
...getRegistrationStatusOptions(),
|
||||||
enabled: isAdmin,
|
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?.id) {
|
||||||
if (isAuthRoute(pathname)) {
|
if (!session.user.hasDownloadedResticPassword && pathname !== "/download-recovery-key") {
|
||||||
if (!session.user.hasDownloadedResticPassword) {
|
|
||||||
throw redirect({ to: "/download-recovery-key" });
|
throw redirect({ to: "/download-recovery-key" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAuthRoute(pathname)) {
|
||||||
throw redirect({ to: "/volumes" });
|
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 dashboardBackupsIndexRouteImport } from './routes/(dashboard)/backups/index'
|
||||||
import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create'
|
import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create'
|
||||||
import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId'
|
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 dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create'
|
||||||
import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create'
|
import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create'
|
||||||
import { Route as dashboardNotificationsNotificationIdRouteImport } from './routes/(dashboard)/notifications/$notificationId'
|
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 authLoginErrorRouteImport } from './routes/(auth)/login.error'
|
||||||
import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index'
|
import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index'
|
||||||
import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/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 dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit'
|
||||||
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
|
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
|
||||||
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
|
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
|
||||||
|
|
@ -108,12 +106,6 @@ const dashboardVolumesVolumeIdRoute =
|
||||||
path: '/volumes/$volumeId',
|
path: '/volumes/$volumeId',
|
||||||
getParentRoute: () => dashboardRouteRoute,
|
getParentRoute: () => dashboardRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const dashboardSettingsSsoLinkRoute =
|
|
||||||
dashboardSettingsSsoLinkRouteImport.update({
|
|
||||||
id: '/settings/sso-link',
|
|
||||||
path: '/settings/sso-link',
|
|
||||||
getParentRoute: () => dashboardRouteRoute,
|
|
||||||
} as any)
|
|
||||||
const dashboardRepositoriesCreateRoute =
|
const dashboardRepositoriesCreateRoute =
|
||||||
dashboardRepositoriesCreateRouteImport.update({
|
dashboardRepositoriesCreateRouteImport.update({
|
||||||
id: '/repositories/create',
|
id: '/repositories/create',
|
||||||
|
|
@ -154,12 +146,6 @@ const dashboardBackupsBackupIdIndexRoute =
|
||||||
path: '/backups/$backupId/',
|
path: '/backups/$backupId/',
|
||||||
getParentRoute: () => dashboardRouteRoute,
|
getParentRoute: () => dashboardRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const dashboardSettingsSsoLinkErrorRoute =
|
|
||||||
dashboardSettingsSsoLinkErrorRouteImport.update({
|
|
||||||
id: '/error',
|
|
||||||
path: '/error',
|
|
||||||
getParentRoute: () => dashboardSettingsSsoLinkRoute,
|
|
||||||
} as any)
|
|
||||||
const dashboardRepositoriesRepositoryIdEditRoute =
|
const dashboardRepositoriesRepositoryIdEditRoute =
|
||||||
dashboardRepositoriesRepositoryIdEditRouteImport.update({
|
dashboardRepositoriesRepositoryIdEditRouteImport.update({
|
||||||
id: '/repositories/$repositoryId/edit',
|
id: '/repositories/$repositoryId/edit',
|
||||||
|
|
@ -196,7 +182,6 @@ export interface FileRoutesByFullPath {
|
||||||
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||||
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||||
'/repositories/create': typeof dashboardRepositoriesCreateRoute
|
'/repositories/create': typeof dashboardRepositoriesCreateRoute
|
||||||
'/settings/sso-link': typeof dashboardSettingsSsoLinkRouteWithChildren
|
|
||||||
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
||||||
'/volumes/create': typeof dashboardVolumesCreateRoute
|
'/volumes/create': typeof dashboardVolumesCreateRoute
|
||||||
'/backups/': typeof dashboardBackupsIndexRoute
|
'/backups/': typeof dashboardBackupsIndexRoute
|
||||||
|
|
@ -205,7 +190,6 @@ export interface FileRoutesByFullPath {
|
||||||
'/settings/': typeof dashboardSettingsIndexRoute
|
'/settings/': typeof dashboardSettingsIndexRoute
|
||||||
'/volumes/': typeof dashboardVolumesIndexRoute
|
'/volumes/': typeof dashboardVolumesIndexRoute
|
||||||
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||||
'/settings/sso-link/error': typeof dashboardSettingsSsoLinkErrorRoute
|
|
||||||
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
|
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
|
||||||
'/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
'/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||||
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||||
|
|
@ -223,7 +207,6 @@ export interface FileRoutesByTo {
|
||||||
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||||
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||||
'/repositories/create': typeof dashboardRepositoriesCreateRoute
|
'/repositories/create': typeof dashboardRepositoriesCreateRoute
|
||||||
'/settings/sso-link': typeof dashboardSettingsSsoLinkRouteWithChildren
|
|
||||||
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
||||||
'/volumes/create': typeof dashboardVolumesCreateRoute
|
'/volumes/create': typeof dashboardVolumesCreateRoute
|
||||||
'/backups': typeof dashboardBackupsIndexRoute
|
'/backups': typeof dashboardBackupsIndexRoute
|
||||||
|
|
@ -232,7 +215,6 @@ export interface FileRoutesByTo {
|
||||||
'/settings': typeof dashboardSettingsIndexRoute
|
'/settings': typeof dashboardSettingsIndexRoute
|
||||||
'/volumes': typeof dashboardVolumesIndexRoute
|
'/volumes': typeof dashboardVolumesIndexRoute
|
||||||
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||||
'/settings/sso-link/error': typeof dashboardSettingsSsoLinkErrorRoute
|
|
||||||
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
|
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
|
||||||
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||||
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||||
|
|
@ -253,7 +235,6 @@ export interface FileRoutesById {
|
||||||
'/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
'/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||||
'/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute
|
'/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||||
'/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute
|
'/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute
|
||||||
'/(dashboard)/settings/sso-link': typeof dashboardSettingsSsoLinkRouteWithChildren
|
|
||||||
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
||||||
'/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute
|
'/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute
|
||||||
'/(dashboard)/backups/': typeof dashboardBackupsIndexRoute
|
'/(dashboard)/backups/': typeof dashboardBackupsIndexRoute
|
||||||
|
|
@ -262,7 +243,6 @@ export interface FileRoutesById {
|
||||||
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
|
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
|
||||||
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
|
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
|
||||||
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||||
'/(dashboard)/settings/sso-link/error': typeof dashboardSettingsSsoLinkErrorRoute
|
|
||||||
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
|
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
|
||||||
'/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
'/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||||
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||||
|
|
@ -282,7 +262,6 @@ export interface FileRouteTypes {
|
||||||
| '/notifications/$notificationId'
|
| '/notifications/$notificationId'
|
||||||
| '/notifications/create'
|
| '/notifications/create'
|
||||||
| '/repositories/create'
|
| '/repositories/create'
|
||||||
| '/settings/sso-link'
|
|
||||||
| '/volumes/$volumeId'
|
| '/volumes/$volumeId'
|
||||||
| '/volumes/create'
|
| '/volumes/create'
|
||||||
| '/backups/'
|
| '/backups/'
|
||||||
|
|
@ -291,7 +270,6 @@ export interface FileRouteTypes {
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
| '/volumes/'
|
| '/volumes/'
|
||||||
| '/repositories/$repositoryId/edit'
|
| '/repositories/$repositoryId/edit'
|
||||||
| '/settings/sso-link/error'
|
|
||||||
| '/backups/$backupId/'
|
| '/backups/$backupId/'
|
||||||
| '/repositories/$repositoryId/'
|
| '/repositories/$repositoryId/'
|
||||||
| '/backups/$backupId/$snapshotId/restore'
|
| '/backups/$backupId/$snapshotId/restore'
|
||||||
|
|
@ -309,7 +287,6 @@ export interface FileRouteTypes {
|
||||||
| '/notifications/$notificationId'
|
| '/notifications/$notificationId'
|
||||||
| '/notifications/create'
|
| '/notifications/create'
|
||||||
| '/repositories/create'
|
| '/repositories/create'
|
||||||
| '/settings/sso-link'
|
|
||||||
| '/volumes/$volumeId'
|
| '/volumes/$volumeId'
|
||||||
| '/volumes/create'
|
| '/volumes/create'
|
||||||
| '/backups'
|
| '/backups'
|
||||||
|
|
@ -318,7 +295,6 @@ export interface FileRouteTypes {
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/volumes'
|
| '/volumes'
|
||||||
| '/repositories/$repositoryId/edit'
|
| '/repositories/$repositoryId/edit'
|
||||||
| '/settings/sso-link/error'
|
|
||||||
| '/backups/$backupId'
|
| '/backups/$backupId'
|
||||||
| '/repositories/$repositoryId'
|
| '/repositories/$repositoryId'
|
||||||
| '/backups/$backupId/$snapshotId/restore'
|
| '/backups/$backupId/$snapshotId/restore'
|
||||||
|
|
@ -338,7 +314,6 @@ export interface FileRouteTypes {
|
||||||
| '/(dashboard)/notifications/$notificationId'
|
| '/(dashboard)/notifications/$notificationId'
|
||||||
| '/(dashboard)/notifications/create'
|
| '/(dashboard)/notifications/create'
|
||||||
| '/(dashboard)/repositories/create'
|
| '/(dashboard)/repositories/create'
|
||||||
| '/(dashboard)/settings/sso-link'
|
|
||||||
| '/(dashboard)/volumes/$volumeId'
|
| '/(dashboard)/volumes/$volumeId'
|
||||||
| '/(dashboard)/volumes/create'
|
| '/(dashboard)/volumes/create'
|
||||||
| '/(dashboard)/backups/'
|
| '/(dashboard)/backups/'
|
||||||
|
|
@ -347,7 +322,6 @@ export interface FileRouteTypes {
|
||||||
| '/(dashboard)/settings/'
|
| '/(dashboard)/settings/'
|
||||||
| '/(dashboard)/volumes/'
|
| '/(dashboard)/volumes/'
|
||||||
| '/(dashboard)/repositories/$repositoryId/edit'
|
| '/(dashboard)/repositories/$repositoryId/edit'
|
||||||
| '/(dashboard)/settings/sso-link/error'
|
|
||||||
| '/(dashboard)/backups/$backupId/'
|
| '/(dashboard)/backups/$backupId/'
|
||||||
| '/(dashboard)/repositories/$repositoryId/'
|
| '/(dashboard)/repositories/$repositoryId/'
|
||||||
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
|
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
|
||||||
|
|
@ -462,13 +436,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport
|
preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport
|
||||||
parentRoute: typeof dashboardRouteRoute
|
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': {
|
'/(dashboard)/repositories/create': {
|
||||||
id: '/(dashboard)/repositories/create'
|
id: '/(dashboard)/repositories/create'
|
||||||
path: '/repositories/create'
|
path: '/repositories/create'
|
||||||
|
|
@ -518,13 +485,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport
|
preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport
|
||||||
parentRoute: typeof dashboardRouteRoute
|
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': {
|
'/(dashboard)/repositories/$repositoryId/edit': {
|
||||||
id: '/(dashboard)/repositories/$repositoryId/edit'
|
id: '/(dashboard)/repositories/$repositoryId/edit'
|
||||||
path: '/repositories/$repositoryId/edit'
|
path: '/repositories/$repositoryId/edit'
|
||||||
|
|
@ -584,26 +544,11 @@ const authRouteRouteWithChildren = authRouteRoute._addFileChildren(
|
||||||
authRouteRouteChildren,
|
authRouteRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface dashboardSettingsSsoLinkRouteChildren {
|
|
||||||
dashboardSettingsSsoLinkErrorRoute: typeof dashboardSettingsSsoLinkErrorRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardSettingsSsoLinkRouteChildren: dashboardSettingsSsoLinkRouteChildren =
|
|
||||||
{
|
|
||||||
dashboardSettingsSsoLinkErrorRoute: dashboardSettingsSsoLinkErrorRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardSettingsSsoLinkRouteWithChildren =
|
|
||||||
dashboardSettingsSsoLinkRoute._addFileChildren(
|
|
||||||
dashboardSettingsSsoLinkRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface dashboardRouteRouteChildren {
|
interface dashboardRouteRouteChildren {
|
||||||
dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute
|
dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute
|
||||||
dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute
|
dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute
|
||||||
dashboardNotificationsCreateRoute: typeof dashboardNotificationsCreateRoute
|
dashboardNotificationsCreateRoute: typeof dashboardNotificationsCreateRoute
|
||||||
dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute
|
dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute
|
||||||
dashboardSettingsSsoLinkRoute: typeof dashboardSettingsSsoLinkRouteWithChildren
|
|
||||||
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
|
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
|
||||||
dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute
|
dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute
|
||||||
dashboardBackupsIndexRoute: typeof dashboardBackupsIndexRoute
|
dashboardBackupsIndexRoute: typeof dashboardBackupsIndexRoute
|
||||||
|
|
@ -625,7 +570,6 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
|
||||||
dashboardNotificationsNotificationIdRoute,
|
dashboardNotificationsNotificationIdRoute,
|
||||||
dashboardNotificationsCreateRoute: dashboardNotificationsCreateRoute,
|
dashboardNotificationsCreateRoute: dashboardNotificationsCreateRoute,
|
||||||
dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute,
|
dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute,
|
||||||
dashboardSettingsSsoLinkRoute: dashboardSettingsSsoLinkRouteWithChildren,
|
|
||||||
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
|
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
|
||||||
dashboardVolumesCreateRoute: dashboardVolumesCreateRoute,
|
dashboardVolumesCreateRoute: dashboardVolumesCreateRoute,
|
||||||
dashboardBackupsIndexRoute: dashboardBackupsIndexRoute,
|
dashboardBackupsIndexRoute: dashboardBackupsIndexRoute,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { getAdminUsersOptions, getSsoSettingsOptions } from "~/client/api-client
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/settings/")({
|
export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: type({ tab: "string?", ssoLinkStatus: "string?", ssoLinkMessage: "string?" }),
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
const authContext = await fetchUser();
|
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",
|
"sso_provider",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
providerId: text("provider_id").notNull().unique(),
|
providerId: text("provider_id").notNull(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id").references(() => usersTable.id, { onDelete: "set null" }),
|
||||||
.notNull()
|
|
||||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
|
||||||
issuer: text("issuer").notNull(),
|
issuer: text("issuer").notNull(),
|
||||||
domain: text("domain").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>(),
|
oidcConfig: text("oidc_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||||
samlConfig: text("saml_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" })
|
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 { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user";
|
||||||
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
|
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
|
||||||
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
|
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>>;
|
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||||
|
|
||||||
|
|
@ -50,9 +51,8 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
before: async (user, ctx) => {
|
before: async (user, ctx) => {
|
||||||
await requireSsoInvitation(user.email, ctx);
|
|
||||||
|
|
||||||
if (isSsoCallbackRequest(ctx)) {
|
if (isSsoCallbackRequest(ctx)) {
|
||||||
|
await requireSsoInvitation(user.email, ctx);
|
||||||
user.hasDownloadedResticPassword = true;
|
user.hasDownloadedResticPassword = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
accountLinking: {
|
accountLinking: {
|
||||||
disableImplicitLinking: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -123,6 +123,7 @@ export const auth = betterAuth({
|
||||||
defaultRole: "member",
|
defaultRole: "member",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
ssoTrustedProviderLinkingPlugin(),
|
||||||
twoFactor({
|
twoFactor({
|
||||||
backupCodeOptions: {
|
backupCodeOptions: {
|
||||||
storeBackupCodes: "encrypted",
|
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) => {
|
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
|
||||||
const { path } = ctx;
|
const { path } = ctx;
|
||||||
const existingUser = await db.query.usersTable.findFirst();
|
|
||||||
|
|
||||||
if (path !== "/sign-up/email") {
|
if (path !== "/sign-up/email") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingUser = await db.query.usersTable.findFirst();
|
||||||
|
|
||||||
const result = await db.query.appMetadataTable.findFirst({
|
const result = await db.query.appMetadataTable.findFirst({
|
||||||
where: { key: REGISTRATION_ENABLED_KEY },
|
where: { key: REGISTRATION_ENABLED_KEY },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,16 @@ export function isSsoCallbackRequest(ctx: GenericEndpointContext | null) {
|
||||||
|
|
||||||
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
|
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return;
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Missing SSO context",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerId = extractProviderIdFromContext(ctx);
|
const providerId = extractProviderIdFromContext(ctx);
|
||||||
if (!providerId) {
|
if (!providerId) {
|
||||||
return;
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Missing providerId in context",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await db
|
const provider = await db
|
||||||
|
|
@ -31,7 +35,9 @@ export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpoi
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (provider.length === 0) {
|
if (provider.length === 0) {
|
||||||
return;
|
throw new APIError("NOT_FOUND", {
|
||||||
|
message: "SSO provider not found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedEmail = normalizeEmail(userEmail);
|
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,11 +10,15 @@ export function extractProviderIdFromContext(ctx: GenericEndpointContext): strin
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.request?.url) {
|
if (ctx.request?.url) {
|
||||||
const pathname = new URL(ctx.request.url).pathname;
|
try {
|
||||||
|
const pathname = new URL(ctx.request.url, "http://localhost").pathname;
|
||||||
const ssoCallbackMatch = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/);
|
const ssoCallbackMatch = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/);
|
||||||
if (ssoCallbackMatch) {
|
if (ssoCallbackMatch) {
|
||||||
return ssoCallbackMatch[1];
|
return ssoCallbackMatch[1];
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,13 @@ export const getOrganizationContext = createServerFn({ method: "GET" }).handler(
|
||||||
});
|
});
|
||||||
const session = await auth.api.getSession({ headers: request.headers });
|
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);
|
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({
|
const member = await auth.api.getActiveMember({
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { validator } from "hono-openapi";
|
||||||
import {
|
import {
|
||||||
type GetStatusDto,
|
type GetStatusDto,
|
||||||
getStatusDto,
|
getStatusDto,
|
||||||
|
|
@ -12,6 +13,8 @@ import {
|
||||||
type AdminUsersDto,
|
type AdminUsersDto,
|
||||||
deleteSsoProviderDto,
|
deleteSsoProviderDto,
|
||||||
deleteSsoInvitationDto,
|
deleteSsoInvitationDto,
|
||||||
|
updateSsoProviderAutoLinkingBody,
|
||||||
|
updateSsoProviderAutoLinkingDto,
|
||||||
} from "./auth.dto";
|
} from "./auth.dto";
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
import { requireAdmin, requireAuth } from "./auth.middleware";
|
import { requireAdmin, requireAuth } from "./auth.middleware";
|
||||||
|
|
@ -34,12 +37,10 @@ export const authController = new Hono()
|
||||||
return c.json<SsoSettingsDto>({ providers: [], invitations: [] });
|
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.listSSOProviders({ headers }),
|
||||||
auth.api.listInvitations({
|
auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }),
|
||||||
headers,
|
authService.getSsoProviderAutoLinkingSettings(activeOrganizationId),
|
||||||
query: { organizationId: activeOrganizationId },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json<SsoSettingsDto>({
|
return c.json<SsoSettingsDto>({
|
||||||
|
|
@ -48,6 +49,7 @@ export const authController = new Hono()
|
||||||
type: provider.type,
|
type: provider.type,
|
||||||
issuer: provider.issuer,
|
issuer: provider.issuer,
|
||||||
domain: provider.domain,
|
domain: provider.domain,
|
||||||
|
autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? true,
|
||||||
organizationId: provider.organizationId,
|
organizationId: provider.organizationId,
|
||||||
})),
|
})),
|
||||||
invitations: invitationsData.map((invitation) => ({
|
invitations: invitationsData.map((invitation) => ({
|
||||||
|
|
@ -65,6 +67,25 @@ export const authController = new Hono()
|
||||||
|
|
||||||
return c.json({ success: true });
|
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) => {
|
.delete("/sso-invitations/:invitationId", requireAuth, requireAdmin, deleteSsoInvitationDto, async (c) => {
|
||||||
const invitationId = c.req.param("invitationId");
|
const invitationId = c.req.param("invitationId");
|
||||||
await authService.deleteSsoInvitation(invitationId);
|
await authService.deleteSsoInvitation(invitationId);
|
||||||
|
|
@ -89,6 +110,7 @@ export const authController = new Hono()
|
||||||
})),
|
})),
|
||||||
total: usersData.total,
|
total: usersData.total,
|
||||||
limit: "limit" in usersData ? (usersData.limit ?? 100) : 100,
|
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) => {
|
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export const ssoSettingsResponse = type({
|
||||||
type: "string",
|
type: "string",
|
||||||
issuer: "string",
|
issuer: "string",
|
||||||
domain: "string",
|
domain: "string",
|
||||||
|
autoLinkMatchingEmails: "boolean",
|
||||||
organizationId: "string | null",
|
organizationId: "string | null",
|
||||||
}).array(),
|
}).array(),
|
||||||
invitations: type({
|
invitations: type({
|
||||||
|
|
@ -75,6 +76,7 @@ export const adminUsersResponse = type({
|
||||||
}).array(),
|
}).array(),
|
||||||
total: "number",
|
total: "number",
|
||||||
limit: "number",
|
limit: "number",
|
||||||
|
offset: "number",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AdminUsersDto = typeof adminUsersResponse.infer;
|
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 {
|
interface ContextVariableMap {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
hasDownloadedResticPassword: boolean;
|
hasDownloadedResticPassword: boolean;
|
||||||
role?: string | null | undefined;
|
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
|
* Delete an invitation
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue