diff --git a/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx b/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx index adeb5037e..954985928 100644 --- a/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx +++ b/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx @@ -100,7 +100,9 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp }; const adoptUrlLocale = () => { - void updateLocale(urlLocale).then(() => setDismissed(true)); + void updateLocale(urlLocale) + .then(() => setDismissed(true)) + .catch(() => {}); }; return ( diff --git a/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx b/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx index 6095aa6ef..12a58dca1 100644 --- a/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx +++ b/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx @@ -36,11 +36,16 @@ import { useLocale } from "#/lib/i18n/useLocale"; interface LocaleSelectProps { // Persist the choice on the signed-in identity when true. persist?: boolean; + // Called after a locale change is requested (e.g. close the mobile drawer). + onLocaleChange?: () => void; } // 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) { +export function LocaleSelect({ + persist = false, + onLocaleChange, +}: LocaleSelectProps) { const { t } = useTranslation(); const locale = useLocale(); const [changeLocale, isChanging] = useChangeLocale(); @@ -52,6 +57,7 @@ export function LocaleSelect({ persist = false }: LocaleSelectProps) { if (value == null || value === locale) { return; } + onLocaleChange?.(); void changeLocale(value, { persist }); }} disabled={isChanging} diff --git a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx index 4c62dd350..d03106e0c 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx @@ -144,7 +144,10 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
- +
{identity == null ? ( diff --git a/apps/compliance-portal/src/lib/i18n/i18n.ts b/apps/compliance-portal/src/lib/i18n/i18n.ts index d41b064aa..b0caa8efc 100644 --- a/apps/compliance-portal/src/lib/i18n/i18n.ts +++ b/apps/compliance-portal/src/lib/i18n/i18n.ts @@ -29,12 +29,7 @@ import { 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]; + const first = window.location.pathname.split("/").filter(Boolean)[0]; if (isUrlLocale(first)) { return urlLocaleToLanguage(first); } diff --git a/apps/compliance-portal/src/lib/i18n/locale.ts b/apps/compliance-portal/src/lib/i18n/locale.ts index 71f9f3d82..92a6e1096 100644 --- a/apps/compliance-portal/src/lib/i18n/locale.ts +++ b/apps/compliance-portal/src/lib/i18n/locale.ts @@ -24,6 +24,7 @@ import { } from "./resolveLanguage"; // Short tags used in URL path segments (and persisted on Identity.locale). +// Keep in sync with iam.SupportedIdentityLocales. export const URL_LOCALES = [ "en", "fr", diff --git a/apps/compliance-portal/src/lib/i18n/localeRedirect.ts b/apps/compliance-portal/src/lib/i18n/localeRedirect.ts index 2832f1406..42683b46d 100644 --- a/apps/compliance-portal/src/lib/i18n/localeRedirect.ts +++ b/apps/compliance-portal/src/lib/i18n/localeRedirect.ts @@ -18,7 +18,7 @@ // 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 { type LoaderFunctionArgs, redirect } from "react-router"; import { isUrlLocale, @@ -30,7 +30,7 @@ import { // (/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 appPath = url.pathname || "/"; const segments = appPath.split("/").filter(Boolean); const first = segments[0]; @@ -41,6 +41,7 @@ export function localeLayoutLoader({ request }: LoaderFunctionArgs) { const guess = resolveUrlLocale(); if (segments.length === 0) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- react-router redirect throw redirect(`/${guess}${url.search}`); } @@ -48,17 +49,11 @@ export function localeLayoutLoader({ request }: LoaderFunctionArgs) { if (/^[a-z]{2}$/.test(first)) { const rest = segments.slice(1); const path = rest.length === 0 ? "/" : `/${rest.join("/")}`; + // eslint-disable-next-line @typescript-eslint/only-throw-error -- react-router redirect throw redirect(localizedPath(guess, path) + url.search); } // First segment is a real route (documents, updates, …) — prefix locale. + // eslint-disable-next-line @typescript-eslint/only-throw-error -- react-router redirect 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 || "/"; -} diff --git a/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts b/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts index 498e6ae7f..8ef65e585 100644 --- a/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts +++ b/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts @@ -49,7 +49,8 @@ export function useChangeLocale() { // Identity↔URL desync that flashed the mismatch callout. startTransition(() => { if (options.persist) { - void updateLocale(locale); + // Mutation notifier already toasts; swallow rejection to avoid noise. + void updateLocale(locale).catch(() => {}); } if (locale !== currentLocale) { void navigate(replaceLocaleInPathname(pathname, locale) + search); diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx index 5d8cbd228..4e479fb23 100644 --- a/apps/compliance-portal/src/routes.tsx +++ b/apps/compliance-portal/src/routes.tsx @@ -25,8 +25,8 @@ import { createBrowserRouter, redirect } from "react-router"; import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary"; import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary"; -import { localeLayoutLoader } from "#/lib/i18n/localeRedirect"; import { resolveUrlLocale } from "#/lib/i18n/locale"; +import { localeLayoutLoader } from "#/lib/i18n/localeRedirect"; import { authRoutes } from "#/pages/auth/routes"; import { documentRoutes } from "#/pages/documents/routes"; import { HomePageSkeleton } from "#/pages/HomePageSkeleton"; diff --git a/pkg/coredata/migrations/20260721T084500Z.sql b/pkg/coredata/migrations/20260721T084500Z.sql index 5c10de54f..9a472bd41 100644 --- a/pkg/coredata/migrations/20260721T084500Z.sql +++ b/pkg/coredata/migrations/20260721T084500Z.sql @@ -1,2 +1,22 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + ALTER TABLE identities ADD COLUMN locale TEXT NULL; diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index d294f99b0..4a493f0b7 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -68,9 +68,9 @@ type ( } ) -// Short URL locale tags accepted for Identity.locale (must stay in sync with -// the compliance-portal URL_LOCALES list). -var supportedIdentityLocales = []string{ +// SupportedIdentityLocales are short URL locale tags accepted for +// Identity.locale. Keep 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", } @@ -106,7 +106,7 @@ func (req UpdateLocaleRequest) Validate() error { "locale", validator.NotEmpty(), validator.MaxLen(8), - validator.OneOfSlice(supportedIdentityLocales), + validator.OneOfSlice(SupportedIdentityLocales), ) return v.Error() diff --git a/pkg/server/api/complianceportal/v1/seo.go b/pkg/server/api/complianceportal/v1/seo.go index bf41026ab..6a2140063 100644 --- a/pkg/server/api/complianceportal/v1/seo.go +++ b/pkg/server/api/complianceportal/v1/seo.go @@ -22,14 +22,11 @@ package complianceportal_v1 import ( "net/http" + "net/url" "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", -} + "go.probo.inc/probo/pkg/iam" +) const defaultCompliancePortalLocale = "en" @@ -47,8 +44,9 @@ func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical st htmlLang = locale canonical = localizedPageURL(pageBaseURL, locale, rest) - hreflang = make([]HreflangLink, 0, len(compliancePortalLocales)+1) - for _, loc := range compliancePortalLocales { + locales := iam.SupportedIdentityLocales + hreflang = make([]HreflangLink, 0, len(locales)+1) + for _, loc := range locales { hreflang = append(hreflang, HreflangLink{ Lang: loc, Href: localizedPageURL(pageBaseURL, loc, rest), @@ -81,7 +79,7 @@ func splitLocaleFromAppPath(appPath string) (locale, rest string) { } func isCompliancePortalLocale(value string) bool { - for _, locale := range compliancePortalLocales { + for _, locale := range iam.SupportedIdentityLocales { if locale == value { return true } @@ -91,11 +89,22 @@ func isCompliancePortalLocale(value string) bool { func localizedPageURL(pageBaseURL, locale, rest string) string { base := strings.TrimRight(pageBaseURL, "/") - if rest == "/" || rest == "" { - return base + "/" + locale + segments := []string{locale} + if rest != "/" && rest != "" { + trimmed := strings.Trim(rest, "/") + if trimmed != "" { + segments = append(segments, strings.Split(trimmed, "/")...) + } } - if !strings.HasPrefix(rest, "/") { - rest = "/" + rest + + escaped := make([]string, len(segments)) + for i, segment := range segments { + escaped[i] = url.PathEscape(segment) } - return base + "/" + locale + rest + + joined, err := url.JoinPath(base, escaped...) + if err != nil { + return base + "/" + strings.Join(escaped, "/") + } + return joined } diff --git a/pkg/server/api/complianceportal/v1/seo_test.go b/pkg/server/api/complianceportal/v1/seo_test.go index 55d6ac346..a12e9e896 100644 --- a/pkg/server/api/complianceportal/v1/seo_test.go +++ b/pkg/server/api/complianceportal/v1/seo_test.go @@ -60,3 +60,20 @@ func TestSEOFromRequest(t *testing.T) { assert.Equal(t, "https://acme.probopage.localhost/en/documents", enHref) assert.Equal(t, enHref, xDefault) } + +func TestSEOFromRequest_EscapesPathSegments(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest( + http.MethodGet, + "https://acme.probopage.localhost/en/docs/foo%20bar", + nil, + ) + require.NoError(t, err) + + _, canonical, _ := complianceportal_v1.SEOFromRequest( + req, + "https://acme.probopage.localhost", + ) + assert.Equal(t, "https://acme.probopage.localhost/en/docs/foo%20bar", canonical) +}