Commit Graph

2592 Commits

Author SHA1 Message Date
Ludovic Vielle
238c19d509 Add pre-assume enrolled device status query
The /enroll wait UI polled device state via node(), which
requires an assumed org session, so confirmation never
succeeded for unassumed viewers. Expose viewer.enrolledDevice
behind itam:employee-device:get (own-device, skip assumption)
and point the poller at it.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:34 +02:00
Ludovic Vielle
afe0c84881 Ship signed universal macOS probo-agent pkg
Publish a notarized arm64+x86_64 .pkg from CI with the CGO tray
binary, Probo Agent.app, and global LaunchAgent. Keep the
LaunchDaemon enrollment-gated, align its plist path with the
launchd label, and document the Apple signing secrets.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:33 +02:00
Ludovic Vielle
4a56be2e3e Improve probo-agent tray icon and enrollment menu
Replace the placeholder orange-circle tray icon with the monochrome
Probo logo used in auditor-mode. On macOS, show the icon only (no
menu bar title); keep the title on Windows.

Restructure the context menu: status rows with colored indicators,
an Enroll via… submenu for region selection, and a clearer About
label. Surface enrollment errors in native dialogs instead of stderr.

Fix self-hosted enrollment on macOS by reading the hostname from
osascript's returned value; the default dialog output order broke
URL parsing and silently skipped opening the browser.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:32 +02:00
Ludovic Vielle
ae769f52a1 Serialize enrollment install with enrolling.lock
Concurrent enroll-url launches could both pass the enrollment
marker check and run overlapping elevated installs, racing on
LoadOrExchangeAPIKey and overwriting agent.key.

Add an exclusive flock on {configDir}/enrolling.lock for the
full install path and re-check IsEnrolled under that lock so
only one install exchanges a token and configures the device.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:30 +02:00
Ludovic Vielle
1329f2a28e Add tray browser enrollment with region picker
The tray helper carried a ServerURL default that nothing read.
Unenrolled users can now open the console /enroll page from the
menu: US, EU, or self-hosted in production, or --server for dev.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:08:29 +02:00
Ludovic Vielle
d0dd87c6c7 Add probo-agent binary, installer, and CI
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:25 +02:00
Ludovic Vielle
b442e1ed76 Add device agent library
Provide enrollment, elevated install, posture checks, keystore, and
system-tray helpers shared by the probo-agent binary.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:24 +02:00
Ludovic Vielle
e767dd8377 Add device enrollment API and agent protocol
Expose ITAM REST endpoints for agents, console GraphQL for device
management, and wire probod bootstrap with enrollment e2e coverage.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:23 +02:00
Ludovic Vielle
1f79453386 Add device data model and ITAM service
Introduce device, posture, and enrollment-token entities with ITAM
service policies for agent-managed fleet inventory.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-24 15:08:21 +02:00
Aurélien Sibiril
b1a67aba00 Link connector docs in the Add Source dialog
The access review Add Source dialog listed each connector with no
path to its setup documentation. Connectors that have a published
docs page on probo.com now surface a "Documentation" link on the
card, opening the page in a new tab; connectors without a page show
nothing extra.

The link is data-driven from the connector registry: a new
DocumentationURL on the provider Registration, populated for the 12
documented providers via a single accessReviewDocsURL helper, is
surfaced as a nullable documentationUrl on ConnectorProviderInfo and
rendered by the console only when present. This keeps the registry
the single source of truth and adds no client-side provider map.

The links resolve once the probo.com access-review docs pages are
deployed; until then they 404, so deploy the docs alongside this
change.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-24 14:50:18 +02:00
Sacha Al Himdani
bcd05a2e55 Reject empty SAML NameIDs on login
Empty NameID values were stored as '' and occupied the
unique saml_subject index, causing duplicate-key failures
on later logins. Reject blank NameIDs during assertion
validation, return a clear error when a NameID is already
linked to another account, and stop returning internal
errors from the SAML consume endpoint.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-24 14:03:30 +02:00
Bryan Frimin
e12351e0f4 Require verified certs for portal redirect hosts
AddCustomDomain only validates the domain's format before inserting
the row; certificate issuance then runs asynchronously. Every host
that row resolved to was accepted by the OIDC, magic-link, and
compliance-portal OAuth `continue` redirect allowlists, so anyone
could self-register an org, claim an arbitrary domain, and have
users redirected there right after a real login. Found while
re-checking GHSA-r9mf-88r7-g6j9 against the compliance portal
rework: the original session-transfer leak is gone, but this open
redirect on the same allowlist was not.

