Port trust center features after main rebase

Move Emile's commitment CRUD into complianceportal management,
wire console and visitor GraphQL, and drop portal magic-link
sign-in in favor of OAuth /initiate while keeping documents,
NDA/full-name gates, and access-request resume markers.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-17 17:06:33 +02:00
parent b221b17d1e
commit bc128ec516
29 changed files with 898 additions and 1023 deletions

View File

@@ -28,29 +28,9 @@
"zoomIn": "Zoom in"
},
"auth": {
"backToPortal": "Back to portal",
"signIn": {
"title": "Sign in to continue",
"description": "Access protected resources or submit a request",
"or": "OR",
"withProvider": "Sign in with {{provider}}",
"emailLabel": "Email Address",
"emailPlaceholder": "you@company.com",
"emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address",
"sendMagicLink": "Send Magic Link",
"resend": "Resend link",
"resendIn": "Resend link in {{seconds}}s",
"magicLinkSent": "Magic link sent!",
"magicLinkSentNote": "Check your email and use the link to sign in."
},
"requestAccess": {
"success": "Access requested"
},
"verify": {
"title": "Confirming your email",
"description": "Please wait while we sign you in\u2026"
},
"fullName": {
"title": "Add your name",
"label": "Full Name",
@@ -59,17 +39,7 @@
"tooShort": "Full name is too short",
"submit": "Continue"
},
"magicLinkExpired": {
"title": "Link expired",
"description": "This magic link has expired. Please request a new one."
},
"magicLinkAlreadyUsed": {
"title": "Link already used",
"description": "This magic link has already been used. Please request a new one."
},
"errors": {
"magicLinkFailed": "Couldn't send the magic link. Please try again.",
"verifyFailed": "Couldn't verify the link. Please try again.",
"fullNameFailed": "Couldn't save your name. Please try again.",
"requestFailed": "Couldn't complete your access request. Please try again."
}

View File

@@ -28,29 +28,9 @@
"zoomIn": "Zoom avant"
},
"auth": {
"backToPortal": "Retour au portail",
"signIn": {
"title": "Connectez-vous pour continuer",
"description": "Accédez aux ressources protégées ou soumettez une demande",
"or": "OU",
"withProvider": "Se connecter avec {{provider}}",
"emailLabel": "Adresse e-mail",
"emailPlaceholder": "vous@entreprise.com",
"emailRequired": "L'e-mail est requis",
"emailInvalid": "Saisissez une adresse e-mail valide",
"sendMagicLink": "Envoyer le lien magique",
"resend": "Renvoyer le lien",
"resendIn": "Renvoyer le lien dans {{seconds}}s",
"magicLinkSent": "Lien magique envoyé !",
"magicLinkSentNote": "Consultez votre e-mail et utilisez le lien pour vous connecter."
},
"requestAccess": {
"success": "Accès demandé"
},
"verify": {
"title": "Confirmation de votre e-mail",
"description": "Veuillez patienter pendant que nous vous connectons\u2026"
},
"fullName": {
"title": "Ajoutez votre nom",
"label": "Nom complet",
@@ -59,17 +39,7 @@
"tooShort": "Le nom complet est trop court",
"submit": "Continuer"
},
"magicLinkExpired": {
"title": "Lien expiré",
"description": "Ce lien magique a expiré. Veuillez en demander un nouveau."
},
"magicLinkAlreadyUsed": {
"title": "Lien déjà utilisé",
"description": "Ce lien magique a déjà été utilisé. Veuillez en demander un nouveau."
},
"errors": {
"magicLinkFailed": "Impossible d'envoyer le lien magique. Veuillez réessayer.",
"verifyFailed": "Impossible de vérifier le lien. Veuillez réessayer.",
"fullNameFailed": "Impossible d'enregistrer votre nom. Veuillez réessayer.",
"requestFailed": "Impossible de finaliser votre demande d'accès. Veuillez réessayer."
}

View File

@@ -27,6 +27,8 @@ import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { Link as RouterLink, useLocation } from "react-router";
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
import { TOP_BAR_NAV_ITEMS } from "./navItems";
import { TopBarMobileNav } from "./TopBarMobileNav";
@@ -110,15 +112,7 @@ export function TopBar({ queryKey }: TopBarProps) {
highContrast
iconStart={<LockSimpleIcon />}
onClick={() => {
const initiateURL = new URL(
"/initiate",
window.location.origin,
);
initiateURL.searchParams.set(
"continue",
location.pathname + location.search + location.hash,
);
window.location.href = initiateURL.toString();
redirectToInitiate(buildRequestAllContinueUrl());
}}
>
{t("topBar.getAccess")}

View File

@@ -43,8 +43,7 @@ import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { useLocation } from "react-router";
import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl";
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
import { useSignOut } from "#/lib/auth/useSignOut";
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
@@ -72,7 +71,6 @@ function isActive(pathname: string, to: string): boolean {
export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
const { t } = useTranslation();
const { pathname } = useLocation();
const { openSignIn } = useSignInDialog();
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
const [signOut, isSigningOut] = useSignOut();
const [open, setOpen] = useState(false);
@@ -150,7 +148,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
iconStart={<LockSimpleIcon />}
onClick={() => {
close();
openSignIn({ continueTo: buildRequestAllContinueUrl() });
redirectToInitiate(buildRequestAllContinueUrl());
}}
>
{t("topBar.getAccess")}

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Google } from "@probo/ui/src/Atoms/ThirdParties/Google";
import { Microsoft } from "@probo/ui/src/Atoms/ThirdParties/Microsoft";
import { Button } from "@probo/ui/src/v2/Button/Button";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import type { OIDCButton_provider$key } from "./__generated__/OIDCButton_provider.graphql";
const providerFragment = graphql`
fragment OIDCButton_provider on OIDCProviderInfo {
name
loginURL
}
`;
const providerIcons: Record<string, (props: ComponentProps<"svg">) => React.ReactNode> = {
google: Google,
microsoft: Microsoft,
};
interface OIDCButtonProps {
providerKey: OIDCButton_provider$key;
// Absolute URL to return to after the provider completes authentication.
continueTo: string;
}
// Redirects the whole window to the provider's hosted login, carrying the
// `continue` target so the portal resumes the pending flow on return.
export function OIDCButton({ providerKey, continueTo }: OIDCButtonProps) {
const { t } = useTranslation();
const provider = useFragment(providerFragment, providerKey);
const Icon = providerIcons[provider.name];
const label = t("auth.signIn.withProvider", {
provider: provider.name.charAt(0).toUpperCase() + provider.name.slice(1),
});
return (
<Button
type="button"
variant="soft"
color="neutral"
highContrast
className="w-full"
iconStart={Icon ? <Icon className="size-4" /> : undefined}
onClick={() => {
const loginURL = new URL(provider.loginURL, window.location.origin);
loginURL.searchParams.set("continue", continueTo);
window.location.href = loginURL.toString();
}}
>
{label}
</Button>
);
}

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useLazyLoadQuery } from "react-relay";
import type { OIDCProvidersQuery } from "./__generated__/OIDCProvidersQuery.graphql";
import { OIDCButton } from "./OIDCButton";
const oidcProvidersQuery = graphql`
query OIDCProvidersQuery {
oidcProviders {
...OIDCButton_provider
}
}
`;
interface OIDCProvidersProps {
continueTo: string;
}
function Divider() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-4">
<span className="h-px flex-1 bg-sand-6" />
<Text size={1} color="faint">{t("auth.signIn.or")}</Text>
<span className="h-px flex-1 bg-sand-6" />
</div>
);
}
function OIDCProvidersContent({ continueTo }: OIDCProvidersProps) {
const data = useLazyLoadQuery<OIDCProvidersQuery>(oidcProvidersQuery, {});
if (data.oidcProviders.length === 0) {
return null;
}
return (
<>
<div className="flex flex-col gap-2">
{data.oidcProviders.map((provider, index) => (
<OIDCButton key={index} providerKey={provider} continueTo={continueTo} />
))}
</div>
<Divider />
</>
);
}
// SSO buttons for the sign-in dialog. Providers load lazily on open; if the
// query fails or the trust center has none, the section renders nothing so the
// email flow stays usable.
export function OIDCProviders({ continueTo }: OIDCProvidersProps) {
return (
<ErrorBoundary
fallback={null}
onError={error => console.error("Failed to load SSO providers", error)}
>
<Suspense
fallback={(
<div className="flex flex-col gap-2">
<ButtonSkeleton className="w-full" />
<ButtonSkeleton className="w-full" />
</div>
)}
>
<OIDCProvidersContent continueTo={continueTo} />
</Suspense>
</ErrorBoundary>
);
}

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Dialog } from "@probo/ui/src/v2/Dialog/Dialog";
import { DialogDescription } from "@probo/ui/src/v2/Dialog/DialogDescription";
import { DialogHeader } from "@probo/ui/src/v2/Dialog/DialogHeader";
import { DialogPopup } from "@probo/ui/src/v2/Dialog/DialogPopup";
import { DialogTitle } from "@probo/ui/src/v2/Dialog/DialogTitle";
import { useTranslation } from "react-i18next";
import { SignInForm } from "./SignInForm";
interface SignInDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// Absolute URL to return to after authentication.
continueTo: string;
}
// The reusable "Login Dialog" from the design: a modal sign-in gate composed of
// the kit Dialog plus the magic-link / SSO sign-in form.
export function SignInDialog({ open, onOpenChange, continueTo }: SignInDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPopup className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("auth.signIn.title")}</DialogTitle>
<DialogDescription>{t("auth.signIn.description")}</DialogDescription>
</DialogHeader>
<SignInForm continueTo={continueTo} onCancel={() => onOpenChange(false)} />
</DialogPopup>
</Dialog>
);
}

