From 2a10b298595adee3a0ae21e42543caebd7b17dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 15 Jul 2026 13:13:40 +0200 Subject: [PATCH] Add sign-in dialog and request-access gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compliance portal's "Get Access" button was inert and the portal had no way to authenticate or request trust-center access. Add a modal sign-in flow (magic link + OIDC) that gates the requestAllAccesses mutation, mirroring the trust app's flow but as a dialog instead of a full /connect page. Introduce the two v2 UI-kit primitives this depends on: a headless Base UI Dialog and a styled Toaster (mutation toasts had no host yet). Wire the top-bar button to open the dialog, resume the deferred access request once authenticated, and add standalone routes for magic-link verification and the full-name gate. Signed-off-by: Émile Ré --- .../compliance-portal/src/_locales/en-US.json | 52 ++++++ .../compliance-portal/src/_locales/fr-FR.json | 52 ++++++ .../src/components/TopBar/TopBar.tsx | 12 +- .../src/components/auth/LoginForm.tsx | 168 ++++++++++++++++++ .../src/components/auth/OIDCButton.tsx | 75 ++++++++ .../src/components/auth/OIDCProviders.tsx | 94 ++++++++++ .../src/components/auth/SignInDialog.tsx | 53 ++++++ .../src/lib/auth/SignInDialogProvider.tsx | 53 ++++++ .../src/lib/auth/continueUrl.ts | 59 ++++++ .../src/lib/auth/signInDialogContext.ts | 43 +++++ .../src/lib/auth/useResumeAccessRequest.ts | 117 ++++++++++++ .../src/lib/auth/useSafeContinueUrl.ts | 33 ++++ .../src/pages/MainLayout.tsx | 28 ++- .../src/pages/auth/AuthLayout.tsx | 34 ++++ .../src/pages/auth/AuthLayoutSkeleton.tsx | 30 ++++ .../src/pages/auth/FullNamePage.tsx | 112 ++++++++++++ .../pages/auth/MagicLinkAlreadyUsedPage.tsx | 39 ++++ .../src/pages/auth/MagicLinkExpiredPage.tsx | 39 ++++ .../src/pages/auth/VerifyMagicLinkPage.tsx | 101 +++++++++++ .../src/pages/auth/routes.ts | 51 ++++++ apps/compliance-portal/src/routes.tsx | 2 + packages/ui/src/v2/Dialog/Dialog.tsx | 31 ++++ packages/ui/src/v2/Dialog/DialogBody.tsx | 32 ++++ packages/ui/src/v2/Dialog/DialogClose.tsx | 30 ++++ .../ui/src/v2/Dialog/DialogDescription.tsx | 36 ++++ packages/ui/src/v2/Dialog/DialogFooter.tsx | 32 ++++ packages/ui/src/v2/Dialog/DialogHeader.tsx | 32 ++++ packages/ui/src/v2/Dialog/DialogPopup.tsx | 46 +++++ packages/ui/src/v2/Dialog/DialogSkeleton.tsx | 45 +++++ packages/ui/src/v2/Dialog/DialogTitle.tsx | 36 ++++ packages/ui/src/v2/Dialog/DialogTrigger.tsx | 30 ++++ packages/ui/src/v2/Dialog/variants.ts | 57 ++++++ 32 files changed, 1644 insertions(+), 10 deletions(-) create mode 100644 apps/compliance-portal/src/components/auth/LoginForm.tsx create mode 100644 apps/compliance-portal/src/components/auth/OIDCButton.tsx create mode 100644 apps/compliance-portal/src/components/auth/OIDCProviders.tsx create mode 100644 apps/compliance-portal/src/components/auth/SignInDialog.tsx create mode 100644 apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx create mode 100644 apps/compliance-portal/src/lib/auth/continueUrl.ts create mode 100644 apps/compliance-portal/src/lib/auth/signInDialogContext.ts create mode 100644 apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts create mode 100644 apps/compliance-portal/src/lib/auth/useSafeContinueUrl.ts create mode 100644 apps/compliance-portal/src/pages/auth/AuthLayout.tsx create mode 100644 apps/compliance-portal/src/pages/auth/AuthLayoutSkeleton.tsx create mode 100644 apps/compliance-portal/src/pages/auth/FullNamePage.tsx create mode 100644 apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx create mode 100644 apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx create mode 100644 apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx create mode 100644 apps/compliance-portal/src/pages/auth/routes.ts create mode 100644 packages/ui/src/v2/Dialog/Dialog.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogBody.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogClose.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogDescription.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogFooter.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogHeader.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogPopup.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogSkeleton.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogTitle.tsx create mode 100644 packages/ui/src/v2/Dialog/DialogTrigger.tsx create mode 100644 packages/ui/src/v2/Dialog/variants.ts diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 8abdfd02b..29f9a0cb2 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -15,6 +15,58 @@ "userMenu": { "signOut": "Sign out" }, + "common": { + "cancel": "Cancel", + "error": "Something went wrong" + }, + "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", + "placeholder": "John Doe", + "required": "Full name is required", + "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.", + "ndaRequired": "You need to sign the NDA before requesting access." + } + }, "home": { "heroTitle": "Trust at {{name}}.", "heroDescription": "Welcome to our Compliance Portal. Find our security documentation and certifications here.", diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 03fcd2d1c..10244333c 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -15,6 +15,58 @@ "userMenu": { "signOut": "Se déconnecter" }, + "common": { + "cancel": "Annuler", + "error": "Une erreur est survenue" + }, + "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", + "placeholder": "Jean Dupont", + "required": "Le nom complet est requis", + "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.", + "ndaRequired": "Vous devez signer l'accord de confidentialité avant de demander l'accès." + } + }, "home": { "heroTitle": "La confiance chez {{name}}.", "heroDescription": "Bienvenue dans notre Compliance Portal. Retrouvez ici notre documentation et nos certifications de sécurité.", diff --git a/apps/compliance-portal/src/components/TopBar/TopBar.tsx b/apps/compliance-portal/src/components/TopBar/TopBar.tsx index e1a85785b..18ce8f00a 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBar.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBar.tsx @@ -27,6 +27,9 @@ import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; import { Link as RouterLink, useLocation } from "react-router"; +import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl"; +import { useSignInDialog } from "#/lib/auth/signInDialogContext"; + import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql"; import { TopBarUserMenu } from "./TopBarUserMenu"; import { topBar } from "./variants"; @@ -64,6 +67,7 @@ export function TopBar({ queryKey }: TopBarProps) { const { t } = useTranslation(); const data = useFragment(topBarFragment, queryKey); const { pathname } = useLocation(); + const { openSignIn } = useSignInDialog(); const { currentTrustCenter } = data; const organizationName = currentTrustCenter.organization.name; @@ -109,7 +113,13 @@ export function TopBar({ queryKey }: TopBarProps) { ))} {data.viewer == null ? ( - ) diff --git a/apps/compliance-portal/src/components/auth/LoginForm.tsx b/apps/compliance-portal/src/components/auth/LoginForm.tsx new file mode 100644 index 000000000..dd2e3f877 --- /dev/null +++ b/apps/compliance-portal/src/components/auth/LoginForm.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { LoginFormMutation } from "./__generated__/LoginFormMutation.graphql"; +import { OIDCProviders } from "./OIDCProviders"; + +const RESEND_COOLDOWN_SECONDS = 60; + +const sendMagicLinkMutation = graphql` + mutation LoginFormMutation($input: SendMagicLinkInput!) { + sendMagicLink(input: $input) { + success + } + } +`; + +interface LoginFormProps { + // 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 LoginForm({ continueTo, onCancel }: LoginFormProps) { + const { t } = useTranslation(); + const toast = Toast.useToastManager(); + const [magicLinkSent, setMagicLinkSent] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS); + const intervalRef = useRef>(undefined); + + const [sendMagicLink, isSending] = useMutation( + 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 ( +
{ + handleSend(String(values.email ?? "")); + }} + > + + + + + + {t("auth.signIn.emailLabel")} + + + + {t("auth.signIn.emailRequired")} + + + {t("auth.signIn.emailInvalid")} + + + + {magicLinkSent && ( + + {t("auth.signIn.magicLinkSentNote")} + + )} + + + + + + +
+ ); +} diff --git a/apps/compliance-portal/src/components/auth/OIDCButton.tsx b/apps/compliance-portal/src/components/auth/OIDCButton.tsx new file mode 100644 index 000000000..ccd6e5814 --- /dev/null +++ b/apps/compliance-portal/src/components/auth/OIDCButton.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) => 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 ( + + ); +} diff --git a/apps/compliance-portal/src/components/auth/OIDCProviders.tsx b/apps/compliance-portal/src/components/auth/OIDCProviders.tsx new file mode 100644 index 000000000..f916d4c14 --- /dev/null +++ b/apps/compliance-portal/src/components/auth/OIDCProviders.tsx @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ + {t("auth.signIn.or")} + +
+ ); +} + +function OIDCProvidersContent({ continueTo }: OIDCProvidersProps) { + const data = useLazyLoadQuery(oidcProvidersQuery, {}); + + if (data.oidcProviders.length === 0) { + return null; + } + + return ( + <> +
+ {data.oidcProviders.map((provider, index) => ( + + ))} +
+ + + ); +} + +// 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 ( + console.error("Failed to load SSO providers", error)} + > + + + + + )} + > + + + + ); +} diff --git a/apps/compliance-portal/src/components/auth/SignInDialog.tsx b/apps/compliance-portal/src/components/auth/SignInDialog.tsx new file mode 100644 index 000000000..ac97d1fc8 --- /dev/null +++ b/apps/compliance-portal/src/components/auth/SignInDialog.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { LoginForm } from "./LoginForm"; + +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 login form. +export function SignInDialog({ open, onOpenChange, continueTo }: SignInDialogProps) { + const { t } = useTranslation(); + + return ( + + + + {t("auth.signIn.title")} + {t("auth.signIn.description")} + + onOpenChange(false)} /> + + + ); +} diff --git a/apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx b/apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx new file mode 100644 index 000000000..622473fc1 --- /dev/null +++ b/apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + + {children} + + + ); +} diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts new file mode 100644 index 000000000..cbbeb8e21 --- /dev/null +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { getPathPrefix } from "#/lib/http/pathPrefix"; + +// Marker appended to a post-auth `continue` URL so the portal fires the pending +// "request access" mutation once the user lands back authenticated. +export const REQUEST_ALL_PARAM = "request-all"; + +// Validates a `continue` target before we navigate to it. Only same-origin URLs +// under the portal's path prefix are accepted; anything else falls back to the +// portal home, so a crafted `?continue=` can never bounce the user off-site. +export function getSafeContinueUrl(param: string | null | undefined): string { + const prefix = getPathPrefix(); + const fallback = window.location.origin + (prefix || "/"); + + if (!param) { + return fallback; + } + + try { + const url = new URL(param, window.location.origin); + if ( + url.origin === window.location.origin + && url.pathname.startsWith(`${prefix}/`) + ) { + return window.location.origin + url.pathname + url.search; + } + } catch { + return fallback; + } + + return fallback; +} + +// Absolute URL of the current page with the request-all marker set, used as the +// `continue` target so the access request resumes after sign-in. +export function buildRequestAllContinueUrl(): string { + const url = new URL(window.location.href); + url.searchParams.set(REQUEST_ALL_PARAM, "true"); + return url.toString(); +} diff --git a/apps/compliance-portal/src/lib/auth/signInDialogContext.ts b/apps/compliance-portal/src/lib/auth/signInDialogContext.ts new file mode 100644 index 000000000..bb9860aaf --- /dev/null +++ b/apps/compliance-portal/src/lib/auth/signInDialogContext.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(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; +} diff --git a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts new file mode 100644 index 000000000..8f8f41b9e --- /dev/null +++ b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useSearchParams } from "react-router"; +import { graphql } from "relay-runtime"; + +import { + buildRequestAllContinueUrl, + REQUEST_ALL_PARAM, +} from "#/lib/auth/continueUrl"; +import { useMutation } from "#/lib/relay/useMutation"; + +import type { useResumeAccessRequestMutation } from "./__generated__/useResumeAccessRequestMutation.graphql"; + +const requestAllAccessesMutation = graphql` + mutation useResumeAccessRequestMutation { + requestAllAccesses { + trustCenterAccess { + id + } + } + } +`; + +// After a user signs in through the dialog, they land back on the page that +// carried the request-all marker. This hook fires the deferred +// `requestAllAccesses` mutation once (when authenticated), routes to the +// full-name gate when the backend asks for it, and clears the marker so a +// refresh never re-triggers it. +export function useResumeAccessRequest(isAuthenticated: boolean) { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const toast = Toast.useToastManager(); + const { t } = useTranslation(); + const firedRef = useRef(false); + + const [requestAllAccesses] = useMutation( + requestAllAccessesMutation, + { errorToast: false }, + ); + + const shouldResume + = isAuthenticated && searchParams.get(REQUEST_ALL_PARAM) === "true"; + + useEffect(() => { + if (!shouldResume || firedRef.current) { + return; + } + firedRef.current = true; + + // Drop the marker up front so a reload can't queue a second request. + searchParams.delete(REQUEST_ALL_PARAM); + setSearchParams(searchParams, { replace: true }); + + void requestAllAccesses({ + variables: {}, + onCompleted: (_response, errors) => { + const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; + + // The backend gates access behind a completed profile; send the user to + // the full-name step, preserving the marker so the request resumes. + if (code === "FULL_NAME_REQUIRED") { + const continueUrl = buildRequestAllContinueUrl(); + void navigate(`/full-name?continue=${encodeURIComponent(continueUrl)}`); + return; + } + + if (errors && errors.length > 0) { + toast.add({ + title: + code === "NDA_SIGNATURE_REQUIRED" + ? t("auth.errors.ndaRequired") + : t("auth.errors.requestFailed"), + type: "error", + }); + return; + } + + toast.add({ title: t("auth.requestAccess.success"), type: "success" }); + }, + onError: () => { + toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); + }, + // The awaitable wrapper rejects on failure; toasts are handled above, so + // swallow the rejection to avoid an unhandled promise. + }).catch(() => {}); + }, [ + shouldResume, + navigate, + requestAllAccesses, + searchParams, + setSearchParams, + t, + toast, + ]); +} diff --git a/apps/compliance-portal/src/lib/auth/useSafeContinueUrl.ts b/apps/compliance-portal/src/lib/auth/useSafeContinueUrl.ts new file mode 100644 index 000000000..d5374c053 --- /dev/null +++ b/apps/compliance-portal/src/lib/auth/useSafeContinueUrl.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { useMemo } from "react"; +import { useSearchParams } from "react-router"; + +import { getSafeContinueUrl } from "#/lib/auth/continueUrl"; + +// Reactive, validated `continue` URL from the current `?continue=` param. Falls +// back to the portal home when the param is absent or points off-site. +export function useSafeContinueUrl(): string { + const [searchParams] = useSearchParams(); + const param = searchParams.get("continue"); + + return useMemo(() => getSafeContinueUrl(param), [param]); +} diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx index d04cc51a3..47206e37c 100644 --- a/apps/compliance-portal/src/pages/MainLayout.tsx +++ b/apps/compliance-portal/src/pages/MainLayout.tsx @@ -25,11 +25,16 @@ import { Outlet } 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 type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql"; export const mainLayoutQuery = graphql` query MainLayoutQuery { + viewer { + __typename + } ...TopBar_query } `; @@ -42,16 +47,21 @@ export function MainLayout({ queryRef }: MainLayoutProps) { const { t } = useTranslation(); const data = usePreloadedQuery(mainLayoutQuery, queryRef); + // Resume a deferred "request access" once the user lands back authenticated. + useResumeAccessRequest(data.viewer != null); + return ( - // Bound the shell to the viewport so the TopBar and footer stay fixed and the - // page area scrolls on its own. Pages that fill the height (the document - // viewer) then scroll their own body while their toolbar stays put. -
- -
- + // Bound the shell to the viewport so the TopBar and footer stay fixed and the + // page area scrolls on its own. Pages that fill the height (the document + // viewer) then scroll their own body while their toolbar stays put. + +
+ +
+ +
+
- -
+ ); } diff --git a/apps/compliance-portal/src/pages/auth/AuthLayout.tsx b/apps/compliance-portal/src/pages/auth/AuthLayout.tsx new file mode 100644 index 000000000..7d55fedac --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/AuthLayout.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { 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). +export default function AuthLayout() { + return ( +
+ + + +
+ ); +} diff --git a/apps/compliance-portal/src/pages/auth/AuthLayoutSkeleton.tsx b/apps/compliance-portal/src/pages/auth/AuthLayoutSkeleton.tsx new file mode 100644 index 000000000..079b4173e --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/AuthLayoutSkeleton.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { CardSkeleton } from "@probo/ui/src/v2/Card/CardSkeleton"; + +// Loading fallback for the standalone auth routes while their chunk resolves. +export function AuthLayoutSkeleton() { + return ( +
+ +
+ ); +} diff --git a/apps/compliance-portal/src/pages/auth/FullNamePage.tsx b/apps/compliance-portal/src/pages/auth/FullNamePage.tsx new file mode 100644 index 000000000..a6310e64d --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/FullNamePage.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { TextField } from "@probo/ui/src/v2/form/TextField"; +import { Heading } from "@probo/ui/src/v2/typography/Heading"; +import { useTranslation } from "react-i18next"; +import { graphql } from "relay-runtime"; + +import { useSafeContinueUrl } from "#/lib/auth/useSafeContinueUrl"; +import { useMutation } from "#/lib/relay/useMutation"; + +import type { FullNamePageMutation } from "./__generated__/FullNamePageMutation.graphql"; + +const updateFullNameMutation = graphql` + mutation FullNamePageMutation($input: UpdateFullNameInput!) { + updateFullName(input: $input) { + success + } + } +`; + +// Post-sign-in gate collecting the user's display name when the backend reports +// FULL_NAME_REQUIRED, then forwarding to the validated continue URL. +export default function FullNamePage() { + const { t } = useTranslation(); + const toast = Toast.useToastManager(); + const safeContinueUrl = useSafeContinueUrl(); + + const [updateFullName, isUpdating] = useMutation( + updateFullNameMutation, + { errorToast: false }, + ); + + const handleSubmit = (fullName: string) => { + void updateFullName({ + variables: { input: { fullName } }, + onCompleted: (_response, errors) => { + const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; + + if (code === "ALREADY_AUTHENTICATED" || (errors == null || errors.length === 0)) { + window.location.href = safeContinueUrl; + return; + } + + toast.add({ title: t("auth.errors.fullNameFailed"), type: "error" }); + }, + onError: () => { + toast.add({ title: t("auth.errors.fullNameFailed"), type: "error" }); + }, + }).catch(() => {}); + }; + + return ( +
+ + {t("auth.fullName.title")} + + +
{ + handleSubmit(String(values.fullName ?? "")); + }} + > + + + {t("auth.fullName.label")} + + + + {t("auth.fullName.required")} + + + {t("auth.fullName.tooShort")} + + + + +
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx b/apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx new file mode 100644 index 000000000..1bb46be38 --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ {t("auth.magicLinkAlreadyUsed.title")} + {t("auth.magicLinkAlreadyUsed.description")} + + {t("auth.backToPortal")} + +
+ ); +} diff --git a/apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx b/apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx new file mode 100644 index 000000000..34c8d7b24 --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ {t("auth.magicLinkExpired.title")} + {t("auth.magicLinkExpired.description")} + + {t("auth.backToPortal")} + +
+ ); +} diff --git a/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx b/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx new file mode 100644 index 000000000..fc627d41e --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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( + 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") { + window.location.href = getSafeContinueUrl(null); + 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 ( +
+ {t("auth.verify.title")} + {t("auth.verify.description")} +
+ ); +} diff --git a/apps/compliance-portal/src/pages/auth/routes.ts b/apps/compliance-portal/src/pages/auth/routes.ts new file mode 100644 index 000000000..79dc993ec --- /dev/null +++ b/apps/compliance-portal/src/pages/auth/routes.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { lazy } from "@probo/react-lazy"; +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. +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[]; diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx index 6453462ec..a82da7eba 100644 --- a/apps/compliance-portal/src/routes.tsx +++ b/apps/compliance-portal/src/routes.tsx @@ -25,6 +25,7 @@ import { createBrowserRouter } from "react-router"; import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary"; import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary"; import { getPathPrefix } from "#/lib/http/pathPrefix"; +import { authRoutes } from "#/pages/auth/routes"; import { documentRoutes } from "#/pages/documents/routes"; import { HomePageSkeleton } from "#/pages/HomePageSkeleton"; import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton"; @@ -64,6 +65,7 @@ const routes = [ }, ], }, + ...authRoutes, ] satisfies AppRoute[]; // The portal is served under a /trust/{slug} path prefix (or a bare custom diff --git a/packages/ui/src/v2/Dialog/Dialog.tsx b/packages/ui/src/v2/Dialog/Dialog.tsx new file mode 100644 index 000000000..f2a966020 --- /dev/null +++ b/packages/ui/src/v2/Dialog/Dialog.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 as BaseDialog } from "@base-ui/react/dialog"; +import type { ComponentProps } from "react"; + +// Root of the modal dialog (Radix "Dialog"). Controlled the same way as Base +// UI's Dialog: `open` / `onOpenChange`, or left uncontrolled. See +// contrib/claude/ui.md. +export type DialogProps = ComponentProps; + +export function Dialog(props: DialogProps) { + return ; +} diff --git a/packages/ui/src/v2/Dialog/DialogBody.tsx b/packages/ui/src/v2/Dialog/DialogBody.tsx new file mode 100644 index 000000000..3fd742b42 --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogBody.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { ComponentProps } from "react"; + +import { dialog } from "./variants"; + +// Content region between the header and footer, carrying the dialog's horizontal +// padding. +export function DialogBody(props: ComponentProps<"div">) { + const { className, ...rest } = props; + const { body } = dialog(); + + return
; +} diff --git a/packages/ui/src/v2/Dialog/DialogClose.tsx b/packages/ui/src/v2/Dialog/DialogClose.tsx new file mode 100644 index 000000000..92f814617 --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogClose.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 as BaseDialog } from "@base-ui/react/dialog"; +import type { ComponentProps } from "react"; + +// Closes the dialog. Pass `render` to reuse an existing control (e.g. a Button) +// as the close action, per Base UI's polymorphism. +export type DialogCloseProps = ComponentProps; + +export function DialogClose(props: DialogCloseProps) { + return ; +} diff --git a/packages/ui/src/v2/Dialog/DialogDescription.tsx b/packages/ui/src/v2/Dialog/DialogDescription.tsx new file mode 100644 index 000000000..1cddf473b --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogDescription.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 as BaseDialog } from "@base-ui/react/dialog"; +import type { ComponentProps } from "react"; + +import { dialog } from "./variants"; + +// Accessible dialog description (wires `aria-describedby` via Base UI). +export type DialogDescriptionProps = Omit, "className"> & { + className?: string; +}; + +export function DialogDescription(props: DialogDescriptionProps) { + const { className, ...rest } = props; + const { description } = dialog(); + + return ; +} diff --git a/packages/ui/src/v2/Dialog/DialogFooter.tsx b/packages/ui/src/v2/Dialog/DialogFooter.tsx new file mode 100644 index 000000000..0e763b2f5 --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogFooter.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { ComponentProps } from "react"; + +import { dialog } from "./variants"; + +// Footer region for dialog actions, right-aligned with the dialog's horizontal +// padding. +export function DialogFooter(props: ComponentProps<"div">) { + const { className, ...rest } = props; + const { footer } = dialog(); + + return
; +} diff --git a/packages/ui/src/v2/Dialog/DialogHeader.tsx b/packages/ui/src/v2/Dialog/DialogHeader.tsx new file mode 100644 index 000000000..a2f8cabda --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogHeader.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { ComponentProps } from "react"; + +import { dialog } from "./variants"; + +// Header region grouping the title and description with the dialog's horizontal +// padding. +export function DialogHeader(props: ComponentProps<"div">) { + const { className, ...rest } = props; + const { header } = dialog(); + + return
; +} diff --git a/packages/ui/src/v2/Dialog/DialogPopup.tsx b/packages/ui/src/v2/Dialog/DialogPopup.tsx new file mode 100644 index 000000000..e00a31dec --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogPopup.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 as BaseDialog } from "@base-ui/react/dialog"; +import type { ComponentProps } from "react"; + +import { dialog } from "./variants"; + +export type DialogPopupProps + = & Omit, "className"> + & { + className?: string; + }; + +// Portal + dimmed backdrop + centered, styled popup frame. Children compose the +// header / body / footer regions. +export function DialogPopup(props: DialogPopupProps) { + const { className, children, ...popupProps } = props; + const { backdrop, popup } = dialog(); + + return ( + + + + {children} + + + ); +} diff --git a/packages/ui/src/v2/Dialog/DialogSkeleton.tsx b/packages/ui/src/v2/Dialog/DialogSkeleton.tsx new file mode 100644 index 000000000..3cc37aebf --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogSkeleton.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { TextSkeleton } from "../typography/TextSkeleton"; + +import { dialog, dialogSkeleton } from "./variants"; + +// Loading placeholder matching the dialog frame (header + body lines + footer), +// importing only variants and skeleton primitives — never Base UI. +export function DialogSkeleton() { + const { header, body, footer } = dialog(); + + return ( +
+
+ + +
+
+ +
+
+ + +
+
+ ); +} diff --git a/packages/ui/src/v2/Dialog/DialogTitle.tsx b/packages/ui/src/v2/Dialog/DialogTitle.tsx new file mode 100644 index 000000000..c8af3237a --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogTitle.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 as BaseDialog } from "@base-ui/react/dialog"; +import type { ComponentProps } from "react"; + +import { dialog } from "./variants"; + +// Accessible dialog title (wires `aria-labelledby` via Base UI). +export type DialogTitleProps = Omit, "className"> & { + className?: string; +}; + +export function DialogTitle(props: DialogTitleProps) { + const { className, ...rest } = props; + const { title } = dialog(); + + return ; +} diff --git a/packages/ui/src/v2/Dialog/DialogTrigger.tsx b/packages/ui/src/v2/Dialog/DialogTrigger.tsx new file mode 100644 index 000000000..5eaa0efda --- /dev/null +++ b/packages/ui/src/v2/Dialog/DialogTrigger.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 as BaseDialog } from "@base-ui/react/dialog"; +import type { ComponentProps } from "react"; + +// Opens the dialog. Pass `render` to use an existing control (e.g. a Button) as +// the trigger, per Base UI's polymorphism — do not clone children by hand. +export type DialogTriggerProps = ComponentProps; + +export function DialogTrigger(props: DialogTriggerProps) { + return ; +} diff --git a/packages/ui/src/v2/Dialog/variants.ts b/packages/ui/src/v2/Dialog/variants.ts new file mode 100644 index 000000000..38abe2fd3 --- /dev/null +++ b/packages/ui/src/v2/Dialog/variants.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { tv } from "tailwind-variants/lite"; + +// Modal dialog (Radix "Dialog" over Base UI). The popup is centered and gets +// vertical padding + a 16px gap; each region (header, body, footer) carries its +// own horizontal padding so a full-bleed body slot stays possible. +export const dialog = tv({ + slots: { + backdrop: [ + "fixed inset-0 z-50 bg-sand-12/40", + "transition-opacity duration-150", + "data-starting-style:opacity-0 data-ending-style:opacity-0", + ], + popup: [ + "fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2", + "flex w-[calc(100vw-2rem)] max-w-[600px] flex-col gap-4", + "max-h-[calc(100vh-2rem)] overflow-y-auto overflow-x-clip", + "rounded-5 border border-sand-6 bg-sand-1 py-6 shadow-6 outline-none", + "transition-all duration-150", + "data-starting-style:scale-95 data-starting-style:opacity-0", + "data-ending-style:scale-95 data-ending-style:opacity-0", + ], + header: "flex flex-col gap-2 px-6", + title: "text-4 font-medium text-sand-12", + description: "text-2 text-sand-11", + body: "px-6", + footer: "flex items-center justify-end gap-3 px-6", + }, +}); + +// Static frame matching the dialog popup, without the interactive positioning, +// so a placeholder can render before Base UI (and the content) loads. +export const dialogSkeleton = tv({ + base: [ + "flex w-full max-w-[600px] flex-col gap-4", + "rounded-5 border border-sand-6 bg-sand-1 py-6 shadow-6", + ], +});