Gate those allowlists on the domain's certificate having reached
Active or Renewing status, which only happens once DNS has pointed
at Probo's edge and an ACME challenge has actually succeeded.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-24 08:38:17 +02:00
Sacha Al Himdani
5c5f60d5e1 Render longer vetting notes as markdown
Keep more of the orchestrator assessment text,
skipping profile fields already on the third party,
and render the notes as markdown in the console.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-23 08:50:22 +02:00
Aurélien Sibiril
3805afc806 Fix PostHog source disconnected on EU cloud OAuth
The connection-status probe and the access-review driver each had
their own copy of the "try us.posthog.com, then eu.posthog.com"
region-discovery loop, and they drifted. The driver skips a region
that rejects the token (wrong region) and uses the one that answers
2xx; the probe instead returned "credential rejected" on the first
region's 401/403, before ever trying the second.

PostHog Cloud US and EU are separate deployments, so an EU OAuth
token is a 401 on us.posthog.com, which is probed first. The probe
bailed there and marked the source disconnected, while access-review
campaigns -- which use the driver -- kept working.

Delete the probe's copy and delegate to the driver's now-exported
ResolvePostHogRegion, the single resolver the campaign also uses. It
flags a credential every region rejected (ErrPostHogCredentialRejected)
apart from a transient failure on the token's own region, so the probe
marks a source disconnected only for a genuinely dead token and does
not flap on a passing 5xx.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 22:44:41 +02:00
Bryan Frimin
eaea090ac4 Style
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 17:59:01 +02:00
Bryan Frimin
0b577cda40 Fix global rate limit ACME
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 17:53:20 +02:00
Aurélien Sibiril
a0ead155db Trim access-review connector comments
Cut verbose inline comments across the access-review connector changes:
the mechanical fact stays at the read site, while incident backstory
("millions of error logs in prod") and provider-specific rationale (why
Clerk reviews the wrong population) move to the commit history where they
belong. Also tighten a loose "a 4xx" to "an auth/not-found 4xx" so the
terminal-classification contract is not overstated. No behavior change.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
a7bcbbcd85 Document Cloudflare per_page floor and Vercel teamId
Note that Cloudflare requires per_page in 5..50 so nobody optimizes
it back to per_page=1, which returned 400 and, before terminal
classification, produced a 400 storm. Also correct a stale team_id
reference in the Vercel user-fetch comment: the OAuth callback
surfaces the team as teamId.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
d18127503d Pin Vercel OAuth callback team parameter name
The Vercel connector reads the team from the camelCase teamId callback
parameter, unlike the snake_case params most providers use. Nothing
guarded the name, so a regression back to team_id would silently drop
the team on every Vercel connect and leave the source resolving no
users.

Extract the read into vercelCallbackTeamID and pin the exact parameter
name with a test, since the surrounding handler needs a live connector
registry and database to exercise directly.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
2971637c72 Default access source org across GraphQL and MCP
Org-defaulting for picker providers only ran in the GraphQL resolver,
so a picker-provider source created or updated through the MCP API
connected fine but resolved no users until the org was picked. Move
the defaulting into the accessreview service as
AutoSelectDefaultOrganization and call it from both surfaces, moving
the providerOrgConfigs picker dispatch alongside it (the three console
picker resolvers now dispatch through service accessors, behavior
unchanged).

Also harden the moved path: resolve the provider from cheap connector
metadata before building the authenticated HTTP client, so the ~50
non-picker providers no longer pay a decrypt/refresh/DB-write on every
create/update; bound the outbound ListOrgs call with a 10s timeout so
a hung provider cannot stall the mutation; and re-check inside the
ConfigureAccessReviewSource tx (OnlyIfUnset) so an org the user picks
while ListOrgs is in flight is not overwritten by the first listed
org.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
f711e9d816 Reset access source name sync on connection change
Once the source-name worker sets name_synced_at, nothing cleared it,
so a source that hit a terminal failure (Brex 403, wrong Sentry org
slug) kept its generic name forever — even after the user reconnected
with the right scope or picked the correct org, contradicting the
Brex guidance to reconnect.

