diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 9663478000..cc65a78ce8 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6240,6 +6240,10 @@ muted = "muted" nameLabel = "Name" namePlaceholder = "e.g. Compliance escalation" +[portal.agentBuilder.status] +draft = "Draft" +published = "Published" + [portal.agentBuilder.tabs] evals = "Evals" scenarios = "Scenarios" @@ -6463,7 +6467,7 @@ install = "Install" usage = "Usage" [portal.catalogue.detail.locked] -description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it." +description = "{{name}} is included from the {{plan}}. Upgrade to embed it." title = "Not available on your plan" [portal.catalogue.detail.preview] @@ -7415,6 +7419,7 @@ manual = "Manual" schedule = "Scheduled" [portal.policies] +defaultName = "{{category}} Policy" subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." title = "Policies" @@ -8125,6 +8130,11 @@ chooseType = "Choose type" configure = "Configure" review = "Review & connect" +[portal.tier] +enterprise = "Enterprise plan" +free = "Editor plan" +pro = "Processor plan" + [portal.usage] managePayment = "Manage Payment" subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." @@ -10139,6 +10149,19 @@ resetPw = "Reset password" suspend = "Suspend" unlock = "Unlock account" +[users.activity] +daysAgo = "{{count}}d ago" +hoursAgo = "{{count}}h ago" +justNow = "Just now" +minutesAgo = "{{count}}m ago" +monthsAgo_one = "{{count}} month ago" +monthsAgo_other = "{{count}} months ago" +never = "Never" +weeksAgo_one = "{{count}} week ago" +weeksAgo_other = "{{count}} weeks ago" +yearsAgo_one = "{{count}} year ago" +yearsAgo_other = "{{count}} years ago" + [users.cap] addProcessor = "+ Processor" approver = "Approves policy" @@ -10209,7 +10232,9 @@ by = "Invited by {{who}}" cancel = "Cancel" count = "{{count}} pending" desc = "Invited people who haven't joined yet. They hold a seat until they accept." -expires = "Expires" +expiresInDays_one = "Expires in {{count}} day" +expiresInDays_other = "Expires in {{count}} days" +expiresToday = "Expires today" title = "Pending invitations" [users.loadError] diff --git a/frontend/editor/src/portal/api/agents.ts b/frontend/editor/src/portal/api/agents.ts index 61a9e75906..7a0cd49464 100644 --- a/frontend/editor/src/portal/api/agents.ts +++ b/frontend/editor/src/portal/api/agents.ts @@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record = { draft: "neutral", }; +export const AGENT_STATUS_LABEL: Record = { + published: "portal.agentBuilder.status.published", + draft: "portal.agentBuilder.status.draft", +}; + /** * Catalogue of tools an agent can be granted or denied. Surfaced as the chip * palette in restricted mode so the deny list is picked from a known set diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 5c3c33a75a..78f8b07667 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -9,6 +9,7 @@ * approach the editor uses for its own catalogue view. */ +import type { TFunction } from "i18next"; import { apiClient } from "@portal/api/http"; import { fromWirePolicy, toWirePolicy } from "@app/policies/codec"; import { runsToActivity, runsToStats } from "@app/policies/runs"; @@ -556,17 +557,30 @@ const DEFAULT_RETRY_DELAY = 5; // POST /api/v1/policies endpoint. The real backend ignores unknown fields. type CatalogueWireBody = WirePolicy & { categoryId: string }; +/** + * The persisted policy name derived from its category, e.g. "Security Policy". + * `category.label` is an i18n key, so translate it before building the name; + * otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources + * "Used by" pill). + */ +function policyDisplayName(entry: CatalogueEntry, t: TFunction): string { + return t("portal.policies.defaultName", { + category: t(entry.category.label), + }); +} + /** Build a wire policy from a setup wizard result. */ export function buildWireFromSetup( entry: CatalogueEntry, result: PolicySetupResult, + t: TFunction, enabled = true, ): CatalogueWireBody { return { categoryId: entry.category.id, ...toWirePolicy({ id: entry.policy?.state.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: result.sources, @@ -589,13 +603,14 @@ export function buildWireFromState( entry: CatalogueEntry, policy: DecoratedPolicy, enabled: boolean, + t: TFunction, ): CatalogueWireBody { const s = policy.state; return { categoryId: entry.category.id, ...toWirePolicy({ id: s.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: s.sources, diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts index 3505238927..19a686cf8c 100644 --- a/frontend/editor/src/portal/api/users.ts +++ b/frontend/editor/src/portal/api/users.ts @@ -1,3 +1,7 @@ +// The bare i18next singleton (the same instance @app/i18n initializes at +// startup), imported directly so this data module doesn't pull i18n's +// init side effects into unit tests that mock react-i18next. +import i18n from "i18next"; import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; @@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId { /** A member's last-seen time as plain language; "Never" when no session is tracked. */ function relativeTime(value: number | string | undefined): string { - if (value === undefined || value === null) return "Never"; + if (value === undefined || value === null) + return i18n.t("users.activity.never"); const ts = typeof value === "string" ? Date.parse(value) : value; - if (!Number.isFinite(ts) || ts <= 0) return "Never"; + if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never"); const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); - if (mins < 1) return "Just now"; - if (mins < 60) return `${mins}m ago`; + if (mins < 1) return i18n.t("users.activity.justNow"); + if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins }); const hours = Math.round(mins / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours }); const days = Math.round(hours / 24); - if (days < 7) return `${days}d ago`; + if (days < 7) return i18n.t("users.activity.daysAgo", { count: days }); const weeks = Math.round(days / 7); - if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; + if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks }); const months = Math.round(days / 30); - if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`; + if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months }); const years = Math.round(days / 365); - return years <= 1 ? "1 year ago" : `${years} years ago`; + return i18n.t("users.activity.yearsAgo", { count: years }); } /** 0 / huge sentinel license values mean "no seat limit". */ diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx index af198d488f..2f4602b11d 100644 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx +++ b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx @@ -26,7 +26,7 @@ export function ProcessingStatusStrip() { style={{ background: TIER_INFO[tier].dotColor }} aria-hidden /> - {TIER_INFO[tier].label} + {t(TIER_INFO[tier].labelKey)} ยท diff --git a/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx b/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx index 20cbcab89c..a411f7f680 100644 --- a/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx @@ -1,7 +1,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { StatusBadge, Tabs, type TabItem } from "@app/ui"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel"; import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel"; import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel"; @@ -52,7 +56,7 @@ export function AgentBuilderPanel({
- {agent.status} + {t(AGENT_STATUS_LABEL[agent.status])} {agent.version} diff --git a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx index 9891c10d03..bb01bf914b 100644 --- a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx +++ b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx @@ -1,5 +1,9 @@ import { useTranslation } from "react-i18next"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import { Button, StatusBadge } from "@app/ui"; import "@portal/views/AgentBuilder.css"; @@ -40,7 +44,7 @@ export function AgentSelector({ - {a.status} + {t(AGENT_STATUS_LABEL[a.status])} {a.version} diff --git a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx index 20d71dbfbc..3b63fe3045 100644 --- a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx @@ -1,6 +1,10 @@ import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@app/ui"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; interface VersionsPanelProps { @@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {v.version} - {v.status} + {t(AGENT_STATUS_LABEL[v.status])} {isCurrent && ( diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx index 337cfc6119..0599d80d72 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx @@ -12,9 +12,11 @@ import { } from "@app/ui"; import { type SdkComponent, + BILLING_UNIT_LABEL, MATURITY_META, formatPrice, } from "@portal/api/sdkComponents"; +import { TIER_INFO } from "@portal/contexts/TierContext"; import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable"; import "@portal/views/Components.css"; @@ -113,7 +115,7 @@ export function ComponentDetailModal({ title={t("portal.catalogue.detail.locked.title")} description={t("portal.catalogue.detail.locked.description", { name: component.name, - tier: component.minTier, + plan: t(TIER_INFO[component.minTier].labelKey), })} /> )} @@ -205,7 +207,7 @@ export function ComponentDetailModal({ />

{t("portal.catalogue.detail.pricing.note", { - unit: component.pricing.unit, + unit: t(BILLING_UNIT_LABEL[component.pricing.unit]), })}

diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index ce78ded6fa..69e23cb406 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { rightSection={ - {KEY_LABEL[apiKey.status]} + {t(KEY_LABEL[apiKey.status])} - {group.label} + {t(group.label)} {blurb && ( diff --git a/frontend/editor/src/portal/components/users/PendingInvitations.tsx b/frontend/editor/src/portal/components/users/PendingInvitations.tsx index acf9f96386..5c6fd65b5a 100644 --- a/frontend/editor/src/portal/components/users/PendingInvitations.tsx +++ b/frontend/editor/src/portal/components/users/PendingInvitations.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { Avatar, Button } from "@app/ui"; import type { PendingInvitation } from "@portal/api/users"; import "@portal/views/Users.css"; @@ -11,13 +12,15 @@ interface PendingInvitationsProps { /** Human "Expires in 3 days" from an ISO expiry; empty when absent, unparseable, * or already past (the adapter filters expired invites, so no "expired" state). */ -function expiryLabel(iso: string | undefined, expiresWord: string): string { +function expiryLabel(iso: string | undefined, t: TFunction): string { if (!iso) return ""; const ts = Date.parse(iso); if (!Number.isFinite(ts) || ts <= Date.now()) return ""; const days = Math.round((ts - Date.now()) / 86400000); - if (days === 0) return `${expiresWord} today`; - return `${expiresWord} in ${days === 1 ? "1 day" : `${days} days`}`; + if (days === 0) return t("users.invites.expiresToday", "Expires today"); + return t("users.invites.expiresInDays", "Expires in {{count}} days", { + count: days, + }); } /** @@ -30,7 +33,6 @@ export function PendingInvitations({ onCancel, }: PendingInvitationsProps) { const { t } = useTranslation(); - const expiresWord = t("users.invites.expires", "Expires"); return (
@@ -50,7 +52,7 @@ export function PendingInvitations({
{invitations.map((inv) => { - const expires = expiryLabel(inv.expiresAt, expiresWord); + const expires = expiryLabel(inv.expiresAt, t); return (
diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index cb8a730640..152e1f7a0b 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -10,16 +10,20 @@ import { usePlanTier } from "@portal/contexts/usePlanTier"; export type Tier = "free" | "pro" | "enterprise"; export interface TierInfo { - label: string; + /** i18n key for the plan label; resolve with `t()` at the call site. */ + labelKey: string; dotColor: string; } export const TIER_INFO: Record = { // Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free // manual-tools tier is "Editor plan"; the metered tier is "Processor plan". - free: { label: "Editor plan", dotColor: "var(--color-text-4)" }, - pro: { label: "Processor plan", dotColor: "var(--color-blue)" }, - enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" }, + free: { labelKey: "portal.tier.free", dotColor: "var(--color-text-4)" }, + pro: { labelKey: "portal.tier.pro", dotColor: "var(--color-blue)" }, + enterprise: { + labelKey: "portal.tier.enterprise", + dotColor: "var(--color-purple)", + }, }; interface TierContextValue { diff --git a/frontend/editor/src/portal/mocks/procurement.ts b/frontend/editor/src/portal/mocks/procurement.ts index 1f3f5a1934..6976601d7b 100644 --- a/frontend/editor/src/portal/mocks/procurement.ts +++ b/frontend/editor/src/portal/mocks/procurement.ts @@ -51,7 +51,7 @@ const ENTERPRISE_DEAL: Deal = { const ENTERPRISE_LEDGER: LedgerGroup[] = [ { stage: "trial", - label: "Trial", + label: "portal.procurement.journeySteps.trial.label", docs: [ { id: "doc-trial-quickstart", @@ -71,7 +71,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "quote", - label: "Quote", + label: "portal.procurement.journeySteps.quote.label", docs: [ { id: "doc-quote-formal", @@ -84,7 +84,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "security", - label: "Agreement", + label: "portal.procurement.journeySteps.agreement.label", docs: [ { id: "doc-agreement-enterprise", @@ -97,7 +97,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "procurement", - label: "Payment", + label: "portal.procurement.journeySteps.payment.label", docs: [ { id: "doc-pay-online", @@ -124,7 +124,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "active", - label: "Implementation", + label: "portal.procurement.journeySteps.implementation.label", docs: [ { id: "doc-active-playbook", diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 0795cef0f0..8d58ed0e79 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -67,7 +67,7 @@ export function Policies() { ) { setPageError(null); try { - await savePolicy(buildWireFromSetup(entry, result)); + await savePolicy(buildWireFromSetup(entry, result, t)); setWizard(null); setDetail(null); refetch(); @@ -97,7 +97,7 @@ export function Policies() { if (!entry || !policy?.state.backendId) return; const enabled = policy.state.status === "paused"; void runLifecycle(() => - savePolicy(buildWireFromState(entry, policy, enabled)), + savePolicy(buildWireFromState(entry, policy, enabled, t)), ); }