View File

@@ -1,168 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Field } from "@base-ui/react/field";
import { Form } from "@base-ui/react/form";
import { Toast } from "@base-ui/react/toast";
import type { GraphQLError } from "@probo/helpers";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { DialogBody } from "@probo/ui/src/v2/Dialog/DialogBody";
import { DialogFooter } from "@probo/ui/src/v2/Dialog/DialogFooter";
import { TextField } from "@probo/ui/src/v2/form/TextField";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "relay-runtime";
import { getSafeContinueUrl } from "#/lib/auth/continueUrl";
import { useMutation } from "#/lib/relay/useMutation";
import type { SignInFormMutation } from "./__generated__/SignInFormMutation.graphql";
import { OIDCProviders } from "./OIDCProviders";
const RESEND_COOLDOWN_SECONDS = 60;
const sendMagicLinkMutation = graphql`
mutation SignInFormMutation($input: SendMagicLinkInput!) {
sendMagicLink(input: $input) {
success
}
}
`;
interface SignInFormProps {
// Absolute URL to return to after authentication (carries the request-all
// marker so an access request resumes once signed in).
continueTo: string;
onCancel: () => void;
}
// Sign-in form used inside the dialog: SSO providers, then a magic-link email
// flow. On success it flips to a "check your email" state with a resend timer.
export function SignInForm({ continueTo, onCancel }: SignInFormProps) {
const { t } = useTranslation();
const toast = Toast.useToastManager();
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
const [sendMagicLink, isSending] = useMutation<SignInFormMutation>(
sendMagicLinkMutation,
{ errorToast: false },
);
useEffect(() => {
if (!magicLinkSent) {
return;
}
intervalRef.current = setInterval(() => {
setSecondsLeft(seconds => Math.max(seconds - 1, 0));
}, 1000);
return () => clearInterval(intervalRef.current);
}, [magicLinkSent]);
const handleSend = (email: string) => {
void sendMagicLink({
variables: { input: { email, continue: continueTo } },
onCompleted: (_response, errors) => {
const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code;
// Already signed in elsewhere: jump straight to the return URL so any
// pending access request resumes.
if (code === "ALREADY_AUTHENTICATED") {
window.location.href = getSafeContinueUrl(continueTo);
return;
}
if (errors && errors.length > 0) {
toast.add({ title: t("auth.errors.magicLinkFailed"), type: "error" });
return;
}
setSecondsLeft(RESEND_COOLDOWN_SECONDS);
setMagicLinkSent(true);
toast.add({ title: t("auth.signIn.magicLinkSent"), type: "success" });
},
onError: () => {
toast.add({ title: t("auth.errors.magicLinkFailed"), type: "error" });
},
}).catch(() => {});
};
const resendDisabled = magicLinkSent && secondsLeft > 0;
const submitLabel = magicLinkSent
? secondsLeft > 0
? t("auth.signIn.resendIn", { seconds: secondsLeft })
: t("auth.signIn.resend")
: t("auth.signIn.sendMagicLink");
return (
<Form
className="flex flex-col gap-4"
onFormSubmit={(values) => {
handleSend(String(values.email ?? ""));
}}
>
<DialogBody className="flex flex-col gap-6">
<OIDCProviders continueTo={continueTo} />
<Field.Root name="email" className="flex flex-col gap-1.5">
<Field.Label className="text-1 font-medium text-sand-12">
{t("auth.signIn.emailLabel")}
</Field.Label>
<TextField
type="email"
name="email"
required
placeholder={t("auth.signIn.emailPlaceholder")}
/>
<Field.Error className="text-1 text-red-11" match="valueMissing">
{t("auth.signIn.emailRequired")}
</Field.Error>
<Field.Error className="text-1 text-red-11" match="typeMismatch">
{t("auth.signIn.emailInvalid")}
</Field.Error>
</Field.Root>
{magicLinkSent && (
<Text size={1} color="neutral">
{t("auth.signIn.magicLinkSentNote")}
</Text>
)}
</DialogBody>
<DialogFooter>
<Button type="button" variant="soft" color="neutral" highContrast onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="solid"
color="neutral"
highContrast
loading={isSending}
disabled={resendDisabled}
>
{submitLabel}
</Button>
</DialogFooter>
</Form>
);
}

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { SignInDialog } from "#/components/auth/SignInDialog";
import {
type OpenSignInOptions,
SignInDialogContextProvider,
} from "#/lib/auth/signInDialogContext";
interface SignInDialogProviderProps {
children: ReactNode;
}
// Owns the single sign-in dialog instance and exposes `openSignIn` so any
// descendant (top bar, resource rows, …) can prompt authentication and pass the
// URL to return to afterwards.
export function SignInDialogProvider({ children }: SignInDialogProviderProps) {
const [open, setOpen] = useState(false);
const [continueTo, setContinueTo] = useState(() => window.location.href);
const openSignIn = useCallback((options?: OpenSignInOptions) => {
setContinueTo(options?.continueTo ?? window.location.href);
setOpen(true);
}, []);
const value = useMemo(() => ({ openSignIn }), [openSignIn]);
return (
<SignInDialogContextProvider value={value}>
{children}
<SignInDialog open={open} onOpenChange={setOpen} continueTo={continueTo} />
</SignInDialogContextProvider>
);
}