Clear name_synced_at whenever the connection changes: on UpdateSource
when a connector is (re)set, inside ConfigureAccessReviewSource when
the org is (re)selected, and on the OAuth reconnect path via the new
ResetSourceNameSyncForConnector service method. The worker then
re-claims the row and re-resolves the display name.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
11e7a250fc Mark source synced when name resolver setup fails
The source-name worker drains claims until ErrNoTask with no
inter-task delay. When connector load or HTTP-client setup failed the
worker logged an error and returned nil without marking the source
synced, leaving name_synced_at NULL. The row stayed claimable, so a
dead connector (notably a revoked OAuth refresh token failing the
eager token refresh) was re-claimed immediately every cycle and hot-
looped the vendor token endpoint.

Treat a setup failure like a terminal resolution failure: keep the
generic name and mark the source synced with a warning so it stops
re-claiming. A later reconnect or reconfigure clears name_synced_at
to retry.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
94552dbf11 Make Grafana/Metabase/Tailscale name errors terminal
The source-name worker keeps a generic name and marks the source
synced only when a resolver reports ErrTerminalNameResolution. The
Grafana and Metabase name resolvers, plus Tailscale via its shared
fetchUsers, returned a plain error on any non-2xx, so a revoked
credential (401/403) never reached the terminal path and the worker
re-claimed the row every drain cycle with no delay.

Route their non-2xx through nameStatusError so permanent 4xx wrap
ErrTerminalNameResolution while 5xx stay retryable. The sentinel is
inert on the ListAccounts sync path, which does not inspect it.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
aeb7a1c8e6 Default first workspace for picker access sources
Picker providers (GitHub, Sentry, GitLab, Bitbucket, Heroku, Asana,
Netlify, ClickUp, DocuSign) require the user to pick an org/workspace in
a follow-up step after connecting. When that step is skipped, the source
stays connected but unconfigured, and its first campaign silently
resolves no users because the driver needs an org — the same "connects
fine, campaign fetches nobody" symptom seen on Sentry.

When a connector is linked to a source, auto-select the first workspace
the connection can list, so the source is usable immediately. An org the
user already chose is never overridden, and the picker stays visible
(the frontend shows the selector whenever an org is selected) so they
can switch when several are listed.

Best-effort: a provider that is unreachable or lists nothing leaves the
source in its existing needs-configuration state rather than failing the
create/update mutation.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
c50d2657f4 Request Brex companies.readonly scope for name resolution
The Brex name resolver calls GET /v2/company to build the source
display name, but the connector only requested openid, offline_access,
and users.readonly -- and users.readonly covers /v2/users (the account
fetch), not /v2/company. So the company endpoint 403'd for every Brex
source, which the source-name worker retried forever (2.9M errors in 7
days) until the terminal-error handling stopped the loop.

Add companies.readonly so the endpoint resolves. Verified as the exact
scope string against Brex's OAuth authorize flow (the "Companies: Read
only" developer scope maps to companies.readonly). Existing Brex
connectors must reconnect to re-consent to the added scope; until they
do, /v2/company still 403s but is now handled cleanly (terminal ->
generic name, no loop) rather than silently retried.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
c837701099 Stop source-name worker looping on permanent failures
The access-review source-name worker never marked a source synced when
name resolution errored, so it re-claimed the source on every poll and
retried at vendor-latency cadence. Two permanently-failing sources
generated millions of error logs (Brex /v2/company 403 and Cloudflare
/accounts 400) and hammered vendor APIs (8.6M 403s to Brex in 30 days) --
a ban risk, all for best-effort display metadata.

Generalize the Google-403 special case: name resolvers now classify a
non-2xx response through nameStatusError, which wraps
ErrTerminalNameResolution for permanent client errors (400, 401, 403,
404) and returns a plain, retryable error for everything else (5xx,
network). The worker treats a terminal error as done -- it keeps the
generic name and marks the source synced -- while transient failures
keep retrying as before.

Also fix the Cloudflare name resolver's own bug: it requested
per_page=1, but Cloudflare's List Accounts endpoint requires per_page in
5..50 and 400s otherwise (the driver already uses 50). That 400 was the
sole cause of the Cloudflare retry storm; bump it to 50.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
60d373e5c4 Disable Clerk access-review connector
Clerk's Backend API secret key only exposes the customer's application
end-users (GET /v1/users) -- the people who sign into the customer's
product -- not the Clerk workspace/dashboard team who administer the
platform. Those admins have no Backend API and are managed only in the
Clerk Dashboard UI. So an access-review campaign against Clerk reviews
the wrong population entirely (verified in prod: one source paginating
16K+ end-users), which is a compliance defect, not just the pagination
timeouts it also caused.

