Add sign-in dialog and request-access gating
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é <emile@probo.com>
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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
|
||||
? (
|
||||
<Button variant="solid" color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<LockSimpleIcon />}
|
||||
onClick={() => openSignIn({ continueTo: buildRequestAllContinueUrl() })}
|
||||
>
|
||||
{t("topBar.getAccess")}
|
||||
</Button>
|
||||
)
|
||||
|
||||
168
apps/compliance-portal/src/components/auth/LoginForm.tsx
Normal file
168
apps/compliance-portal/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
// 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 { 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<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
const [sendMagicLink, isSending] = useMutation<LoginFormMutation>(
|
||||
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>
|
||||
);
|
||||
}
|
||||
75
apps/compliance-portal/src/components/auth/OIDCButton.tsx
Normal file
75
apps/compliance-portal/src/components/auth/OIDCButton.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
94
apps/compliance-portal/src/components/auth/OIDCProviders.tsx
Normal file
94
apps/compliance-portal/src/components/auth/OIDCProviders.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
53
apps/compliance-portal/src/components/auth/SignInDialog.tsx
Normal file
53
apps/compliance-portal/src/components/auth/SignInDialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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 { 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPopup className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("auth.signIn.title")}</DialogTitle>
|
||||
<DialogDescription>{t("auth.signIn.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<LoginForm continueTo={continueTo} onCancel={() => onOpenChange(false)} />
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
53
apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx
Normal file
53
apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
59
apps/compliance-portal/src/lib/auth/continueUrl.ts
Normal file
59
apps/compliance-portal/src/lib/auth/continueUrl.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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 { 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();
|
||||
}
|
||||
43
apps/compliance-portal/src/lib/auth/signInDialogContext.ts
Normal file
43
apps/compliance-portal/src/lib/auth/signInDialogContext.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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;
|
||||
}
|
||||
117
apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts
Normal file
117
apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// 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 { 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<useResumeAccessRequestMutation>(
|
||||
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,
|
||||
]);
|
||||
}
|
||||
33
apps/compliance-portal/src/lib/auth/useSafeContinueUrl.ts
Normal file
33
apps/compliance-portal/src/lib/auth/useSafeContinueUrl.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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 { 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]);
|
||||
}
|
||||
@@ -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>(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.
|
||||
<div className="flex h-dvh flex-col bg-sand-2">
|
||||
<TopBar queryKey={data} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
// 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.
|
||||
<SignInDialogProvider>
|
||||
<div className="flex h-dvh flex-col bg-sand-2">
|
||||
<TopBar queryKey={data} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
<PoweredBy label={t("footer.poweredBy")} />
|
||||
</div>
|
||||
<PoweredBy label={t("footer.poweredBy")} />
|
||||
</div>
|
||||
</SignInDialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
34
apps/compliance-portal/src/pages/auth/AuthLayout.tsx
Normal file
34
apps/compliance-portal/src/pages/auth/AuthLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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 { 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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-sand-2 p-4">
|
||||
<Card size={2} className="w-full max-w-md">
|
||||
<Outlet />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/compliance-portal/src/pages/auth/AuthLayoutSkeleton.tsx
Normal file
30
apps/compliance-portal/src/pages/auth/AuthLayoutSkeleton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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 { CardSkeleton } from "@probo/ui/src/v2/Card/CardSkeleton";
|
||||
|
||||
// Loading fallback for the standalone auth routes while their chunk resolves.
|
||||
export function AuthLayoutSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-sand-2 p-4">
|
||||
<CardSkeleton size={2} className="h-48 w-full max-w-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/compliance-portal/src/pages/auth/FullNamePage.tsx
Normal file
112
apps/compliance-portal/src/pages/auth/FullNamePage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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 { 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<FullNamePageMutation>(
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Heading level={1} size={5} align="center">
|
||||
{t("auth.fullName.title")}
|
||||
</Heading>
|
||||
|
||||
<Form
|
||||
className="flex flex-col gap-6"
|
||||
onFormSubmit={(values) => {
|
||||
handleSubmit(String(values.fullName ?? ""));
|
||||
}}
|
||||
>
|
||||
<Field.Root name="fullName" className="flex flex-col gap-1.5">
|
||||
<Field.Label className="text-1 font-medium text-sand-12">
|
||||
{t("auth.fullName.label")}
|
||||
</Field.Label>
|
||||
<TextField
|
||||
type="text"
|
||||
name="fullName"
|
||||
required
|
||||
minLength={2}
|
||||
placeholder={t("auth.fullName.placeholder")}
|
||||
/>
|
||||
<Field.Error className="text-1 text-red-11" match="valueMissing">
|
||||
{t("auth.fullName.required")}
|
||||
</Field.Error>
|
||||
<Field.Error className="text-1 text-red-11" match="tooShort">
|
||||
{t("auth.fullName.tooShort")}
|
||||
</Field.Error>
|
||||
</Field.Root>
|
||||
|
||||
<Button type="submit" variant="solid" color="neutral" highContrast loading={isUpdating}>
|
||||
{t("auth.fullName.submit")}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
101
apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx
Normal file
101
apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// 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") {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
51
apps/compliance-portal/src/pages/auth/routes.ts
Normal file
51
apps/compliance-portal/src/pages/auth/routes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 { 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[];
|
||||
@@ -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
|
||||
|
||||
31
packages/ui/src/v2/Dialog/Dialog.tsx
Normal file
31
packages/ui/src/v2/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 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<typeof BaseDialog.Root>;
|
||||
|
||||
export function Dialog(props: DialogProps) {
|
||||
return <BaseDialog.Root {...props} />;
|
||||
}
|
||||
32
packages/ui/src/v2/Dialog/DialogBody.tsx
Normal file
32
packages/ui/src/v2/Dialog/DialogBody.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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 { 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 <div className={body({ className })} {...rest} />;
|
||||
}
|
||||
30
packages/ui/src/v2/Dialog/DialogClose.tsx
Normal file
30
packages/ui/src/v2/Dialog/DialogClose.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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 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<typeof BaseDialog.Close>;
|
||||
|
||||
export function DialogClose(props: DialogCloseProps) {
|
||||
return <BaseDialog.Close {...props} />;
|
||||
}
|
||||
36
packages/ui/src/v2/Dialog/DialogDescription.tsx
Normal file
36
packages/ui/src/v2/Dialog/DialogDescription.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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 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<ComponentProps<typeof BaseDialog.Description>, "className"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DialogDescription(props: DialogDescriptionProps) {
|
||||
const { className, ...rest } = props;
|
||||
const { description } = dialog();
|
||||
|
||||
return <BaseDialog.Description className={description({ className })} {...rest} />;
|
||||
}
|
||||
32
packages/ui/src/v2/Dialog/DialogFooter.tsx
Normal file
32
packages/ui/src/v2/Dialog/DialogFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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 { 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 <div className={footer({ className })} {...rest} />;
|
||||
}
|
||||
32
packages/ui/src/v2/Dialog/DialogHeader.tsx
Normal file
32
packages/ui/src/v2/Dialog/DialogHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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 { 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 <div className={header({ className })} {...rest} />;
|
||||
}
|
||||
46
packages/ui/src/v2/Dialog/DialogPopup.tsx
Normal file
46
packages/ui/src/v2/Dialog/DialogPopup.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 as BaseDialog } from "@base-ui/react/dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { dialog } from "./variants";
|
||||
|
||||
export type DialogPopupProps
|
||||
= & Omit<ComponentProps<typeof BaseDialog.Popup>, "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 (
|
||||
<BaseDialog.Portal>
|
||||
<BaseDialog.Backdrop className={backdrop()} />
|
||||
<BaseDialog.Popup className={popup({ className })} {...popupProps}>
|
||||
{children}
|
||||
</BaseDialog.Popup>
|
||||
</BaseDialog.Portal>
|
||||
);
|
||||
}
|
||||
45
packages/ui/src/v2/Dialog/DialogSkeleton.tsx
Normal file
45
packages/ui/src/v2/Dialog/DialogSkeleton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 { 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 (
|
||||
<div className={dialogSkeleton()} aria-hidden>
|
||||
<div className={header()}>
|
||||
<TextSkeleton size={4} className="w-48" />
|
||||
<TextSkeleton size={2} className="w-72" />
|
||||
</div>
|
||||
<div className={body()}>
|
||||
<TextSkeleton size={2} className="w-full" />
|
||||
</div>
|
||||
<div className={footer()}>
|
||||
<TextSkeleton size={2} className="w-20" />
|
||||
<TextSkeleton size={2} className="w-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
packages/ui/src/v2/Dialog/DialogTitle.tsx
Normal file
36
packages/ui/src/v2/Dialog/DialogTitle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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 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<ComponentProps<typeof BaseDialog.Title>, "className"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DialogTitle(props: DialogTitleProps) {
|
||||
const { className, ...rest } = props;
|
||||
const { title } = dialog();
|
||||
|
||||
return <BaseDialog.Title className={title({ className })} {...rest} />;
|
||||
}
|
||||
30
packages/ui/src/v2/Dialog/DialogTrigger.tsx
Normal file
30
packages/ui/src/v2/Dialog/DialogTrigger.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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 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<typeof BaseDialog.Trigger>;
|
||||
|
||||
export function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <BaseDialog.Trigger {...props} />;
|
||||
}
|
||||
57
packages/ui/src/v2/Dialog/variants.ts
Normal file
57
packages/ui/src/v2/Dialog/variants.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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 { 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",
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user