View File

@@ -108,3 +108,12 @@ export function gateRedirectPath(error: unknown, continueUrl: string): string |
}
return null;
}
// Sends the browser to the OAuth entry point, carrying a validated continue URL
// so the user returns to the portal (and any deferred access request resumes)
// after sign-in.
export function redirectToInitiate(continueTo: string): void {
const initiateURL = new URL("/initiate", window.location.origin);
initiateURL.searchParams.set("continue", getSafeContinueUrl(continueTo));
window.location.href = initiateURL.toString();
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { createContext, useContext } from "react";
export type OpenSignInOptions = {
// Absolute URL the user should return to once authenticated. Defaults to the
// current page. Include the request-all marker to resume an access request.
continueTo?: string;
};
export type SignInDialogContextValue = {
openSignIn: (options?: OpenSignInOptions) => void;
};
const SignInDialogContext = createContext<SignInDialogContextValue | null>(null);
export const SignInDialogContextProvider = SignInDialogContext.Provider;
export function useSignInDialog(): SignInDialogContextValue {
const context = useContext(SignInDialogContext);
if (context === null) {
throw new Error("useSignInDialog must be used within a SignInDialogProvider");
}
return context;
}

View File

@@ -96,7 +96,7 @@ const requestFileMutation = graphql`
}
`;
// After a user signs in through the dialog, they land back on the page that
// After a user signs in through OAuth /initiate, they land back on the page that
// carried a deferred access marker. This hook fires the matching mutation once
// (when authenticated) — request-all from the top bar, or a single
// document / report / file requested from a locked row — routes to the

View File

@@ -30,8 +30,7 @@ import { graphql, useFragment } from "react-relay";
import { useSearchParams } from "react-router";
import { SubscribeDialog } from "#/components/SubscribeDialog/SubscribeDialog";
import { buildSubscribeContinueUrl, SUBSCRIBE_PARAM } from "#/lib/auth/continueUrl";
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
import { buildSubscribeContinueUrl, redirectToInitiate, SUBSCRIBE_PARAM } from "#/lib/auth/continueUrl";
import {
SubscribeDialogContextProvider,
} from "#/lib/mailingList/subscribeDialogContext";
@@ -69,7 +68,6 @@ export function SubscribeDialogProvider({
children,
}: SubscribeDialogProviderProps) {
const data = useFragment(subscribeDialogProviderFragment, queryKey);
const { openSignIn } = useSignInDialog();
const [searchParams, setSearchParams] = useSearchParams();
const [dialogOpen, setDialogOpen] = useState(false);
const [unsubscribeFromMailingList, isUnsubscribing] = useUnsubscribeFromMailingList();
@@ -80,11 +78,11 @@ export function SubscribeDialogProvider({
const openSubscribe = useCallback(() => {
if (viewer == null) {
openSignIn({ continueTo: buildSubscribeContinueUrl() });
redirectToInitiate(buildSubscribeContinueUrl());
return;
}
setDialogOpen(true);
}, [openSignIn, viewer]);
}, [viewer]);
const unsubscribe = useCallback(async () => {
try {

View File

@@ -25,7 +25,6 @@ import { Outlet, useMatch } from "react-router";
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
import { TopBar } from "#/components/TopBar/TopBar";
import { SignInDialogProvider } from "#/lib/auth/SignInDialogProvider";
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider";
@@ -57,28 +56,26 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
// stage; every other page uses normal document flow so the footer sits after
// content (and at the bottom of short pages via flex-1 main).
return (
<SignInDialogProvider>
<SubscribeDialogProvider queryKey={data}>
<SubscribeDialogProvider queryKey={data}>
<div
className={
isDocumentViewer
? "flex h-dvh flex-col bg-sand-2"
: "flex min-h-dvh flex-col bg-sand-2"
}
>
<TopBar queryKey={data} />
<div
className={
isDocumentViewer
? "flex h-dvh flex-col bg-sand-2"
: "flex min-h-dvh flex-col bg-sand-2"
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "flex flex-1 flex-col"
}
>
<TopBar queryKey={data} />
<div
className={
isDocumentViewer
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "flex flex-1 flex-col"
}
>
<Outlet />
</div>
{isDocumentViewer ? null : <PoweredBy label={t("footer.poweredBy")} />}
<Outlet />
</div>
</SubscribeDialogProvider>
</SignInDialogProvider>
{isDocumentViewer ? null : <PoweredBy label={t("footer.poweredBy")} />}
</div>
</SubscribeDialogProvider>
);
}

View File

@@ -21,8 +21,7 @@
import { Card } from "@probo/ui/src/v2/Card/Card";
import { Outlet } from "react-router";
// Minimal centered chrome for the standalone auth steps that a magic-link email
// or a full-name gate lands on (these cannot live inside the portal shell).
// Minimal centered chrome for standalone auth steps (e.g. the full-name gate).
export default function AuthLayout() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-sand-2 p-4">

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Link } from "@probo/ui/src/v2/Button/Link";
import { Heading } from "@probo/ui/src/v2/typography/Heading";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useTranslation } from "react-i18next";
// Shown when a magic link was already consumed; the user restarts sign-in.
export default function MagicLinkAlreadyUsedPage() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-4 text-center">
<Heading level={1} size={5}>{t("auth.magicLinkAlreadyUsed.title")}</Heading>
<Text color="neutral">{t("auth.magicLinkAlreadyUsed.description")}</Text>
<Link to="/" variant="solid" color="neutral" highContrast>
{t("auth.backToPortal")}
</Link>
</div>
);
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Link } from "@probo/ui/src/v2/Button/Link";
import { Heading } from "@probo/ui/src/v2/typography/Heading";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useTranslation } from "react-i18next";
// Shown when a magic link has expired; the user restarts sign-in from home.
export default function MagicLinkExpiredPage() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-4 text-center">
<Heading level={1} size={5}>{t("auth.magicLinkExpired.title")}</Heading>
<Text color="neutral">{t("auth.magicLinkExpired.description")}</Text>
<Link to="/" variant="solid" color="neutral" highContrast>
{t("auth.backToPortal")}
</Link>
</div>
);
}