Unregister Clerk from the builtin provider registry and drop its driver,
provider registration, tests, and cassette so it can no longer be added
or fetched. Keep the ConnectorProviderClerk enum value, its IsValid
membership, the GraphQL enum value, and the frontend logo so existing
stored CLERK connectors still validate, serialize, and display (users
can still see and remove them). Drop it from ConnectorProviders so the
registry stays in sync with the registered set.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
0da9e3c5c4 Read Vercel team from the teamId callback param
Vercel appends the customer's team to the OAuth completion callback as
teamId (camelCase), but the handler read query.Get("team_id"). The
lookup always missed, so every team install fell through to the
/v2/user personal-account fallback -- which returns 404 for a
team-scoped integration token -- leaving TeamID empty. The Vercel
driver then refused to build ("team_id is required") and every
access-review campaign targeting a Vercel team fetched zero accounts.

Personal-account installs were unaffected because they never send a
teamId and legitimately use the /v2/user path, which is why the
breakage only showed up for team installs.

Read the parameter under its real name so the team is captured and
persisted in the connector settings.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Aurélien Sibiril
9c0f95bab1 Restore trailing slash on Sentry API requests
Commit f5703d390 replaced the fmt.Sprintf URL construction with
url.JoinPath, which calls path.Join and therefore strips a trailing
slash unless the final element carries one. Sentry's API only routes
slashed paths and answers 404 without redirecting, so every
ListAccounts call failed on its first request and no access-review
campaign targeting Sentry could fetch a single account.

The failure was invisible for two reasons. queryMembers maps 404 to
errSentryOrgNotAccessible, so a routing bug surfaced to users as
"reconnect the connector with the correct organization" -- advice that
could never help, because the slug was never wrong. And commit
74ce2bc5d edited the recorded request URL in testdata/sentry.yaml to
match the new construction instead of re-recording the cassette, which
kept CI green; that cassette still carries Sentry's own Link header
with the trailing slash, contradicting its own request line.

Pass the slash on the final JoinPath element in both the members
endpoint and the organization name resolver, revert the cassette to the
URL Sentry actually served, and add a regression test that drives the
driver against a server which 404s unslashed paths, so the URL shape is
pinned independently of the cassette matcher.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00
Ludovic Vielle
cbd0387490 Expose ACME cooldown end time and error details
Operators could see that a rate-limit cooldown was active, but not
when it ends, and failure logs omitted most of the CA problem
document. Add a until-timestamp gauge and log the full acme.Error
surface so cooldowns and ACME responses are diagnosable.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-07-22 16:38:11 +02:00
Bryan Frimin
9f6a0c1d40 Fixes cubic review
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 13:57:56 +02:00
Bryan Frimin
3f202002d9 Fix parsing int error
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 13:20:53 +02:00
Bryan Frimin
81b7ee5fad Close cert provisioning correctness gaps
Several race and validity gaps could leave certificate provisioning
stuck, unusable, or noisy:

- Accept the HTTP-01 challenge only after the key authorization is
  committed, so the CA cannot hit the token before this instance can
  serve it and invalidate the order.
- Persist challenge metadata under a blocking write-back lock; a row
  merely locked by a competing transaction no longer silently drops the
  accepted order.
- Abandon a recovered VALID order and restart instead of issuing it
  with a freshly generated key that cannot match the existing cert.
- Exclude rate-limited rows from the ten-minute stale reset so the
  resumable order survives the ACME cooldown.
- Size the provisioning poll lease to exceed the max processing window
  so a released claim lock cannot let another worker process the same
  row concurrently.
- Parse Retry-After as unsigned seconds and clamp overflow so malformed
  values fall back to the default cooldown instead of disabling it.
- Normalize the acme_errors problem_type label to the RFC 8555 set to
  bound Prometheus cardinality.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 13:20:53 +02:00
Bryan Frimin
45c45ac5a0 Style
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 13:20:52 +02:00
Bryan Frimin
121d4dcf93 Fix certmanager provisioning retry and metrics
Address several provisioning defects that either stalled the retry
budget or crashed the process:

