Files
zerobyte/app/client/modules/sso/components/sso-settings-section.tsx

375 lines
13 KiB
TypeScript

import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Ban, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
deleteSsoInvitationMutation,
deleteSsoProviderMutation,
getSsoSettingsOptions,
updateSsoProviderAutoLinkingMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/client/components/ui/alert-dialog";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { Button } from "~/client/components/ui/button";
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 { useOrganizationContext } from "~/client/hooks/use-org-context";
import { useTimeFormat } from "~/client/lib/datetime";
import { getOrigin } from "~/client/functions/get-origin";
import { authClient } from "~/client/lib/auth-client";
import { cn } from "~/client/lib/utils";
import { useServerFn } from "@tanstack/react-start";
type InvitationRole = "member" | "admin" | "owner";
type Props = {
initialSettings?: GetSsoSettingsResponse;
initialOrigin?: string;
};
export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
const originQuery = useServerFn(getOrigin);
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
const navigate = useNavigate();
const { formatDateWithMonth } = useTimeFormat();
const { activeOrganization } = useOrganizationContext();
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
const ssoSettingsQuery = useSuspenseQuery({ ...getSsoSettingsOptions(), initialData: initialSettings });
const updateProviderAutoLinkingMutation = useMutation({
...updateSsoProviderAutoLinkingMutation(),
onSuccess: (_, v) => {
toast.success(v.body?.enabled ? "Automatic account linking enabled" : "Automatic account linking disabled");
},
onError: (error) => {
toast.error("Failed to update provider", { description: error.message });
},
});
const deleteProviderMutation = useMutation({
...deleteSsoProviderMutation(),
onSuccess: () => {
toast.success("SSO provider deleted");
},
onError: (error) => {
toast.error("Failed to delete provider", { description: error.message });
},
});
const inviteMemberMutation = useMutation({
mutationFn: async () => {
if (!activeOrganization) {
throw new Error("No active organization found in session");
}
const normalizedEmail = inviteEmail.trim().toLowerCase();
if (!normalizedEmail) {
throw new Error("Email is required");
}
const { error } = await authClient.organization.inviteMember({
email: normalizedEmail,
role: inviteRole,
organizationId: activeOrganization.id,
});
if (error) {
throw error;
}
},
onSuccess: () => {
toast.success("Invitation created");
setInviteEmail("");
setInviteRole("member");
},
onError: (error) => {
toast.error("Failed to create invitation", { description: error.message });
},
});
const cancelInvitationMutation = useMutation({
mutationFn: async (invitationId: string) => {
const { error } = await authClient.organization.cancelInvitation({ invitationId });
if (error) {
throw error;
}
},
onSuccess: () => {
toast.success("Invitation cancelled");
},
onError: (error) => {
toast.error("Failed to cancel invitation", { description: error.message });
},
});
const deleteInvitationMutation = useMutation({
...deleteSsoInvitationMutation(),
onSuccess: () => {
toast.success("Invitation deleted");
},
onError: (error) => {
toast.error("Failed to delete invitation", { description: error.message });
},
});
const providers = ssoSettingsQuery.data.providers;
const invitations = ssoSettingsQuery.data.invitations;
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium">Registered providers</p>
<p className="text-xs text-muted-foreground">Manage identity providers used for organization sign-in.</p>
</div>
<Button
type="button"
disabled={!activeOrganization}
onClick={() => void navigate({ to: "/settings/sso/new" })}
>
Register new
</Button>
</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>
<TableRow>
<TableHead>Provider ID</TableHead>
<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>
</TableHeader>
<TableBody>
{providers.map((provider) => (
<TableRow key={provider.providerId}>
<TableCell className="font-medium">{provider.providerId}</TableCell>
<TableCell>{provider.domain}</TableCell>
<TableCell className="break-all">{provider.issuer}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="uppercase text-xs font-medium px-2 py-0.5 rounded border">{provider.type}</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 },
});
}}
/>
<span className="text-xs text-muted-foreground">
{provider.autoLinkMatchingEmails ? "On" : "Off"}
</span>
</div>
</TableCell>
<TableCell>
<Input
type="text"
readOnly
value={`${data}/api/auth/sso/callback/${provider.providerId}`}
className="h-8 max-w-62.5 font-mono text-xs text-muted-foreground"
onClick={(e) => e.currentTarget.select()}
/>
</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
title="Delete provider"
loading={deleteProviderMutation.isPending}
disabled={deleteProviderMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete SSO provider</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the SSO provider <strong>{provider.providerId}</strong>?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteProviderMutation.mutate({ path: { providerId: provider.providerId } })}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
<TableRow className={cn({ hidden: providers.length > 0 })}>
<TableCell colSpan={7} className="text-center text-sm text-muted-foreground">
No providers registered yet.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div className="space-y-4 border-t border-border/50 pt-6">
<div className="space-y-1.5">
<p className="text-sm font-medium">Invite-only access</p>
<p className="text-xs text-muted-foreground">
Users must be invited or already have an account before they can sign in using SSO.
</p>
</div>
<div className="grid gap-3 @md:grid-cols-[minmax(0,1fr)_180px_auto]">
<div className="space-y-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder="teammate@example.com"
disabled={!activeOrganization || inviteMemberMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">Role</Label>
<Select
value={inviteRole}
onValueChange={(value) => setInviteRole(value as InvitationRole)}
disabled={!activeOrganization || inviteMemberMutation.isPending}
>
<SelectTrigger id="invite-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button
type="button"
loading={inviteMemberMutation.isPending}
onClick={() => inviteMemberMutation.mutate()}
disabled={!activeOrganization}
>
Invite
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell className="font-medium">{invitation.email}</TableCell>
<TableCell className="uppercase">{invitation.role}</TableCell>
<TableCell>
<span
className={cn(`text-xs font-medium px-2 py-0.5 rounded border`, {
"bg-primary/10 border-primary/20": invitation.status === "pending",
"bg-muted border-muted-foreground/20": invitation.status !== "pending",
})}
>
{invitation.status}
</span>
</TableCell>
<TableCell>{formatDateWithMonth(invitation.expiresAt)}</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="ghost"
size="icon"
title="Cancel invitation"
loading={cancelInvitationMutation.isPending}
disabled={cancelInvitationMutation.isPending}
onClick={() => cancelInvitationMutation.mutate(invitation.id)}
className={cn({ hidden: invitation.status !== "pending" })}
>
<Ban className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Delete invitation"
loading={deleteInvitationMutation.isPending}
disabled={deleteInvitationMutation.isPending}
onClick={() => deleteInvitationMutation.mutate({ path: { invitationId: invitation.id } })}
className={cn({ hidden: invitation.status === "pending" })}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
<TableRow className={cn({ hidden: invitations.length > 0 })}>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
No invitations yet.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
);
}