View File

@@ -1,103 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import type { GraphQLError } from "@probo/helpers";
import { Heading } from "@probo/ui/src/v2/typography/Heading";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { getSafeContinueUrl } from "#/lib/auth/continueUrl";
import { useMutation } from "#/lib/relay/useMutation";
import type { VerifyMagicLinkPageMutation } from "./__generated__/VerifyMagicLinkPageMutation.graphql";
const verifyMagicLinkMutation = graphql`
mutation VerifyMagicLinkPageMutation($input: VerifyMagicLinkInput!) {
verifyMagicLink(input: $input) {
continue
}
}
`;
// Landing page for the magic-link email. It verifies the token on mount and
// forwards to the (validated) continue URL, where any pending access request
// resumes.
export default function VerifyMagicLinkPage() {
const { t } = useTranslation();
const toast = Toast.useToastManager();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const submittedRef = useRef(false);
const [verifyMagicLink] = useMutation<VerifyMagicLinkPageMutation>(
verifyMagicLinkMutation,
{ errorToast: false },
);
useEffect(() => {
const token = searchParams.get("token");
if (!token || submittedRef.current) {
return;
}
submittedRef.current = true;
void verifyMagicLink({
variables: { input: { token: token.trim() } },
onCompleted: (response, errors) => {
const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code;
if (code === "ALREADY_AUTHENTICATED") {
// Already signed in: honor a `continue` on the URL if present so a
// deferred access request still resumes, instead of always going home.
window.location.href = getSafeContinueUrl(searchParams.get("continue"));
return;
}
if (code === "TOKEN_EXPIRED") {
void navigate("/magic-link-expired");
return;
}
if (code === "TOKEN_ALREADY_USED") {
void navigate("/magic-link-already-used");
return;
}
if (errors && errors.length > 0) {
toast.add({ title: t("auth.errors.verifyFailed"), type: "error" });
return;
}
window.location.href = getSafeContinueUrl(response.verifyMagicLink?.continue);
},
onError: () => {
toast.add({ title: t("auth.errors.verifyFailed"), type: "error" });
},
}).catch(() => {});
}, [navigate, searchParams, t, toast, verifyMagicLink]);
return (
<div className="flex flex-col items-center gap-2 text-center">
<Heading level={1} size={5}>{t("auth.verify.title")}</Heading>
<Text color="neutral">{t("auth.verify.description")}</Text>
</div>
);
}