- Classify CAA resolver/transport failures apart from a real CAA policy
  denial. Both shared the "caa records" wording, so a transient resolver
  error was persisted as customer misconfiguration and retried forever
  without consuming the retry budget. A new ErrCAANotPermitted sentinel
  now marks the genuine misconfiguration; other CAA errors are treated
  as ordinary transient failures.

- Honor an explicit Retry-After: 0 (or a past date) as permission for an
  immediate retry instead of promoting it to the one-hour default
  cooldown. acme.RateLimit collapses zero, invalid, and absent headers
  to a zero duration, so the header is now parsed directly to tell an
  explicit zero apart from a missing one.

- Reuse already-registered Prometheus collectors when a second
  ACMEService shares a registerer. The fixed-name collectors were
  MustRegistered, so a duplicate registration panicked the process.

- Persist provisioning failures on a context detached from the process
  tick deadline. A timed-out attempt reached persistFailure with an
  expired context, so the write-back failed and the retry budget never
  advanced, leaving the certificate indefinitely retriable.

- Use pgx.StrictNamedArgs in the certificate FOR UPDATE loaders to match
  the coredata SQL contract.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 13:20:52 +02:00
Bryan Frimin
9724a2ce50 Harden cert provisioning failure and write-back
Rate limits no longer inflate ssl_retry_count into an instant FAILED
path. Centralize outcomes in decideProvisioningOutcome, keep ACME
order state on transient and rate-limit errors, bound each Process
tick with a timeout, and block on FOR UPDATE when persisting a
freshly issued certificate.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-22 13:20:52 +02:00
Émile Ré
f1136cb748 Use entity name as CIMD client name
OAuth client_name should be the short brand
label, not the composed English hero heading.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 13:08:03 +02:00
Émile Ré
465242eb17 Fix lint issues
Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 13:08:02 +02:00
Émile Ré
0d832508c9 Replace portal title with entity name
Store a short entity name instead of the full home
heading so orgs can brand portals for sub-entities.
Restore hero i18n composition and keep the English
document title composed from the entity name.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 13:08:02 +02:00
Émile Ré
0fe814cfe4 Fix deprecated use of proxyproto.ConnStrictWhiteListPolicy
Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 13:07:52 +02:00
Émile Ré
9146a4ac1d Update gqlgen generated comments
Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 12:53:45 +02:00
Émile Ré
52310dbf65 Read portal SEO origin from request context
SNI middleware already stores scheme://host; SEOFromRequest
no longer takes a base URL or re-strips the path. Expose
context helpers and drop the obsolete strip tests.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 12:19:25 +02:00
Émile Ré
9ee0df4d3f Document portal env and log SNI failures
Restore VITE_API_URL to :8080 in .env.example with notes
for local Vite (:5174 + proxy target). Log compliance
portal lookup errors in the SNI middleware with context.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 12:05:56 +02:00
Émile Ré
ee3136a0d2 Fix lint issues
Signed-off-by: Émile Ré <emile@probo.com>
2026-07-22 10:13:49 +02:00
Émile Ré
6024a2e803 Omit SEO links for relative base URLs
portalOrigin fell back to the raw input when scheme or host
was missing, so paths like /fr/documents still produced
canonical and hreflang tags. Return an empty origin instead.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-21 19:08:55 +02:00
Émile Ré
59f053e8f9 Fix portal SEO, Korean copy, and favicon
Custom-domain base URLs included the request path, so
canonical/hreflang doubled routes; emit SEO links only with
an absolute origin. Rewrite Korean mismatch strings to avoid
literal particle parentheses, rename HtmlLang to HTMLLang, and
ship public favicons so Vite no longer 404s the fallback icon.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-21 18:50:34 +02:00
Émile Ré
e47091e5b5 Fix lint issues
Signed-off-by: Émile Ré <emile@probo.com>
2026-07-21 18:43:50 +02:00
Émile Ré
418bb5a8f8 Address review feedback on portal i18n
Swallow locale mutation rejections after the toast, close the
mobile drawer on locale change, escape SEO paths, share the
IAM locale list with SEO, and finish dropping /trust leftovers.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-21 18:43:50 +02:00
Émile Ré
e4260d50d3 Drop leftover /trust path prefix handling
Portals are host-routed on slug subdomains, so SEO path
stripping and the frontend basename helper were dead code.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-21 18:43:49 +02:00
Émile Ré
c223873e96 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>
2026-07-21 18:43:48 +02:00