mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 07:43:16 +10:00
feat: add organisation sso portal (#1946)
Allow organisations to manage an SSO OIDC compliant portal. This method is intended to streamline the onboarding process and paves the way to allow organisations to manage their members in a more strict way.
This commit is contained in:
@ -6,6 +6,7 @@ import {
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
@ -77,6 +78,11 @@ export default function SettingsLayout() {
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: t`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: t`Billing`,
|
||||
@ -94,6 +100,13 @@ export default function SettingsLayout() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
||||
route.path.includes('/sso')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
432
apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Normal file
432
apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import {
|
||||
formatOrganisationCallbackUrl,
|
||||
formatOrganisationLoginUrl,
|
||||
} from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { domainRegex } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
|
||||
import type { TGetOrganisationAuthenticationPortalResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-authentication-portal.types';
|
||||
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-authentication-portal.types';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
||||
.pick({
|
||||
enabled: true,
|
||||
wellKnownUrl: true,
|
||||
clientId: true,
|
||||
autoProvisionUsers: true,
|
||||
defaultOrganisationRole: true,
|
||||
})
|
||||
.extend({
|
||||
clientSecret: z.string().nullable(),
|
||||
allowedDomains: z.string().refine(
|
||||
(value) => {
|
||||
const domains = value.split(' ').filter(Boolean);
|
||||
|
||||
return domains.every((domain) => domainRegex.test(domain));
|
||||
},
|
||||
{
|
||||
message: msg`Invalid domains`.id,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Organisation SSO Portal');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingSSOLoginPage() {
|
||||
const { t } = useLingui();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
<SSOProviderForm authenticationPortal={authenticationPortal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SSOProviderFormProps = {
|
||||
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
|
||||
};
|
||||
|
||||
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { mutateAsync: updateOrganisationAuthenticationPortal } =
|
||||
trpc.enterprise.organisation.authenticationPortal.update.useMutation();
|
||||
|
||||
const form = useForm<TProviderFormSchema>({
|
||||
resolver: zodResolver(ZProviderFormSchema),
|
||||
defaultValues: {
|
||||
enabled: authenticationPortal.enabled,
|
||||
clientId: authenticationPortal.clientId,
|
||||
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
|
||||
wellKnownUrl: authenticationPortal.wellKnownUrl,
|
||||
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
|
||||
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
|
||||
allowedDomains: authenticationPortal.allowedDomains.join(' '),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: TProviderFormSchema) => {
|
||||
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
|
||||
|
||||
if (enabled && !clientId) {
|
||||
form.setError('clientId', {
|
||||
message: t`Client ID is required`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && clientSecret === '') {
|
||||
form.setError('clientSecret', {
|
||||
message: t`Client secret is required`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && !wellKnownUrl) {
|
||||
form.setError('wellKnownUrl', {
|
||||
message: t`Well-known URL is required`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateOrganisationAuthenticationPortal({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret: values.clientSecret ?? undefined,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers: values.autoProvisionUsers,
|
||||
defaultOrganisationRole: values.defaultOrganisationRole,
|
||||
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Provider has been updated successfully`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We couldn't update the provider. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isSsoEnabled = form.watch('enabled');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Organisation authentication portal URL</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pr-12"
|
||||
disabled
|
||||
value={formatOrganisationLoginUrl(organisation.url)}
|
||||
/>
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={formatOrganisationLoginUrl(organisation.url)}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Redirect URI</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pr-12"
|
||||
disabled
|
||||
value={formatOrganisationCallbackUrl(organisation.url)}
|
||||
/>
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={formatOrganisationCallbackUrl(organisation.url)}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Required scopes</Trans>
|
||||
</Label>
|
||||
|
||||
<Input className="pr-12" disabled value={`openid profile email`} />
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>This is the required scopes you must set in your provider's settings</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wellKnownUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={isSsoEnabled}>
|
||||
<Trans>Issuer URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!form.formState.errors.wellKnownUrl && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={isSsoEnabled}>
|
||||
<Trans>Client ID</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="client-id" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required={isSsoEnabled}>
|
||||
<Trans>Client Secret</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="client-secret"
|
||||
type="password"
|
||||
{...field}
|
||||
value={field.value === null ? '**********************' : field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultOrganisationRole"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Organisation Role for New Users</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select default role`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
|
||||
(role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowedDomains"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Allowed Email Domains</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t`your-domain.com another-domain.com`}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!form.formState.errors.allowedDomains && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Space-separated list of domains. Leave empty to allow all domains.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="autoProvisionUsers"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
<Trans>Auto-provision Users</Trans>
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Automatically create accounts for new users on first login</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
<Trans>Enable SSO portal</Trans>
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Whether to enable the SSO portal for your organisation</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that anyone who signs in through your portal will be added to your
|
||||
organisation as a member.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button loading={form.formState.isSubmitting} type="submit">
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 mr-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Linked Accounts</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>View and manage all login methods linked to your account.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link to="/settings/security/linked-accounts">
|
||||
<Trans>Manage linked accounts</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Linked Accounts');
|
||||
}
|
||||
|
||||
export default function SettingsSecurityLinkedAccounts() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch } = useQuery({
|
||||
queryKey: ['linked-accounts'],
|
||||
queryFn: async () => await authClient.account.getMany(),
|
||||
});
|
||||
|
||||
const results = data?.accounts ?? [];
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Provider`,
|
||||
accessorKey: 'provider',
|
||||
cell: ({ row }) => row.original.provider,
|
||||
},
|
||||
{
|
||||
header: t`Linked At`,
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) =>
|
||||
row.original.createdAt
|
||||
? DateTime.fromJSDate(row.original.createdAt).toRelative()
|
||||
: t`Unknown`,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<AccountUnlinkDialog
|
||||
accountId={row.original.id}
|
||||
provider={row.original.provider}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)[number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Linked Accounts`}
|
||||
subtitle={t`View and manage all login methods linked to your account.`}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results}
|
||||
hasFilters={false}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-8 w-16 rounded" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AccountUnlinkDialogProps = {
|
||||
accountId: string;
|
||||
provider: string;
|
||||
onSuccess: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const AccountUnlinkDialog = ({ accountId, onSuccess, provider }: AccountUnlinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleRevoke = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authClient.account.delete(accountId);
|
||||
|
||||
await onSuccess();
|
||||
|
||||
toast({
|
||||
title: t`Account unlinked`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to unlink account`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trans>Unlink</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the <span className="font-semibold">{provider}</span> login
|
||||
method from your account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button variant="destructive" loading={isLoading} onClick={handleRevoke}>
|
||||
<Trans>Unlink</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
218
apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
Normal file
218
apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { MailsIcon } from 'lucide-react';
|
||||
import { Link, redirect, useSearchParams } from 'react-router';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.signin';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Sign In');
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={404}
|
||||
errorCodeMap={{
|
||||
404: {
|
||||
heading: msg`Authentication Portal Not Found`,
|
||||
subHeading: msg`404 Not Found`,
|
||||
message: msg`The organisation authentication portal does not exist, or is not configured`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Go back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { isAuthenticated, user } = await getOptionalSession(request);
|
||||
|
||||
const orgUrl = params.orgUrl;
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
url: orgUrl,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!organisation ||
|
||||
!organisation.organisationAuthenticationPortal.enabled ||
|
||||
!organisation.organisationClaim.flags.authenticationPortal
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to organisation if already signed in and a member of the organisation.
|
||||
if (isAuthenticated && user && organisation.members.find((member) => member.userId === user.id)) {
|
||||
throw redirect(`/o/${orgUrl}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organisationName: organisation.name,
|
||||
orgUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganisationSignIn({ loaderData }: Route.ComponentProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { organisationName, orgUrl } = loaderData;
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
|
||||
|
||||
const action = searchParams.get('action');
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await authClient.oidc.org.signIn({
|
||||
orgUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
if (action === 'verification-required') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<MailsIcon className="text-primary h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
<Trans>Confirmation email sent</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-x-2">
|
||||
<Button asChild>
|
||||
<Link to={`/o/${orgUrl}/signin`} replace>
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<Trans>Welcome to {organisationName}</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`flag-3rd-party-service`}
|
||||
checked={isConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`flag-3rd-party-service`}
|
||||
>
|
||||
<Trans>
|
||||
I understand that I am providing my credentials to a 3rd party service configured by
|
||||
this organisation
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background w-full"
|
||||
loading={isSubmitting}
|
||||
disabled={!isConfirmationChecked}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<Trans>Sign In</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="relative mt-2 flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>OR</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1 flex items-center justify-center text-xs">
|
||||
<Link to="/signin">
|
||||
<Trans>Return to Documenso sign in page here</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,333 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
||||
import { data, isRouteErrorResponse } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatOrganisationLoginPath } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@documenso/ui/primitives/card';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout, defaultErrorCodeMap } from '~/components/general/generic-error-layout';
|
||||
|
||||
import type { Route } from './+types/organisation.sso.confirmation.$token';
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const errorCode = isRouteErrorResponse(error) ? error.data.type : 500;
|
||||
|
||||
const errorMap = match(errorCode)
|
||||
.with('invalid-token', () => ({
|
||||
subHeading: msg`400 Error`,
|
||||
heading: msg`Invalid Token`,
|
||||
message: msg`The token is invalid or has expired.`,
|
||||
}))
|
||||
.otherwise(() => defaultErrorCodeMap[500]);
|
||||
|
||||
return (
|
||||
<GenericErrorLayout errorCode={500} errorCodeMap={{ 500: errorMap }} secondaryButton={null} />
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
|
||||
|
||||
if (!metadata.success) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
id: metadata.data.organisationId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
type: metadata.data.type,
|
||||
user: {
|
||||
name: verificationToken.user.name,
|
||||
email: verificationToken.user.email,
|
||||
avatar: verificationToken.user.avatarImageId,
|
||||
},
|
||||
organisation: {
|
||||
name: organisation.name,
|
||||
url: organisation.url,
|
||||
avatar: organisation.avatarImageId,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token, type, user, organisation } = loaderData;
|
||||
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
|
||||
|
||||
const { mutate: declineLinkOrganisationAccount, isPending: isDeclining } =
|
||||
trpc.enterprise.organisation.authenticationPortal.declineLinkAccount.useMutation({
|
||||
onSuccess: async () => {
|
||||
await navigate('/');
|
||||
|
||||
toast({
|
||||
title: 'Account link declined',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error declining account link',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: linkOrganisationAccount, isPending: isLinking } =
|
||||
trpc.enterprise.organisation.authenticationPortal.linkAccount.useMutation({
|
||||
onSuccess: async () => {
|
||||
await navigate(formatOrganisationLoginPath(organisation.url));
|
||||
|
||||
toast({
|
||||
title: 'Account linked successfully',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error linking account',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="w-full max-w-2xl border">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{type === 'link' ? (
|
||||
<Trans>Account Linking Request</Trans>
|
||||
) : (
|
||||
<Trans>Account Creation Request</Trans>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{type === 'link' ? (
|
||||
<Trans>
|
||||
An organisation wants to link your account. Please review the details below.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
An organisation wants to create an account for you. Please review the details below.
|
||||
</Trans>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Current User Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
|
||||
<UserCircle2 className="h-4 w-4" />
|
||||
<Trans>Your Account</Trans>
|
||||
</h3>
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(user.avatar)}
|
||||
avatarFallback={extractInitials(user.name || user.email)}
|
||||
primaryText={user.name}
|
||||
secondaryText={user.email}
|
||||
/>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Account</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Organisation Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<Trans>Requesting Organisation</Trans>
|
||||
</h3>
|
||||
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(organisation.avatar)}
|
||||
avatarFallback={extractInitials(organisation.name)}
|
||||
primaryText={organisation.name}
|
||||
secondaryText={`/o/${organisation.url}`}
|
||||
/>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Organisation</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Warnings Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<Trans>Important: What This Means</Trans>
|
||||
</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>
|
||||
By accepting this request, you grant {organisation.name} the following
|
||||
permissions:
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<Eye className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
Full account access:
|
||||
</span>{' '}
|
||||
View all your profile information, settings, and activity
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Settings className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
Account management:
|
||||
</span>{' '}
|
||||
Modify your account settings, permissions, and preferences
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Database className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="text-muted-foreground font-semibold">Data access:</span>{' '}
|
||||
Access all data associated with your account
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Alert variant="warning" className="mt-3">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This organisation will have administrative control over your account. You can
|
||||
revoke this access later, but they will retain access to any data they've
|
||||
already collected.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id={`accept-conditions`}
|
||||
checked={isConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`accept-conditions`}
|
||||
>
|
||||
<Trans>I agree to link my account with this organization</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDeclining || isLinking}
|
||||
onClick={() => declineLinkOrganisationAccount({ token })}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!isConfirmationChecked || isDeclining || isLinking}
|
||||
loading={isLinking}
|
||||
onClick={() => linkOrganisationAccount({ token })}
|
||||
>
|
||||
<Trans>Accept & Link Account</Trans>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user