View File

@@ -23,29 +23,16 @@ import type { AppRoute } from "@probo/routes";
import { AuthLayoutSkeleton } from "./AuthLayoutSkeleton";
// Standalone auth steps that a magic-link email or a full-name gate lands on.
// These sit outside the portal shell, under a minimal centered layout.
// Standalone auth steps (e.g. the full-name gate) outside the portal shell.
export const authRoutes = [
{
Fallback: AuthLayoutSkeleton,
Component: lazy(() => import("#/pages/auth/AuthLayout")),
children: [
{
path: "verify-magic-link",
Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")),
},
{
path: "full-name",
Component: lazy(() => import("#/pages/auth/FullNamePage")),
},
{
path: "magic-link-expired",
Component: lazy(() => import("#/pages/auth/MagicLinkExpiredPage")),
},
{
path: "magic-link-already-used",
Component: lazy(() => import("#/pages/auth/MagicLinkAlreadyUsedPage")),
},
],
},
] satisfies AppRoute[];

View File

@@ -62,7 +62,7 @@ function StatusIcon({
// Trailing access control for a document entry: a "View" link to the viewer when
// authorized, a pending label when access was requested, otherwise a "Get
// Access" action that requests access (prompting sign-in first when needed).
// Access" action that requests access (redirecting to OAuth /initiate when needed).
export function DocumentAccessAction({
isAuthorized,
requested,

View File

@@ -29,11 +29,11 @@ import { graphql } from "relay-runtime";
import {
buildRequestAccessContinueUrl,
gateRedirectPath,
redirectToInitiate,
REQUEST_DOCUMENT_PARAM,
REQUEST_FILE_PARAM,
REQUEST_REPORT_PARAM,
} from "#/lib/auth/continueUrl";
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
import { useMutation } from "#/lib/relay/useMutation";
import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql";
@@ -95,11 +95,10 @@ const fileMutation = graphql`
// Shared success / error handling for a single access request. The auth,
// full-name, and NDA gates are thrown by the fetch layer, so they surface in
// `onError`: unauthenticated opens the sign-in dialog, while full-name and NDA
// deep-link to their gate page — all deferring the request via the continue URL
// so it resumes once the gate is cleared. Everything else is a generic toast.
// `onError`: unauthenticated redirects to OAuth /initiate, while full-name and
// NDA deep-link to their gate page — all deferring the request via the continue
// URL so it resumes once the gate is cleared. Everything else is a generic toast.
function useAccessRequestHandlers(param: string, id: string) {
const { openSignIn } = useSignInDialog();
const navigate = useNavigate();
const toast = Toast.useToastManager();
const { t } = useTranslation();
@@ -116,10 +115,10 @@ function useAccessRequestHandlers(param: string, id: string) {
onError: (error: Error) => {
const continueUrl = buildRequestAccessContinueUrl(param, id);
// Not signed in: open the dialog, deferring this request until the user
// lands back authenticated (see useResumeAccessRequest).
// Not signed in: start OAuth, deferring this request until the user lands
// back authenticated (see useResumeAccessRequest).
if (error instanceof UnAuthenticatedError) {
openSignIn({ continueTo: continueUrl });
redirectToInitiate(continueUrl);
return;
}
// Full-name / NDA gate: deep-link to the gate page, preserving the
@@ -132,7 +131,7 @@ function useAccessRequestHandlers(param: string, id: string) {
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
}),
[openSignIn, navigate, toast, t, param, id],
[navigate, toast, t, param, id],
);
}

View File

@@ -62,9 +62,7 @@ export const ndaPageQuery = graphql`
id
}
currentTrustCenter @required(action: THROW) {
organization {
name
}
title
nonDisclosureAgreement {
fileUrl
}
@@ -238,7 +236,7 @@ export function NDAPage({ queryRef }: NDAPageProps) {
{t("title")}
</Heading>
<Text size={2} color="neutral">
{t("subtitle", { name: trustCenter.organization.name })}
{t("subtitle", { name: trustCenter.title })}
</Text>
{signature.consentText != null && (
<Text size={1} color="faint" className={slots.consent()}>