Put locale in compliance portal URLs
Path-segment locales make each language crawlable with self canonical and hreflang, while identity.locale persists an explicit choice without client storage or cookie banners. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="{{if .HtmlLang}}{{.HtmlLang}}{{else}}en{{end}}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -41,6 +41,9 @@
|
|||||||
<meta property="og:description" content="{{.Description}}">
|
<meta property="og:description" content="{{.Description}}">
|
||||||
<meta property="og:url" content="{{.OGURL}}">
|
<meta property="og:url" content="{{.OGURL}}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
|
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
||||||
|
{{range .Hreflang}}<link rel="alternate" hreflang="{{.Lang}}" href="{{.Href}}">
|
||||||
|
{{end}}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="text-sand-12 bg-sand-1">
|
<body class="text-sand-12 bg-sand-1">
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Vorherige Seite",
|
"previous": "Vorherige Seite",
|
||||||
"next": "Nächste Seite"
|
"next": "Nächste Seite"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Sprache",
|
||||||
|
"updateFailed": "Sprache konnte nicht aktualisiert werden",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "Sie sehen diese Seite auf {{language}}.",
|
||||||
|
"switchToMine": "Zu {{language}} wechseln",
|
||||||
|
"useThis": "{{language}} als meine Sprache verwenden",
|
||||||
|
"dismiss": "Schließen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
"signOutFailed": "Cannot sign out"
|
"signOutFailed": "Cannot sign out"
|
||||||
},
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"error": "Something went wrong",
|
"error": "Something went wrong",
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Página anterior",
|
"previous": "Página anterior",
|
||||||
"next": "Página siguiente"
|
"next": "Página siguiente"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Idioma",
|
||||||
|
"updateFailed": "No se pudo actualizar el idioma",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "Estás viendo esta página en {{language}}.",
|
||||||
|
"switchToMine": "Cambiar a {{language}}",
|
||||||
|
"useThis": "Usar {{language}} como mi idioma",
|
||||||
|
"dismiss": "Descartar"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"error": "Une erreur est survenue",
|
"error": "Une erreur est survenue",
|
||||||
"previousPage": "Page pr\u00e9c\u00e9dente",
|
"previousPage": "Page précédente",
|
||||||
"nextPage": "Page suivante",
|
"nextPage": "Page suivante",
|
||||||
"pageOf": "Page {{current}} sur {{total}}",
|
"pageOf": "Page {{current}} sur {{total}}",
|
||||||
"zoomOut": "Zoom arri\u00e8re",
|
"zoomOut": "Zoom arrière",
|
||||||
"zoomIn": "Zoom avant"
|
"zoomIn": "Zoom avant"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Page précédente",
|
"previous": "Page précédente",
|
||||||
"next": "Page suivante"
|
"next": "Page suivante"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Langue",
|
||||||
|
"updateFailed": "Impossible de mettre à jour la langue",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "Vous consultez cette page en {{language}}.",
|
||||||
|
"switchToMine": "Passer en {{language}}",
|
||||||
|
"useThis": "Utiliser {{language}} comme ma langue",
|
||||||
|
"dismiss": "Ignorer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Halaman sebelumnya",
|
"previous": "Halaman sebelumnya",
|
||||||
"next": "Halaman berikutnya"
|
"next": "Halaman berikutnya"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Pagina precedente",
|
"previous": "Pagina precedente",
|
||||||
"next": "Pagina successiva"
|
"next": "Pagina successiva"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "前のページ",
|
"previous": "前のページ",
|
||||||
"next": "次のページ"
|
"next": "次のページ"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "이전 페이지",
|
"previous": "이전 페이지",
|
||||||
"next": "다음 페이지"
|
"next": "다음 페이지"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Poprzednia strona",
|
"previous": "Poprzednia strona",
|
||||||
"next": "Następna strona"
|
"next": "Następna strona"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Página anterior",
|
"previous": "Página anterior",
|
||||||
"next": "Página seguinte"
|
"next": "Página seguinte"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Önceki sayfa",
|
"previous": "Önceki sayfa",
|
||||||
"next": "Sonraki sayfa"
|
"next": "Sonraki sayfa"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "Попередня сторінка",
|
"previous": "Попередня сторінка",
|
||||||
"next": "Наступна сторінка"
|
"next": "Наступна сторінка"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,15 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "上一页",
|
"previous": "上一页",
|
||||||
"next": "下一页"
|
"next": "下一页"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"label": "Language",
|
||||||
|
"updateFailed": "Couldn't update language",
|
||||||
|
"mismatch": {
|
||||||
|
"message": "You're viewing this page in {{language}}.",
|
||||||
|
"switchToMine": "Switch to {{language}}",
|
||||||
|
"useThis": "Use {{language}} as my language",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
apps/compliance-portal/src/components/LocaleMismatchCallout.tsx
Normal file
119
apps/compliance-portal/src/components/LocaleMismatchCallout.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// 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 { GlobeIcon, XIcon } from "@phosphor-icons/react";
|
||||||
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
|
import { Callout } from "@probo/ui/src/v2/Callout/Callout";
|
||||||
|
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isUrlLocale,
|
||||||
|
URL_LOCALE_LABELS,
|
||||||
|
} from "#/lib/i18n/locale";
|
||||||
|
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
|
||||||
|
import { useLocale } from "#/lib/i18n/useLocale";
|
||||||
|
import { useUpdateLocale } from "#/lib/i18n/useUpdateLocale";
|
||||||
|
|
||||||
|
import type { LocaleMismatchCallout_identity$key } from "./__generated__/LocaleMismatchCallout_identity.graphql";
|
||||||
|
|
||||||
|
const localeMismatchCalloutFragment = graphql`
|
||||||
|
fragment LocaleMismatchCallout_identity on Identity {
|
||||||
|
locale
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface LocaleMismatchCalloutProps {
|
||||||
|
identityKey: LocaleMismatchCallout_identity$key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft notice when the URL locale differs from the signed-in identity preference.
|
||||||
|
// Dismissed state is React-only (no localStorage/cookies).
|
||||||
|
export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const identity = useFragment(localeMismatchCalloutFragment, identityKey);
|
||||||
|
const urlLocale = useLocale();
|
||||||
|
const [changeLocale, isChanging] = useChangeLocale();
|
||||||
|
const [updateLocale, isUpdating] = useUpdateLocale();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
const savedLocale = isUrlLocale(identity.locale) ? identity.locale : null;
|
||||||
|
|
||||||
|
if (dismissed || savedLocale == null || savedLocale === urlLocale) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlLabel = URL_LOCALE_LABELS[urlLocale];
|
||||||
|
const savedLabel = URL_LOCALE_LABELS[savedLocale];
|
||||||
|
const busy = isChanging || isUpdating;
|
||||||
|
|
||||||
|
const switchToSaved = () => {
|
||||||
|
void changeLocale(savedLocale, { persist: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const adoptUrlLocale = () => {
|
||||||
|
void updateLocale(urlLocale).then(() => setDismissed(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-sand-6 bg-sand-2 px-4 py-3">
|
||||||
|
<Callout size={1} variant="soft" color="gold" icon={<GlobeIcon weight="fill" />}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="min-w-0 flex-1 text-2 text-sand-12">
|
||||||
|
{t("locale.mismatch.message", { language: urlLabel })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size={1}
|
||||||
|
variant="soft"
|
||||||
|
color="neutral"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={switchToSaved}
|
||||||
|
>
|
||||||
|
{t("locale.mismatch.switchToMine", { language: savedLabel })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size={1}
|
||||||
|
variant="solid"
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
disabled={busy}
|
||||||
|
onClick={adoptUrlLocale}
|
||||||
|
>
|
||||||
|
{t("locale.mismatch.useThis", { language: urlLabel })}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size={1}
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
aria-label={t("locale.mismatch.dismiss")}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { Link } from "react-router";
|
|||||||
|
|
||||||
import { ComplianceArticleItem } from "#/components/ComplianceArticleItem/ComplianceArticleItem";
|
import { ComplianceArticleItem } from "#/components/ComplianceArticleItem/ComplianceArticleItem";
|
||||||
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
|
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
|
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
|
||||||
|
|
||||||
@@ -44,10 +45,11 @@ interface MailingListUpdateListItemProps {
|
|||||||
// no category, so we show the title and relative date with a generic icon.
|
// no category, so we show the title and relative date with a generic icon.
|
||||||
export function MailingListUpdateListItem({ updateKey }: MailingListUpdateListItemProps) {
|
export function MailingListUpdateListItem({ updateKey }: MailingListUpdateListItemProps) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const update = useFragment(mailingListUpdateListItemFragment, updateKey);
|
const update = useFragment(mailingListUpdateListItemFragment, updateKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/updates/${update.id}`} className="block transition-colors hover:bg-sand-a2">
|
<Link to={localizedPath(`/updates/${update.id}`)} className="block transition-colors hover:bg-sand-a2">
|
||||||
<ComplianceArticleItem
|
<ComplianceArticleItem
|
||||||
icon={<NewspaperIcon weight="light" />}
|
icon={<NewspaperIcon weight="light" />}
|
||||||
title={update.title}
|
title={update.title}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { InlineErrorCard } from "#/components/errors/InlineErrorCard";
|
|||||||
import { HomeSection } from "#/components/HomeSection/HomeSection";
|
import { HomeSection } from "#/components/HomeSection/HomeSection";
|
||||||
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
||||||
import { dotPatternStyle } from "#/components/MediaTile/variants";
|
import { dotPatternStyle } from "#/components/MediaTile/variants";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import type { RecentUpdatesSection_compliancePortal$key } from "./__generated__/RecentUpdatesSection_compliancePortal.graphql";
|
import type { RecentUpdatesSection_compliancePortal$key } from "./__generated__/RecentUpdatesSection_compliancePortal.graphql";
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ export function RecentUpdatesSection({ compliancePortalKey }: RecentUpdatesSecti
|
|||||||
|
|
||||||
function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSectionProps) {
|
function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const data = useFragment(recentUpdatesSectionFragment, compliancePortalKey);
|
const data = useFragment(recentUpdatesSectionFragment, compliancePortalKey);
|
||||||
const updates = data.updates.edges.map(edge => edge.node);
|
const updates = data.updates.edges.map(edge => edge.node);
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSecti
|
|||||||
<HomeSection
|
<HomeSection
|
||||||
title={t("home.sections.recentUpdates")}
|
title={t("home.sections.recentUpdates")}
|
||||||
action={(
|
action={(
|
||||||
<Link to="/updates" variant="ghost" color="neutral" size={2}>
|
<Link to={localizedPath("/updates")} variant="ghost" color="neutral" size={2}>
|
||||||
{t("home.recentUpdates.viewAll")}
|
{t("home.recentUpdates.viewAll")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 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 { GlobeIcon } from "@phosphor-icons/react";
|
||||||
|
import { Select } from "@probo/ui/src/v2/Select/Select";
|
||||||
|
import { SelectItem } from "@probo/ui/src/v2/Select/SelectItem";
|
||||||
|
import { SelectPopup } from "@probo/ui/src/v2/Select/SelectPopup";
|
||||||
|
import { SelectTrigger } from "@probo/ui/src/v2/Select/SelectTrigger";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
URL_LOCALE_LABELS,
|
||||||
|
URL_LOCALES,
|
||||||
|
type UrlLocale,
|
||||||
|
} from "#/lib/i18n/locale";
|
||||||
|
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
|
||||||
|
import { useLocale } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
|
interface LocaleSelectProps {
|
||||||
|
// Persist the choice on the signed-in identity when true.
|
||||||
|
persist?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact locale control for the top bar (guest and mobile). Uses the v2 Select
|
||||||
|
// (Figma Select / ghost + globe) rather than a custom dropdown.
|
||||||
|
export function LocaleSelect({ persist = false }: LocaleSelectProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const locale = useLocale();
|
||||||
|
const [changeLocale, isChanging] = useChangeLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={locale}
|
||||||
|
onValueChange={(value: UrlLocale | null) => {
|
||||||
|
if (value == null || value === locale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void changeLocale(value, { persist });
|
||||||
|
}}
|
||||||
|
disabled={isChanging}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size={1}
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t("locale.label")}
|
||||||
|
className="w-auto min-w-0"
|
||||||
|
>
|
||||||
|
{(value: UrlLocale | null) => (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<GlobeIcon className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
{value ? URL_LOCALE_LABELS[value] : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectPopup>
|
||||||
|
{URL_LOCALES.map(code => (
|
||||||
|
<SelectItem key={code} value={code}>
|
||||||
|
{URL_LOCALE_LABELS[code]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectPopup>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,8 +28,10 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
import { Link as RouterLink, useLocation } from "react-router";
|
import { Link as RouterLink, useLocation } from "react-router";
|
||||||
|
|
||||||
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
|
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
|
||||||
|
import { LocaleSelect } from "./LocaleSelect";
|
||||||
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
||||||
import { TopBarMobileNav } from "./TopBarMobileNav";
|
import { TopBarMobileNav } from "./TopBarMobileNav";
|
||||||
import { TopBarUserMenu } from "./TopBarUserMenu";
|
import { TopBarUserMenu } from "./TopBarUserMenu";
|
||||||
@@ -61,17 +63,19 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
const data = useFragment(topBarFragment, queryKey);
|
const data = useFragment(topBarFragment, queryKey);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
|
|
||||||
const { currentCompliancePortal } = data;
|
const { currentCompliancePortal } = data;
|
||||||
const title = currentCompliancePortal.title;
|
const title = currentCompliancePortal.title;
|
||||||
const logoUrl = currentCompliancePortal.themedLogoUrl ?? undefined;
|
const logoUrl = currentCompliancePortal.themedLogoUrl ?? undefined;
|
||||||
|
|
||||||
const slots = topBar();
|
const slots = topBar();
|
||||||
|
const homePath = localizedPath("/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={slots.bar()}>
|
<header className={slots.bar()}>
|
||||||
<div className={slots.inner()}>
|
<div className={slots.inner()}>
|
||||||
<RouterLink to="/" className={slots.brand()}>
|
<RouterLink to={homePath} className={slots.brand()}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={1}
|
size={1}
|
||||||
variant="soft"
|
variant="soft"
|
||||||
@@ -92,20 +96,25 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
<div className={slots.spacer()} />
|
<div className={slots.spacer()} />
|
||||||
|
|
||||||
<nav className={slots.nav()}>
|
<nav className={slots.nav()}>
|
||||||
{TOP_BAR_NAV_ITEMS.map(item => (
|
{TOP_BAR_NAV_ITEMS.map((item) => {
|
||||||
|
const to = localizedPath(item.to);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={to}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
size={2}
|
size={2}
|
||||||
active={isActive(pathname, item.to)}
|
active={isActive(pathname, to)}
|
||||||
>
|
>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{data.viewer == null
|
{data.viewer == null
|
||||||
? (
|
? (
|
||||||
|
<>
|
||||||
|
<LocaleSelect />
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
@@ -117,6 +126,7 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
>
|
>
|
||||||
{t("topBar.getAccess")}
|
{t("topBar.getAccess")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
: <TopBarUserMenu identityKey={data.viewer} />}
|
: <TopBarUserMenu identityKey={data.viewer} />}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ import { useLocation } from "react-router";
|
|||||||
|
|
||||||
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
||||||
import { useSignOut } from "#/lib/auth/useSignOut";
|
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||||
|
|
||||||
import type { TopBarMobileNav_identity$key } from "./__generated__/TopBarMobileNav_identity.graphql";
|
import type { TopBarMobileNav_identity$key } from "./__generated__/TopBarMobileNav_identity.graphql";
|
||||||
|
import { LocaleSelect } from "./LocaleSelect";
|
||||||
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
||||||
import { topBar } from "./variants";
|
import { topBar } from "./variants";
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
|||||||
const [signOut, isSigningOut] = useSignOut();
|
const [signOut, isSigningOut] = useSignOut();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const identity = useFragment(topBarMobileNavFragment, identityKey);
|
const identity = useFragment(topBarMobileNavFragment, identityKey);
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
|
|
||||||
const barSlots = topBar();
|
const barSlots = topBar();
|
||||||
|
|
||||||
@@ -119,24 +122,30 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
|||||||
|
|
||||||
<DrawerBody>
|
<DrawerBody>
|
||||||
<nav className="contents">
|
<nav className="contents">
|
||||||
{TOP_BAR_NAV_ITEMS.map(item => (
|
{TOP_BAR_NAV_ITEMS.map((item) => {
|
||||||
|
const to = localizedPath(item.to);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={to}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
size={3}
|
size={3}
|
||||||
active={isActive(pathname, item.to)}
|
active={isActive(pathname, to)}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
>
|
>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
|
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
|
<div className="w-full pb-2">
|
||||||
|
<LocaleSelect persist={identity != null} />
|
||||||
|
</div>
|
||||||
{identity == null
|
{identity == null
|
||||||
? (
|
? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,19 +18,37 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { BellIcon, BellRingingIcon, CaretDownIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
|
import {
|
||||||
|
BellIcon,
|
||||||
|
BellRingingIcon,
|
||||||
|
CaretDownIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
SignOutIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
||||||
import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown";
|
import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown";
|
||||||
import { DropdownGroup } from "@probo/ui/src/v2/Dropdown/DropdownGroup";
|
import { DropdownGroup } from "@probo/ui/src/v2/Dropdown/DropdownGroup";
|
||||||
import { DropdownItem } from "@probo/ui/src/v2/Dropdown/DropdownItem";
|
import { DropdownItem } from "@probo/ui/src/v2/Dropdown/DropdownItem";
|
||||||
import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup";
|
import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup";
|
||||||
|
import { DropdownRadioGroup } from "@probo/ui/src/v2/Dropdown/DropdownRadioGroup";
|
||||||
|
import { DropdownRadioItem } from "@probo/ui/src/v2/Dropdown/DropdownRadioItem";
|
||||||
import { DropdownSeparator } from "@probo/ui/src/v2/Dropdown/DropdownSeparator";
|
import { DropdownSeparator } from "@probo/ui/src/v2/Dropdown/DropdownSeparator";
|
||||||
|
import { DropdownSubmenu } from "@probo/ui/src/v2/Dropdown/DropdownSubmenu";
|
||||||
|
import { DropdownSubmenuTrigger } from "@probo/ui/src/v2/Dropdown/DropdownSubmenuTrigger";
|
||||||
import { DropdownTrigger } from "@probo/ui/src/v2/Dropdown/DropdownTrigger";
|
import { DropdownTrigger } from "@probo/ui/src/v2/Dropdown/DropdownTrigger";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
import { useSignOut } from "#/lib/auth/useSignOut";
|
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||||
|
import {
|
||||||
|
URL_LOCALE_LABELS,
|
||||||
|
URL_LOCALES,
|
||||||
|
type UrlLocale,
|
||||||
|
} from "#/lib/i18n/locale";
|
||||||
|
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
|
||||||
|
import { useLocale } from "#/lib/i18n/useLocale";
|
||||||
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||||
|
|
||||||
import type { TopBarUserMenu_identity$key } from "./__generated__/TopBarUserMenu_identity.graphql";
|
import type { TopBarUserMenu_identity$key } from "./__generated__/TopBarUserMenu_identity.graphql";
|
||||||
@@ -40,6 +58,7 @@ const topBarUserMenuFragment = graphql`
|
|||||||
fragment TopBarUserMenu_identity on Identity {
|
fragment TopBarUserMenu_identity on Identity {
|
||||||
fullName
|
fullName
|
||||||
email
|
email
|
||||||
|
locale
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -52,6 +71,8 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
|||||||
const identity = useFragment(topBarUserMenuFragment, identityKey);
|
const identity = useFragment(topBarUserMenuFragment, identityKey);
|
||||||
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
||||||
const [signOut, isSigningOut] = useSignOut();
|
const [signOut, isSigningOut] = useSignOut();
|
||||||
|
const locale = useLocale();
|
||||||
|
const [changeLocale, isChangingLocale] = useChangeLocale();
|
||||||
|
|
||||||
// New users may not have set a full name yet; fall back to the email.
|
// New users may not have set a full name yet; fall back to the email.
|
||||||
const displayName = identity.fullName.trim() || identity.email;
|
const displayName = identity.fullName.trim() || identity.email;
|
||||||
@@ -103,6 +124,25 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
|||||||
>
|
>
|
||||||
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownSubmenu>
|
||||||
|
<DropdownSubmenuTrigger iconStart={<GlobeIcon />}>
|
||||||
|
{t("locale.label")}
|
||||||
|
</DropdownSubmenuTrigger>
|
||||||
|
<DropdownPopup side="left" align="start">
|
||||||
|
<DropdownRadioGroup
|
||||||
|
value={locale}
|
||||||
|
onValueChange={(value: string) => {
|
||||||
|
void changeLocale(value as UrlLocale, { persist: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{URL_LOCALES.map(code => (
|
||||||
|
<DropdownRadioItem key={code} value={code} disabled={isChangingLocale}>
|
||||||
|
{URL_LOCALE_LABELS[code]}
|
||||||
|
</DropdownRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownRadioGroup>
|
||||||
|
</DropdownPopup>
|
||||||
|
</DropdownSubmenu>
|
||||||
<DropdownSeparator />
|
<DropdownSeparator />
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
color="error"
|
color="error"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Link } from "@probo/ui/src/v2/Button/Link";
|
|||||||
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
import { NotFoundError } from "#/lib/relay/errors";
|
import { NotFoundError } from "#/lib/relay/errors";
|
||||||
|
|
||||||
interface ErrorContent {
|
interface ErrorContent {
|
||||||
@@ -64,6 +65,7 @@ interface GlobalErrorProps {
|
|||||||
// actions. Used by the route boundaries (root + page).
|
// actions. Used by the route boundaries (root + page).
|
||||||
export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorProps) {
|
export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const { code, titleKey, descriptionKey } = resolveContent(error);
|
const { code, titleKey, descriptionKey } = resolveContent(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +76,7 @@ export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorPro
|
|||||||
description={t(descriptionKey)}
|
description={t(descriptionKey)}
|
||||||
actions={(
|
actions={(
|
||||||
<>
|
<>
|
||||||
<Link to="/" variant="solid" color="neutral" highContrast size={2}>
|
<Link to={localizedPath("/")} variant="solid" color="neutral" highContrast size={2}>
|
||||||
{t("errors.actions.backToCompliancePortal")}
|
{t("errors.actions.backToCompliancePortal")}
|
||||||
</Link>
|
</Link>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay";
|
import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay";
|
||||||
|
|
||||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||||
|
import { localizedPath, resolveUrlLocale, type UrlLocale } from "#/lib/i18n/locale";
|
||||||
|
|
||||||
// Markers appended to a post-auth `continue` URL so the portal fires the pending
|
// Markers appended to a post-auth `continue` URL so the portal fires the pending
|
||||||
// "request access" mutation once the user lands back authenticated. `request-all`
|
// "request access" mutation once the user lands back authenticated. `request-all`
|
||||||
@@ -41,7 +42,8 @@ export const SUBSCRIBE_PARAM = "subscribe";
|
|||||||
// portal home, so a crafted `?continue=` can never bounce the user off-site.
|
// portal home, so a crafted `?continue=` can never bounce the user off-site.
|
||||||
export function getSafeContinueUrl(param: string | null | undefined): string {
|
export function getSafeContinueUrl(param: string | null | undefined): string {
|
||||||
const prefix = getPathPrefix();
|
const prefix = getPathPrefix();
|
||||||
const fallback = window.location.origin + (prefix || "/");
|
const localeHome = localizedPath(resolveUrlLocale(), "/");
|
||||||
|
const fallback = window.location.origin + (prefix || "") + localeHome;
|
||||||
|
|
||||||
if (!param) {
|
if (!param) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -49,10 +51,10 @@ export function getSafeContinueUrl(param: string | null | undefined): string {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(param, window.location.origin);
|
const url = new URL(param, window.location.origin);
|
||||||
if (
|
const underPrefix = prefix === ""
|
||||||
url.origin === window.location.origin
|
? url.pathname.startsWith("/")
|
||||||
&& url.pathname.startsWith(`${prefix}/`)
|
: url.pathname === prefix || url.pathname.startsWith(`${prefix}/`);
|
||||||
) {
|
if (url.origin === window.location.origin && underPrefix) {
|
||||||
return window.location.origin + url.pathname + url.search;
|
return window.location.origin + url.pathname + url.search;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,12 +101,16 @@ export function buildSubscribeContinueUrl(): string {
|
|||||||
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
||||||
// by the route boundaries and the request-access flows so all gate handling
|
// by the route boundaries and the request-access flows so all gate handling
|
||||||
// stays in one place.
|
// stays in one place.
|
||||||
export function gateRedirectPath(error: unknown, continueUrl: string): string | null {
|
export function gateRedirectPath(
|
||||||
|
error: unknown,
|
||||||
|
continueUrl: string,
|
||||||
|
locale: UrlLocale = resolveUrlLocale(),
|
||||||
|
): string | null {
|
||||||
if (error instanceof FullNameRequiredError) {
|
if (error instanceof FullNameRequiredError) {
|
||||||
return `/full-name?continue=${encodeURIComponent(continueUrl)}`;
|
return `${localizedPath(locale, "/full-name")}?continue=${encodeURIComponent(continueUrl)}`;
|
||||||
}
|
}
|
||||||
if (error instanceof NDASignatureRequiredError) {
|
if (error instanceof NDASignatureRequiredError) {
|
||||||
return `/nda?continue=${encodeURIComponent(continueUrl)}`;
|
return `${localizedPath(locale, "/nda")}?continue=${encodeURIComponent(continueUrl)}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
REQUEST_FILE_PARAM,
|
REQUEST_FILE_PARAM,
|
||||||
REQUEST_REPORT_PARAM,
|
REQUEST_REPORT_PARAM,
|
||||||
} from "#/lib/auth/continueUrl";
|
} from "#/lib/auth/continueUrl";
|
||||||
|
import { useLocale } from "#/lib/i18n/useLocale";
|
||||||
import { useMutation } from "#/lib/relay/useMutation";
|
import { useMutation } from "#/lib/relay/useMutation";
|
||||||
|
|
||||||
import type { useResumeAccessRequest_documentMutation } from "./__generated__/useResumeAccessRequest_documentMutation.graphql";
|
import type { useResumeAccessRequest_documentMutation } from "./__generated__/useResumeAccessRequest_documentMutation.graphql";
|
||||||
@@ -105,6 +106,7 @@ const requestFileMutation = graphql`
|
|||||||
export function useResumeAccessRequest(isAuthenticated: boolean) {
|
export function useResumeAccessRequest(isAuthenticated: boolean) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const locale = useLocale();
|
||||||
const toast = Toast.useToastManager();
|
const toast = Toast.useToastManager();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const firedRef = useRef(false);
|
const firedRef = useRef(false);
|
||||||
@@ -155,7 +157,7 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
|
|||||||
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
|
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
const gatePath = gateRedirectPath(error, continueUrl);
|
const gatePath = gateRedirectPath(error, continueUrl, locale);
|
||||||
if (gatePath) {
|
if (gatePath) {
|
||||||
void navigate(gatePath);
|
void navigate(gatePath);
|
||||||
return;
|
return;
|
||||||
@@ -208,6 +210,7 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [
|
}, [
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
locale,
|
||||||
navigate,
|
navigate,
|
||||||
requestAllAccesses,
|
requestAllAccesses,
|
||||||
requestDocumentAccess,
|
requestDocumentAccess,
|
||||||
|
|||||||
@@ -22,8 +22,25 @@ import { createInstance } from "i18next";
|
|||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
import { DEFAULT_NAMESPACE, globBackend } from "./backend";
|
import { DEFAULT_NAMESPACE, globBackend } from "./backend";
|
||||||
|
import {
|
||||||
|
isUrlLocale,
|
||||||
|
urlLocaleToLanguage,
|
||||||
|
} from "./locale";
|
||||||
import { resolveLanguage, SUPPORTED_LANGUAGES } from "./resolveLanguage";
|
import { resolveLanguage, SUPPORTED_LANGUAGES } from "./resolveLanguage";
|
||||||
|
|
||||||
|
function initialLanguage() {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
const trustMatch = pathname.match(/^\/trust\/[^/]+(\/.*)?$/);
|
||||||
|
const appPath = trustMatch
|
||||||
|
? (trustMatch[1] && trustMatch[1].length > 0 ? trustMatch[1] : "/")
|
||||||
|
: pathname;
|
||||||
|
const first = appPath.split("/").filter(Boolean)[0];
|
||||||
|
if (isUrlLocale(first)) {
|
||||||
|
return urlLocaleToLanguage(first);
|
||||||
|
}
|
||||||
|
return resolveLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
// Build a dedicated instance rather than mutating i18next's global singleton.
|
// Build a dedicated instance rather than mutating i18next's global singleton.
|
||||||
// Initializing it through initReactI18next still registers it as the instance
|
// Initializing it through initReactI18next still registers it as the instance
|
||||||
// react-i18next's hooks read from, so no I18nextProvider is required.
|
// react-i18next's hooks read from, so no I18nextProvider is required.
|
||||||
@@ -33,7 +50,7 @@ void i18n
|
|||||||
.use(globBackend)
|
.use(globBackend)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
lng: resolveLanguage(),
|
lng: initialLanguage(),
|
||||||
fallbackLng: "en-US",
|
fallbackLng: "en-US",
|
||||||
supportedLngs: SUPPORTED_LANGUAGES,
|
supportedLngs: SUPPORTED_LANGUAGES,
|
||||||
load: "currentOnly",
|
load: "currentOnly",
|
||||||
|
|||||||
149
apps/compliance-portal/src/lib/i18n/locale.ts
Normal file
149
apps/compliance-portal/src/lib/i18n/locale.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// 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 {
|
||||||
|
resolveLanguage,
|
||||||
|
type SupportedLanguage,
|
||||||
|
} from "./resolveLanguage";
|
||||||
|
|
||||||
|
// Short tags used in URL path segments (and persisted on Identity.locale).
|
||||||
|
export const URL_LOCALES = [
|
||||||
|
"en",
|
||||||
|
"fr",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"id",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"tr",
|
||||||
|
"uk",
|
||||||
|
"zh",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type UrlLocale = (typeof URL_LOCALES)[number];
|
||||||
|
|
||||||
|
const DEFAULT_URL_LOCALE: UrlLocale = "en";
|
||||||
|
|
||||||
|
const LANGUAGE_TO_URL_LOCALE: Record<SupportedLanguage, UrlLocale> = {
|
||||||
|
"en-US": "en",
|
||||||
|
"fr-FR": "fr",
|
||||||
|
"de-DE": "de",
|
||||||
|
"es-ES": "es",
|
||||||
|
"id-ID": "id",
|
||||||
|
"it-IT": "it",
|
||||||
|
"ja-JP": "ja",
|
||||||
|
"ko-KR": "ko",
|
||||||
|
"pl-PL": "pl",
|
||||||
|
"pt-PT": "pt",
|
||||||
|
"tr-TR": "tr",
|
||||||
|
"uk-UA": "uk",
|
||||||
|
"zh-CN": "zh",
|
||||||
|
};
|
||||||
|
|
||||||
|
const URL_LOCALE_TO_LANGUAGE = Object.fromEntries(
|
||||||
|
Object.entries(LANGUAGE_TO_URL_LOCALE).map(([language, locale]) => [locale, language]),
|
||||||
|
) as Record<UrlLocale, SupportedLanguage>;
|
||||||
|
|
||||||
|
// Native-language labels for the locale switcher (not translated via i18n).
|
||||||
|
export const URL_LOCALE_LABELS: Record<UrlLocale, string> = {
|
||||||
|
en: "English",
|
||||||
|
fr: "Français",
|
||||||
|
de: "Deutsch",
|
||||||
|
es: "Español",
|
||||||
|
id: "Bahasa Indonesia",
|
||||||
|
it: "Italiano",
|
||||||
|
ja: "日本語",
|
||||||
|
ko: "한국어",
|
||||||
|
pl: "Polski",
|
||||||
|
pt: "Português",
|
||||||
|
tr: "Türkçe",
|
||||||
|
uk: "Українська",
|
||||||
|
zh: "中文",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isUrlLocale(value: string | undefined | null): value is UrlLocale {
|
||||||
|
return value != null && (URL_LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function languageToUrlLocale(language: SupportedLanguage): UrlLocale {
|
||||||
|
return LANGUAGE_TO_URL_LOCALE[language];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlLocaleToLanguage(locale: UrlLocale): SupportedLanguage {
|
||||||
|
return URL_LOCALE_TO_LANGUAGE[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUrlLocale(): UrlLocale {
|
||||||
|
return languageToUrlLocale(resolveLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUrlLocale(value: string | undefined | null): UrlLocale | null {
|
||||||
|
if (!isUrlLocale(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultUrlLocale(): UrlLocale {
|
||||||
|
return DEFAULT_URL_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix an app-absolute path with a locale segment. Paths must start with "/".
|
||||||
|
// "/" becomes "/en"; "/documents" becomes "/en/documents".
|
||||||
|
export function localizedPath(locale: UrlLocale, path: string): string {
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
throw new Error(`localizedPath expects an absolute path, got: ${path}`);
|
||||||
|
}
|
||||||
|
if (path === "/") {
|
||||||
|
return `/${locale}`;
|
||||||
|
}
|
||||||
|
return `/${locale}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap the locale prefix on a basename-relative pathname (e.g. "/en/documents"
|
||||||
|
// → "/fr/documents"). Paths without a valid locale prefix are treated as
|
||||||
|
// unprefixed and get the new locale prepended.
|
||||||
|
export function replaceLocaleInPathname(pathname: string, locale: UrlLocale): string {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return `/${locale}`;
|
||||||
|
}
|
||||||
|
if (isUrlLocale(segments[0])) {
|
||||||
|
segments[0] = locale;
|
||||||
|
return `/${segments.join("/")}`;
|
||||||
|
}
|
||||||
|
return `/${locale}/${segments.join("/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip a leading locale segment from a basename-relative pathname.
|
||||||
|
export function stripLocaleFromPathname(pathname: string): string {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
if (isUrlLocale(segments[0])) {
|
||||||
|
const rest = segments.slice(1);
|
||||||
|
return rest.length === 0 ? "/" : `/${rest.join("/")}`;
|
||||||
|
}
|
||||||
|
return pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||||
|
}
|
||||||
64
apps/compliance-portal/src/lib/i18n/localeRedirect.ts
Normal file
64
apps/compliance-portal/src/lib/i18n/localeRedirect.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// 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 { redirect, type LoaderFunctionArgs } from "react-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isUrlLocale,
|
||||||
|
localizedPath,
|
||||||
|
resolveUrlLocale,
|
||||||
|
} from "./locale";
|
||||||
|
|
||||||
|
// Ensures `/:lang` is a supported short tag. Legacy unprefixed URLs
|
||||||
|
// (/documents) and unknown two-letter tags are rewritten to a guessed locale.
|
||||||
|
export function localeLayoutLoader({ request }: LoaderFunctionArgs) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const appPath = stripBasename(url.pathname);
|
||||||
|
const segments = appPath.split("/").filter(Boolean);
|
||||||
|
const first = segments[0];
|
||||||
|
|
||||||
|
if (isUrlLocale(first)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guess = resolveUrlLocale();
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
throw redirect(`/${guess}${url.search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown two-letter tag (e.g. /xx/documents) → drop it and keep the rest.
|
||||||
|
if (/^[a-z]{2}$/.test(first)) {
|
||||||
|
const rest = segments.slice(1);
|
||||||
|
const path = rest.length === 0 ? "/" : `/${rest.join("/")}`;
|
||||||
|
throw redirect(localizedPath(guess, path) + url.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First segment is a real route (documents, updates, …) — prefix locale.
|
||||||
|
throw redirect(localizedPath(guess, appPath) + url.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBasename(pathname: string): string {
|
||||||
|
const match = pathname.match(/^\/trust\/[^/]+(\/.*)?$/);
|
||||||
|
if (match) {
|
||||||
|
return match[1] && match[1].length > 0 ? match[1] : "/";
|
||||||
|
}
|
||||||
|
return pathname || "/";
|
||||||
|
}
|
||||||
56
apps/compliance-portal/src/lib/i18n/useChangeLocale.ts
Normal file
56
apps/compliance-portal/src/lib/i18n/useChangeLocale.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// 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 { useCallback } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
replaceLocaleInPathname,
|
||||||
|
type UrlLocale,
|
||||||
|
} from "./locale";
|
||||||
|
import { useLocale } from "./useLocale";
|
||||||
|
import { useUpdateLocale } from "./useUpdateLocale";
|
||||||
|
|
||||||
|
interface ChangeLocaleOptions {
|
||||||
|
// When true, also persist the locale on the signed-in identity.
|
||||||
|
persist?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the same path under a new locale; optionally persist for signed-in users.
|
||||||
|
export function useChangeLocale() {
|
||||||
|
const currentLocale = useLocale();
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [updateLocale, isUpdating] = useUpdateLocale();
|
||||||
|
|
||||||
|
const changeLocale = useCallback(async (
|
||||||
|
locale: UrlLocale,
|
||||||
|
options: ChangeLocaleOptions = {},
|
||||||
|
) => {
|
||||||
|
if (options.persist) {
|
||||||
|
await updateLocale(locale);
|
||||||
|
}
|
||||||
|
if (locale !== currentLocale) {
|
||||||
|
void navigate(replaceLocaleInPathname(pathname, locale) + search);
|
||||||
|
}
|
||||||
|
}, [currentLocale, navigate, pathname, search, updateLocale]);
|
||||||
|
|
||||||
|
return [changeLocale, isUpdating] as const;
|
||||||
|
}
|
||||||
44
apps/compliance-portal/src/lib/i18n/useLocale.ts
Normal file
44
apps/compliance-portal/src/lib/i18n/useLocale.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// 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 { useParams } from "react-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultUrlLocale,
|
||||||
|
isUrlLocale,
|
||||||
|
localizedPath,
|
||||||
|
type UrlLocale,
|
||||||
|
} from "./locale";
|
||||||
|
|
||||||
|
// Current URL locale from the `/:lang` route param. Falls back to English when
|
||||||
|
// the param is missing (should not happen under the locale layout).
|
||||||
|
export function useLocale(): UrlLocale {
|
||||||
|
const { lang } = useParams();
|
||||||
|
if (isUrlLocale(lang)) {
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
return defaultUrlLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a locale-prefixed app path for the active URL locale.
|
||||||
|
export function useLocalizedPath() {
|
||||||
|
const locale = useLocale();
|
||||||
|
return (path: string) => localizedPath(locale, path);
|
||||||
|
}
|
||||||
55
apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts
Normal file
55
apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// 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 { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { graphql } from "react-relay";
|
||||||
|
|
||||||
|
import { useMutation } from "#/lib/relay/useMutation";
|
||||||
|
|
||||||
|
import type { useUpdateLocaleMutation } from "./__generated__/useUpdateLocaleMutation.graphql";
|
||||||
|
import type { UrlLocale } from "./locale";
|
||||||
|
|
||||||
|
const updateLocaleMutation = graphql`
|
||||||
|
mutation useUpdateLocaleMutation($input: UpdateLocaleInput!) {
|
||||||
|
updateLocale(input: $input) {
|
||||||
|
identity {
|
||||||
|
id
|
||||||
|
locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Persists the viewer's preferred UI locale on their identity.
|
||||||
|
export function useUpdateLocale() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [commit, isUpdating] = useMutation<useUpdateLocaleMutation>(updateLocaleMutation, {
|
||||||
|
errorToast: t("locale.updateFailed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLocale = useCallback(async (locale: UrlLocale) => {
|
||||||
|
await commit({
|
||||||
|
variables: { input: { locale } },
|
||||||
|
});
|
||||||
|
}, [commit]);
|
||||||
|
|
||||||
|
return [updateLocale, isUpdating] as const;
|
||||||
|
}
|
||||||
48
apps/compliance-portal/src/pages/LocaleLayout.tsx
Normal file
48
apps/compliance-portal/src/pages/LocaleLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// 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 { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Outlet, useParams } from "react-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isUrlLocale,
|
||||||
|
urlLocaleToLanguage,
|
||||||
|
} from "#/lib/i18n/locale";
|
||||||
|
|
||||||
|
// Syncs i18next and <html lang> with the `/:lang` route param. Locale validation
|
||||||
|
// and unprefixed redirects happen in the route loader before this mounts.
|
||||||
|
export function LocaleLayout() {
|
||||||
|
const { lang } = useParams();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUrlLocale(lang)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const language = urlLocaleToLanguage(lang);
|
||||||
|
if (i18n.language !== language) {
|
||||||
|
void i18n.changeLanguage(language);
|
||||||
|
}
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}, [lang, i18n]);
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import type { PreloadedQuery } from "react-relay";
|
|||||||
import { graphql, usePreloadedQuery } from "react-relay";
|
import { graphql, usePreloadedQuery } from "react-relay";
|
||||||
import { Outlet, useMatch } from "react-router";
|
import { Outlet, useMatch } from "react-router";
|
||||||
|
|
||||||
|
import { LocaleMismatchCallout } from "#/components/LocaleMismatchCallout";
|
||||||
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
|
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
|
||||||
import { TopBar } from "#/components/TopBar/TopBar";
|
import { TopBar } from "#/components/TopBar/TopBar";
|
||||||
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
|
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
|
||||||
@@ -34,6 +35,7 @@ export const mainLayoutQuery = graphql`
|
|||||||
query MainLayoutQuery {
|
query MainLayoutQuery {
|
||||||
viewer {
|
viewer {
|
||||||
__typename
|
__typename
|
||||||
|
...LocaleMismatchCallout_identity
|
||||||
}
|
}
|
||||||
...TopBar_query
|
...TopBar_query
|
||||||
...SubscribeDialogProvider_query
|
...SubscribeDialogProvider_query
|
||||||
@@ -47,7 +49,7 @@ interface MainLayoutProps {
|
|||||||
export function MainLayout({ queryRef }: MainLayoutProps) {
|
export function MainLayout({ queryRef }: MainLayoutProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
||||||
const isDocumentViewer = useMatch("documents/:alias") != null;
|
const isDocumentViewer = useMatch("/:lang/documents/:alias") != null;
|
||||||
|
|
||||||
// Resume a deferred "request access" once the user lands back authenticated.
|
// Resume a deferred "request access" once the user lands back authenticated.
|
||||||
useResumeAccessRequest(data.viewer != null);
|
useResumeAccessRequest(data.viewer != null);
|
||||||
@@ -65,6 +67,9 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TopBar queryKey={data} />
|
<TopBar queryKey={data} />
|
||||||
|
{data.viewer != null
|
||||||
|
? <LocaleMismatchCallout identityKey={data.viewer} />
|
||||||
|
: null}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDocumentViewer
|
isDocumentViewer
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import { Text } from "@probo/ui/src/v2/typography/Text";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
// Catch-all page for portal paths that match no route, so an unknown URL renders
|
// Catch-all page for portal paths that match no route, so an unknown URL renders
|
||||||
// an explicit not-found state inside the layout instead of an empty body.
|
// an explicit not-found state inside the layout instead of an empty body.
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
@@ -39,7 +41,7 @@ export default function NotFoundPage() {
|
|||||||
<Text size={2} color="neutral">
|
<Text size={2} color="neutral">
|
||||||
{t("notFound.description")}
|
{t("notFound.description")}
|
||||||
</Text>
|
</Text>
|
||||||
<Link to="/" variant="soft" color="neutral" highContrast size={2}>
|
<Link to={localizedPath("/")} variant="soft" color="neutral" highContrast size={2}>
|
||||||
{t("notFound.backHome")}
|
{t("notFound.backHome")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import { useRequestReportAccess } from "../_lib/useAccessRequest";
|
import { useRequestReportAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
||||||
@@ -50,6 +52,7 @@ interface AuditReportListItemProps {
|
|||||||
// access action linking to the viewer when authorized. Renders nothing when the
|
// access action linking to the viewer when authorized. Renders nothing when the
|
||||||
// audit has no report file.
|
// audit has no report file.
|
||||||
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const audit = useFragment(auditReportListItemFragment, auditKey);
|
const audit = useFragment(auditReportListItemFragment, auditKey);
|
||||||
const report = audit.reportFile;
|
const report = audit.reportFile;
|
||||||
// Hook must run unconditionally; the empty id is never used when there is no
|
// Hook must run unconditionally; the empty id is never used when there is no
|
||||||
@@ -66,7 +69,7 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
|||||||
meta={report.fileName}
|
meta={report.fileName}
|
||||||
isAuthorized={report.isUserAuthorized}
|
isAuthorized={report.isUserAuthorized}
|
||||||
requested={report.access?.status === "REQUESTED"}
|
requested={report.access?.status === "REQUESTED"}
|
||||||
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
|
viewHref={localizedPath(`/documents/${encodeURIComponent(report.alias ?? report.id)}`)}
|
||||||
onGetAccess={requestAccess}
|
onGetAccess={requestAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import { useRequestFileAccess } from "../_lib/useAccessRequest";
|
import { useRequestFileAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql";
|
import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql";
|
||||||
@@ -45,6 +47,7 @@ interface CompliancePortalFileListItemProps {
|
|||||||
// A single uploaded trust-center file entry: name, its category, and an access
|
// A single uploaded trust-center file entry: name, its category, and an access
|
||||||
// action linking to the viewer when authorized.
|
// action linking to the viewer when authorized.
|
||||||
export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileListItemProps) {
|
export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileListItemProps) {
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const file = useFragment(compliancePortalFileListItemFragment, fileKey);
|
const file = useFragment(compliancePortalFileListItemFragment, fileKey);
|
||||||
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
|
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi
|
|||||||
meta={file.category}
|
meta={file.category}
|
||||||
isAuthorized={file.isUserAuthorized}
|
isAuthorized={file.isUserAuthorized}
|
||||||
requested={file.access?.status === "REQUESTED"}
|
requested={file.access?.status === "REQUESTED"}
|
||||||
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
|
viewHref={localizedPath(`/documents/${encodeURIComponent(file.alias ?? file.id)}`)}
|
||||||
onGetAccess={requestAccess}
|
onGetAccess={requestAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import { useRequestDocumentAccess } from "../_lib/useAccessRequest";
|
import { useRequestDocumentAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
||||||
@@ -47,6 +49,7 @@ interface DocumentListItemProps {
|
|||||||
// linking to the viewer when authorized.
|
// linking to the viewer when authorized.
|
||||||
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const document = useFragment(documentListItemFragment, documentKey);
|
const document = useFragment(documentListItemFragment, documentKey);
|
||||||
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
|
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
|||||||
meta={t(`types.${document.documentType}`)}
|
meta={t(`types.${document.documentType}`)}
|
||||||
isAuthorized={document.isUserAuthorized}
|
isAuthorized={document.isUserAuthorized}
|
||||||
requested={document.access?.status === "REQUESTED"}
|
requested={document.access?.status === "REQUESTED"}
|
||||||
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
|
viewHref={localizedPath(`/documents/${encodeURIComponent(document.alias ?? document.id)}`)}
|
||||||
onGetAccess={requestAccess}
|
onGetAccess={requestAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
|
import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ interface DocumentViewerProps {
|
|||||||
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) {
|
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
const toast = Toast.useToastManager();
|
const toast = Toast.useToastManager();
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
|
|
||||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
@@ -104,7 +106,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
|
|||||||
<div className={slots.root()}>
|
<div className={slots.root()}>
|
||||||
<HeaderBand flushBottomSpace>
|
<HeaderBand flushBottomSpace>
|
||||||
<div className={slots.header()}>
|
<div className={slots.header()}>
|
||||||
<Link to="/documents" variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
<Link to={localizedPath("/documents")} variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
||||||
{t("viewer.back")}
|
{t("viewer.back")}
|
||||||
</Link>
|
</Link>
|
||||||
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
|
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
REQUEST_FILE_PARAM,
|
REQUEST_FILE_PARAM,
|
||||||
REQUEST_REPORT_PARAM,
|
REQUEST_REPORT_PARAM,
|
||||||
} from "#/lib/auth/continueUrl";
|
} from "#/lib/auth/continueUrl";
|
||||||
|
import { useLocale } from "#/lib/i18n/useLocale";
|
||||||
import { useMutation } from "#/lib/relay/useMutation";
|
import { useMutation } from "#/lib/relay/useMutation";
|
||||||
|
|
||||||
import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql";
|
import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql";
|
||||||
@@ -100,6 +101,7 @@ const fileMutation = graphql`
|
|||||||
// URL so it resumes once the gate is cleared. Everything else is a generic toast.
|
// URL so it resumes once the gate is cleared. Everything else is a generic toast.
|
||||||
function useAccessRequestHandlers(param: string, id: string) {
|
function useAccessRequestHandlers(param: string, id: string) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const locale = useLocale();
|
||||||
const toast = Toast.useToastManager();
|
const toast = Toast.useToastManager();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ function useAccessRequestHandlers(param: string, id: string) {
|
|||||||
}
|
}
|
||||||
// Full-name / NDA gate: deep-link to the gate page, preserving the
|
// Full-name / NDA gate: deep-link to the gate page, preserving the
|
||||||
// marker so the request resumes afterwards.
|
// marker so the request resumes afterwards.
|
||||||
const gatePath = gateRedirectPath(error, continueUrl);
|
const gatePath = gateRedirectPath(error, continueUrl, locale);
|
||||||
if (gatePath) {
|
if (gatePath) {
|
||||||
void navigate(gatePath);
|
void navigate(gatePath);
|
||||||
return;
|
return;
|
||||||
@@ -131,7 +133,7 @@ function useAccessRequestHandlers(param: string, id: string) {
|
|||||||
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
|
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[navigate, toast, t, param, id],
|
[navigate, locale, toast, t, param, id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { Navigate, useSearchParams } from "react-router";
|
|||||||
|
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
import { getSafeContinueUrl } from "#/lib/auth/continueUrl";
|
import { getSafeContinueUrl } from "#/lib/auth/continueUrl";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
import { useMutation } from "#/lib/relay/useMutation";
|
import { useMutation } from "#/lib/relay/useMutation";
|
||||||
import { PdfPreview, type PdfPreviewHandle } from "#/pages/documents/_components/PdfPreview";
|
import { PdfPreview, type PdfPreviewHandle } from "#/pages/documents/_components/PdfPreview";
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ interface NDAPageProps {
|
|||||||
// continue URL. Reached from the route boundary on NDA_SIGNATURE_REQUIRED.
|
// continue URL. Reached from the route boundary on NDA_SIGNATURE_REQUIRED.
|
||||||
export function NDAPage({ queryRef }: NDAPageProps) {
|
export function NDAPage({ queryRef }: NDAPageProps) {
|
||||||
const { t } = useTranslation("nda");
|
const { t } = useTranslation("nda");
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const documentViewedRef = useRef(false);
|
const documentViewedRef = useRef(false);
|
||||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||||
@@ -212,11 +214,11 @@ export function NDAPage({ queryRef }: NDAPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!data.viewer) {
|
if (!data.viewer) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to={localizedPath("/")} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nda || !signature) {
|
if (!nda || !signature) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to={localizedPath("/")} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature already sealed: the effect above redirects to the continue URL;
|
// Signature already sealed: the effect above redirects to the continue URL;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { graphql, usePreloadedQuery } from "react-relay";
|
|||||||
|
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
import { formatDate } from "#/lib/datetime/formatDate";
|
import { formatDate } from "#/lib/datetime/formatDate";
|
||||||
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
import { NotFoundError } from "#/lib/relay/errors";
|
import { NotFoundError } from "#/lib/relay/errors";
|
||||||
|
|
||||||
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
||||||
@@ -53,6 +54,7 @@ interface UpdateDetailPageProps {
|
|||||||
|
|
||||||
export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
||||||
const { t, i18n } = useTranslation("updates");
|
const { t, i18n } = useTranslation("updates");
|
||||||
|
const localizedPath = useLocalizedPath();
|
||||||
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
||||||
|
|
||||||
if (data.node?.__typename !== "MailingListUpdate") {
|
if (data.node?.__typename !== "MailingListUpdate") {
|
||||||
@@ -66,7 +68,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
|||||||
<>
|
<>
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
<div className={toolbar()}>
|
<div className={toolbar()}>
|
||||||
<Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
<Link to={localizedPath("/updates")} variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||||
{t("backToUpdates")}
|
{t("backToUpdates")}
|
||||||
</Link>
|
</Link>
|
||||||
<UpdatesSubscribeButton />
|
<UpdatesSubscribeButton />
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ import { createBrowserRouter, redirect } from "react-router";
|
|||||||
import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
|
import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
|
||||||
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
||||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||||
|
import { localeLayoutLoader } from "#/lib/i18n/localeRedirect";
|
||||||
|
import { resolveUrlLocale } from "#/lib/i18n/locale";
|
||||||
import { authRoutes } from "#/pages/auth/routes";
|
import { authRoutes } from "#/pages/auth/routes";
|
||||||
import { documentRoutes } from "#/pages/documents/routes";
|
import { documentRoutes } from "#/pages/documents/routes";
|
||||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||||
|
import { LocaleLayout } from "#/pages/LocaleLayout";
|
||||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||||
import { ndaRoutes } from "#/pages/nda/routes";
|
import { ndaRoutes } from "#/pages/nda/routes";
|
||||||
import { requestRoutes } from "#/pages/requests/routes";
|
import { requestRoutes } from "#/pages/requests/routes";
|
||||||
@@ -37,7 +40,14 @@ import { updateRoutes } from "#/pages/updates/routes";
|
|||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/:lang",
|
||||||
|
loader: localeLayoutLoader,
|
||||||
|
Component: LocaleLayout,
|
||||||
|
ErrorBoundary: RootErrorBoundary,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
// Pathless: MainLayout wraps portal pages under /:lang without adding a
|
||||||
|
// segment. (A child path of "/" would be absolute and bypass /:lang.)
|
||||||
Fallback: MainLayoutSkeleton,
|
Fallback: MainLayoutSkeleton,
|
||||||
Component: lazy(() => import("#/pages/MainLayoutLoader")),
|
Component: lazy(() => import("#/pages/MainLayoutLoader")),
|
||||||
// A layout failure takes down the shell, so it shows a standalone full page.
|
// A layout failure takes down the shell, so it shows a standalone full page.
|
||||||
@@ -56,9 +66,10 @@ const routes = [
|
|||||||
// Legacy trust-app URL; home now lives at the index route.
|
// Legacy trust-app URL; home now lives at the index route.
|
||||||
{
|
{
|
||||||
path: "overview",
|
path: "overview",
|
||||||
loader: () => {
|
loader: ({ params }) => {
|
||||||
|
const lang = params.lang ?? resolveUrlLocale();
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
throw redirect("/");
|
throw redirect(`/${lang}`);
|
||||||
},
|
},
|
||||||
Component: Fragment,
|
Component: Fragment,
|
||||||
},
|
},
|
||||||
@@ -76,6 +87,17 @@ const routes = [
|
|||||||
},
|
},
|
||||||
...authRoutes,
|
...authRoutes,
|
||||||
...ndaRoutes,
|
...ndaRoutes,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Bare basename root (no locale segment) → guessed locale home.
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
loader: () => {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
throw redirect(`/${resolveUrlLocale()}`);
|
||||||
|
},
|
||||||
|
Component: Fragment,
|
||||||
|
},
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|
||||||
// The portal is served under a /trust/{slug} path prefix (or a bare custom
|
// The portal is served under a /trust/{slug} path prefix (or a bare custom
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type (
|
|||||||
HashedPassword []byte `db:"hashed_password"`
|
HashedPassword []byte `db:"hashed_password"`
|
||||||
EmailAddressVerified bool `db:"email_address_verified"`
|
EmailAddressVerified bool `db:"email_address_verified"`
|
||||||
SAMLSubject *string `db:"saml_subject"`
|
SAMLSubject *string `db:"saml_subject"`
|
||||||
|
Locale *string `db:"locale"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,7 @@ SELECT
|
|||||||
hashed_password,
|
hashed_password,
|
||||||
email_address_verified,
|
email_address_verified,
|
||||||
saml_subject,
|
saml_subject,
|
||||||
|
locale,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -118,6 +120,7 @@ SELECT
|
|||||||
hashed_password,
|
hashed_password,
|
||||||
email_address_verified,
|
email_address_verified,
|
||||||
saml_subject,
|
saml_subject,
|
||||||
|
locale,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -206,7 +209,17 @@ func (i *Identity) Insert(
|
|||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
identities (id, email_address, full_name, hashed_password, email_address_verified, saml_subject, created_at, updated_at)
|
identities (
|
||||||
|
id,
|
||||||
|
email_address,
|
||||||
|
full_name,
|
||||||
|
hashed_password,
|
||||||
|
email_address_verified,
|
||||||
|
saml_subject,
|
||||||
|
locale,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@identity_id,
|
@identity_id,
|
||||||
@email_address,
|
@email_address,
|
||||||
@@ -214,6 +227,7 @@ VALUES (
|
|||||||
@hashed_password,
|
@hashed_password,
|
||||||
@email_address_verified,
|
@email_address_verified,
|
||||||
@saml_subject,
|
@saml_subject,
|
||||||
|
@locale,
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
@@ -225,6 +239,7 @@ VALUES (
|
|||||||
"full_name": i.FullName,
|
"full_name": i.FullName,
|
||||||
"hashed_password": i.HashedPassword,
|
"hashed_password": i.HashedPassword,
|
||||||
"saml_subject": i.SAMLSubject,
|
"saml_subject": i.SAMLSubject,
|
||||||
|
"locale": i.Locale,
|
||||||
"created_at": i.CreatedAt,
|
"created_at": i.CreatedAt,
|
||||||
"updated_at": i.UpdatedAt,
|
"updated_at": i.UpdatedAt,
|
||||||
"email_address_verified": i.EmailAddressVerified,
|
"email_address_verified": i.EmailAddressVerified,
|
||||||
@@ -254,6 +269,7 @@ SET
|
|||||||
email_address_verified = @email_address_verified,
|
email_address_verified = @email_address_verified,
|
||||||
saml_subject = @saml_subject,
|
saml_subject = @saml_subject,
|
||||||
hashed_password = @hashed_password,
|
hashed_password = @hashed_password,
|
||||||
|
locale = @locale,
|
||||||
updated_at = @updated_at
|
updated_at = @updated_at
|
||||||
WHERE
|
WHERE
|
||||||
id = @identity_id
|
id = @identity_id
|
||||||
@@ -265,8 +281,9 @@ WHERE
|
|||||||
"full_name": i.FullName,
|
"full_name": i.FullName,
|
||||||
"email_address_verified": i.EmailAddressVerified,
|
"email_address_verified": i.EmailAddressVerified,
|
||||||
"saml_subject": i.SAMLSubject,
|
"saml_subject": i.SAMLSubject,
|
||||||
"updated_at": i.UpdatedAt,
|
|
||||||
"hashed_password": i.HashedPassword,
|
"hashed_password": i.HashedPassword,
|
||||||
|
"locale": i.Locale,
|
||||||
|
"updated_at": i.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := conn.Exec(ctx, q, args)
|
result, err := conn.Exec(ctx, q, args)
|
||||||
@@ -295,6 +312,7 @@ SELECT
|
|||||||
hashed_password,
|
hashed_password,
|
||||||
email_address_verified,
|
email_address_verified,
|
||||||
saml_subject,
|
saml_subject,
|
||||||
|
locale,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
|
|||||||
2
pkg/coredata/migrations/20260721T084500Z.sql
Normal file
2
pkg/coredata/migrations/20260721T084500Z.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE identities
|
||||||
|
ADD COLUMN locale TEXT NULL;
|
||||||
@@ -62,8 +62,18 @@ type (
|
|||||||
UpdateIdentityRequest struct {
|
UpdateIdentityRequest struct {
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateLocaleRequest struct {
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Short URL locale tags accepted for Identity.locale (must stay in sync with
|
||||||
|
// the compliance-portal URL_LOCALES list).
|
||||||
|
var supportedIdentityLocales = []string{
|
||||||
|
"en", "fr", "de", "es", "id", "it", "ja", "ko", "pl", "pt", "tr", "uk", "zh",
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TokenTypeEmailConfirmation = "email_confirmation"
|
TokenTypeEmailConfirmation = "email_confirmation"
|
||||||
)
|
)
|
||||||
@@ -88,6 +98,20 @@ func (req UpdateIdentityRequest) Validate() error {
|
|||||||
return v.Error()
|
return v.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (req UpdateLocaleRequest) Validate() error {
|
||||||
|
v := validator.New()
|
||||||
|
|
||||||
|
v.Check(
|
||||||
|
req.Locale,
|
||||||
|
"locale",
|
||||||
|
validator.NotEmpty(),
|
||||||
|
validator.MaxLen(8),
|
||||||
|
validator.OneOfSlice(supportedIdentityLocales),
|
||||||
|
)
|
||||||
|
|
||||||
|
return v.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req *ChangeEmailRequest) error {
|
func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req *ChangeEmailRequest) error {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid request: %w", err)
|
return fmt.Errorf("invalid request: %w", err)
|
||||||
@@ -402,6 +426,42 @@ func (s AccountService) UpdateIdentity(ctx context.Context, identityID gid.GID,
|
|||||||
return identity, nil
|
return identity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s AccountService) UpdateLocale(ctx context.Context, identityID gid.GID, req *UpdateLocaleRequest) (*coredata.Identity, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := &coredata.Identity{}
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
err := identity.LoadByID(ctx, tx, identityID)
|
||||||
|
if err != nil {
|
||||||
|
if err == coredata.ErrResourceNotFound {
|
||||||
|
return NewIdentityNotFoundError(identityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot load identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity.Locale = &req.Locale
|
||||||
|
identity.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := identity.Update(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot update identity locale: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s AccountService) ListPersonalAPIKeys(
|
func (s AccountService) ListPersonalAPIKeys(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
identityID gid.GID,
|
identityID gid.GID,
|
||||||
|
|||||||
@@ -97,6 +97,42 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
|
|||||||
return &types.UpdateFullNamePayload{Success: true}, nil
|
return &types.UpdateFullNamePayload{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateLocale is the resolver for the updateLocale field.
|
||||||
|
func (r *mutationResolver) UpdateLocale(ctx context.Context, input types.UpdateLocaleInput) (*types.UpdateLocalePayload, error) {
|
||||||
|
identity := authn.IdentityFromContext(ctx)
|
||||||
|
if identity == nil {
|
||||||
|
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to update locale")
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := r.iam.AccountService.UpdateLocale(
|
||||||
|
ctx,
|
||||||
|
identity.ID,
|
||||||
|
&iam.UpdateLocaleRequest{
|
||||||
|
Locale: input.Locale,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot update identity locale", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.UpdateLocalePayload{
|
||||||
|
Identity: &types.Identity{
|
||||||
|
ID: identity.ID,
|
||||||
|
Email: identity.EmailAddress,
|
||||||
|
FullName: identity.FullName,
|
||||||
|
EmailVerified: identity.EmailAddressVerified,
|
||||||
|
Locale: identity.Locale,
|
||||||
|
CreatedAt: identity.CreatedAt,
|
||||||
|
UpdatedAt: identity.UpdatedAt,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SignOut is the resolver for the signOut field.
|
// SignOut is the resolver for the signOut field.
|
||||||
func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload, error) {
|
func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload, error) {
|
||||||
session := authn.SessionFromContext(ctx)
|
session := authn.SessionFromContext(ctx)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (r *queryResolver) Viewer(ctx context.Context) (*types.Identity, error) {
|
|||||||
Email: identity.EmailAddress,
|
Email: identity.EmailAddress,
|
||||||
FullName: identity.FullName,
|
FullName: identity.FullName,
|
||||||
EmailVerified: identity.EmailAddressVerified,
|
EmailVerified: identity.EmailAddressVerified,
|
||||||
|
Locale: identity.Locale,
|
||||||
CreatedAt: identity.CreatedAt,
|
CreatedAt: identity.CreatedAt,
|
||||||
UpdatedAt: identity.UpdatedAt,
|
UpdatedAt: identity.UpdatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload
|
updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload
|
||||||
@authentication(required: PRESENT) @sessionOnly
|
@authentication(required: PRESENT) @sessionOnly
|
||||||
|
updateLocale(input: UpdateLocaleInput!): UpdateLocalePayload
|
||||||
|
@authentication(required: PRESENT) @sessionOnly
|
||||||
signOut: SignOutPayload! @authentication(required: PRESENT) @sessionOnly
|
signOut: SignOutPayload! @authentication(required: PRESENT) @sessionOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +14,15 @@ type UpdateFullNamePayload {
|
|||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateLocaleInput {
|
||||||
|
# Short URL locale tag (en, fr, de, …).
|
||||||
|
locale: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLocalePayload {
|
||||||
|
identity: Identity!
|
||||||
|
}
|
||||||
|
|
||||||
type SignOutPayload {
|
type SignOutPayload {
|
||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type Identity implements Node {
|
|||||||
email: EmailAddr!
|
email: EmailAddr!
|
||||||
fullName: String!
|
fullName: String!
|
||||||
emailVerified: Boolean!
|
emailVerified: Boolean!
|
||||||
|
# Preferred UI locale as a short URL tag (en, fr, de, …). Null until the
|
||||||
|
# viewer explicitly chooses a language in the portal.
|
||||||
|
locale: String
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,8 +134,10 @@ func compliancePageHeadData() HeadDataFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compliancePageBaseURL := complianceportal.CompliancePortalBaseURLFromContext(r.Context())
|
compliancePageBaseURL := complianceportal.CompliancePortalBaseURLFromContext(r.Context())
|
||||||
|
pageBase := ref.UnrefOrZero(compliancePageBaseURL)
|
||||||
|
htmlLang, canonical, hreflang := SEOFromRequest(r, pageBase)
|
||||||
|
|
||||||
description := tc.Title + " Compliance Page"
|
description := tc.Title
|
||||||
if tc.Description != nil && *tc.Description != "" {
|
if tc.Description != nil && *tc.Description != "" {
|
||||||
description = *tc.Description
|
description = *tc.Description
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,10 @@ func compliancePageHeadData() HeadDataFunc {
|
|||||||
headData := HeadData{
|
headData := HeadData{
|
||||||
Title: tc.Title,
|
Title: tc.Title,
|
||||||
Description: description,
|
Description: description,
|
||||||
OGURL: ref.UnrefOrZero(compliancePageBaseURL),
|
OGURL: pageBase,
|
||||||
|
HtmlLang: htmlLang,
|
||||||
|
CanonicalURL: canonical,
|
||||||
|
Hreflang: hreflang,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.LogoFileID != nil && compliancePageBaseURL != nil {
|
if tc.LogoFileID != nil && compliancePageBaseURL != nil {
|
||||||
|
|||||||
116
pkg/server/api/complianceportal/v1/seo.go
Normal file
116
pkg/server/api/complianceportal/v1/seo.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package complianceportal_v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Short locale tags used in compliance-portal URL paths. Keep in sync with the
|
||||||
|
// frontend URL_LOCALES list and iam.supportedIdentityLocales.
|
||||||
|
var compliancePortalLocales = []string{
|
||||||
|
"en", "fr", "de", "es", "id", "it", "ja", "ko", "pl", "pt", "tr", "uk", "zh",
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCompliancePortalLocale = "en"
|
||||||
|
|
||||||
|
// SEOFromRequest derives html lang, a self-referencing canonical URL, and
|
||||||
|
// hreflang alternates (including x-default → English) for the SPA shell.
|
||||||
|
func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical string, hreflang []HreflangLink) {
|
||||||
|
appPath := complianceAppPath(r.URL.Path)
|
||||||
|
locale, rest := splitLocaleFromAppPath(appPath)
|
||||||
|
|
||||||
|
htmlLang = locale
|
||||||
|
canonical = localizedPageURL(pageBaseURL, locale, rest)
|
||||||
|
|
||||||
|
hreflang = make([]HreflangLink, 0, len(compliancePortalLocales)+1)
|
||||||
|
for _, loc := range compliancePortalLocales {
|
||||||
|
hreflang = append(hreflang, HreflangLink{
|
||||||
|
Lang: loc,
|
||||||
|
Href: localizedPageURL(pageBaseURL, loc, rest),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hreflang = append(hreflang, HreflangLink{
|
||||||
|
Lang: "x-default",
|
||||||
|
Href: localizedPageURL(pageBaseURL, defaultCompliancePortalLocale, rest),
|
||||||
|
})
|
||||||
|
|
||||||
|
return htmlLang, canonical, hreflang
|
||||||
|
}
|
||||||
|
|
||||||
|
// complianceAppPath returns the path relative to the portal root: under
|
||||||
|
// /trust/:slug it strips that prefix; on a custom domain it returns the path as-is.
|
||||||
|
func complianceAppPath(pathname string) string {
|
||||||
|
trimmed := strings.TrimPrefix(pathname, "/")
|
||||||
|
if strings.HasPrefix(trimmed, "trust/") {
|
||||||
|
parts := strings.SplitN(trimmed, "/", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return "/" + parts[2]
|
||||||
|
}
|
||||||
|
if pathname == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLocaleFromAppPath(appPath string) (locale, rest string) {
|
||||||
|
segments := strings.Split(strings.Trim(appPath, "/"), "/")
|
||||||
|
if len(segments) == 0 || segments[0] == "" {
|
||||||
|
return defaultCompliancePortalLocale, "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCompliancePortalLocale(segments[0]) {
|
||||||
|
locale = segments[0]
|
||||||
|
if len(segments) == 1 {
|
||||||
|
return locale, "/"
|
||||||
|
}
|
||||||
|
return locale, "/" + strings.Join(segments[1:], "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unprefixed legacy path — treat content path as-is; default lang for tags.
|
||||||
|
return defaultCompliancePortalLocale, appPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCompliancePortalLocale(value string) bool {
|
||||||
|
for _, locale := range compliancePortalLocales {
|
||||||
|
if locale == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizedPageURL(pageBaseURL, locale, rest string) string {
|
||||||
|
base := strings.TrimRight(pageBaseURL, "/")
|
||||||
|
if rest == "/" || rest == "" {
|
||||||
|
return base + "/" + locale
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rest, "/") {
|
||||||
|
rest = "/" + rest
|
||||||
|
}
|
||||||
|
return base + "/" + locale + rest
|
||||||
|
}
|
||||||
55
pkg/server/api/complianceportal/v1/seo_test.go
Normal file
55
pkg/server/api/complianceportal/v1/seo_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package complianceportal_v1_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
complianceportal_v1 "go.probo.inc/probo/pkg/server/api/complianceportal/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSEOFromRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "https://app.example.com/trust/acme/fr/documents", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lang, canonical, hreflang := complianceportal_v1.SEOFromRequest(req, "https://app.example.com/trust/acme")
|
||||||
|
assert.Equal(t, "fr", lang)
|
||||||
|
assert.Equal(t, "https://app.example.com/trust/acme/fr/documents", canonical)
|
||||||
|
require.NotEmpty(t, hreflang)
|
||||||
|
|
||||||
|
var xDefault string
|
||||||
|
var enHref string
|
||||||
|
for _, link := range hreflang {
|
||||||
|
if link.Lang == "x-default" {
|
||||||
|
xDefault = link.Href
|
||||||
|
}
|
||||||
|
if link.Lang == "en" {
|
||||||
|
enHref = link.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, "https://app.example.com/trust/acme/en/documents", enHref)
|
||||||
|
assert.Equal(t, enHref, xDefault)
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ type (
|
|||||||
Description string
|
Description string
|
||||||
OGURL string
|
OGURL string
|
||||||
FaviconURL string
|
FaviconURL string
|
||||||
|
HtmlLang string
|
||||||
|
CanonicalURL string
|
||||||
|
Hreflang []HreflangLink
|
||||||
|
}
|
||||||
|
|
||||||
|
HreflangLink struct {
|
||||||
|
Lang string
|
||||||
|
Href string
|
||||||
}
|
}
|
||||||
|
|
||||||
HeadDataFunc func(r *http.Request) HeadData
|
HeadDataFunc func(r *http.Request) HeadData
|
||||||
|
|||||||
Reference in New Issue
Block a user