i18n(policies): route policy UI strings through i18n (English only) (#6628)

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-06-11 23:37:43 +01:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e88d22d2fc
commit cc1235bbf2
8 changed files with 460 additions and 102 deletions
@@ -5865,6 +5865,106 @@ deleteConfirmTitle = "Delete {{label}} policy?"
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?" deleteConfirmTitle = "Delete {{label}} policy?"
[policies.catalog]
compliance = "Compliance"
ingestion = "Ingestion"
retention = "Retention"
routing = "Routing"
security = "Security"
[policies.detail]
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organization. Contact an admin to change this policy."
noActivityDescription = "Documents will appear here once this policy runs."
noActivityTitle = "No activity yet"
onEveryUpload = "On every upload"
originalsNote = "Originals stay untouched • Enforced version saved alongside"
pause = "Pause"
recentActivity = "Recent Activity"
resume = "Resume"
retry = "Retry"
showLess = "Show less"
showMore = "Show more"
statActive = "Active"
statDataProcessed = "Data processed"
statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.fields]
selectedCount = "{{count}} selected"
[policies.pii]
fieldLabel = "PII to redact"
placeholder = "Select PII types"
[policies.sidebar]
activeCount = "{{count}} active"
infoAriaLabel = "What is a policy?"
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
loading = "Loading…"
railAriaLabel = "{{label}} policy — {{status}}"
railSuffixActive = " (Active)"
railSuffixPaused = " (Paused)"
setUp = "Set up"
title = "Policies"
upgradeToEnterprise = "Upgrade to enterprise"
[policies.status]
active = "Active"
paused = "Paused"
setup = "Set up"
[policies.toolConfig]
enableAriaLabel = "Enable {{tool}}"
infoAriaLabel = "What does {{tool}} do?"
[policies.wizard]
allDocTypesDescription = "Enable the Classification policy to filter by document type."
allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
editTitle = "Edit {{label}} Policy"
enablePolicy = "Enable Policy"
filenameAutoNumber = "Auto-number"
filenamePositionAria = "Filename position"
filenamePrefix = "Prefix"
filenameSuffix = "Suffix"
filenameTextAria = "Filename text"
filenameTextPlaceholder = "Text to add (optional)"
lockedDescription = "Contact an admin to change this policy."
lockedTitle = "Managed by your organization"
maxRetriesLabel = "Max retries"
noToolsError = "Add at least one configured tool to the workflow first."
outputAsLabel = "Output as"
outputFilenameSubhead = "Output filename"
outputModeAria = "Output mode"
outputNewFile = "New file"
outputNewVersion = "New version"
outputRetriesLabel = "Output & retries"
outputSubhead = "Output"
retryDelayAria = "Retry delay minutes"
retryDelayLabel = "Retry delay (min)"
runOnExport = "Export"
runOnLabel = "Run on"
runOnSubhead = "Run on"
runOnUpload = "Upload"
saveChanges = "Save Changes"
saveError = "Couldn't save the policy. Please try again."
setupClassification = "Set up Classification"
setupTitle = "Set up {{label}} Policy"
sourcesDesc = "Choose where this policy runs and which document types it applies to."
sourcesLabel = "Sources"
stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected"
[printFile] [printFile]
title = "Print File" title = "Print File"
@@ -4,6 +4,29 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
// The global setup mock returns the i18n KEY (t: (key) => key); these tests
// assert the rendered English copy, so override locally to return the default
// value passed to t() — mirroring i18next's runtime fallback when a key is
// missing. (Interpolated strings are returned raw; no test here asserts them.)
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) =>
typeof defaultValue === "string" ? defaultValue : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
I18nextProvider: ({ children }: { children: ReactNode }) => children,
}));
// usePolicies derives `canConfigure` from app-config; with no AppConfigProvider
// here `config` is null, which (tri-state gate) hides the edit affordances. Mock
// app-config as a single-user deployment (login off) so the local operator can
// configure and the narrative view's Edit/Pause/Delete actions render.
vi.mock("@app/contexts/AppConfigContext", async (orig) => ({
...(await orig<typeof import("@app/contexts/AppConfigContext")>()),
useAppConfig: () => ({ config: { enableLogin: false } }),
}));
// The shared Tooltip (used by the "what is a policy?" info button) pulls in // The shared Tooltip (used by the "what is a policy?" info button) pulls in
// preferences/sidebar contexts we don't set up here — passthrough it. // preferences/sidebar contexts we don't set up here — passthrough it.
vi.mock("@app/components/shared/Tooltip", () => ({ vi.mock("@app/components/shared/Tooltip", () => ({
@@ -13,6 +13,7 @@
*/ */
import { useState, useEffect, useMemo, type ReactNode } from "react"; import { useState, useEffect, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
import { usePolicies } from "@app/hooks/usePolicies"; import { usePolicies } from "@app/hooks/usePolicies";
@@ -75,6 +76,7 @@ export function PoliciesSection({
* collapse button), mirroring the back-button + title in a policy. */ * collapse button), mirroring the back-button + title in a policy. */
leadingControl?: ReactNode; leadingControl?: ReactNode;
} = {}) { } = {}) {
const { t } = useTranslation();
const pol = usePolicies(); const pol = usePolicies();
const { categories } = usePolicyCatalog(); const { categories } = usePolicyCatalog();
// Persist the expand/collapse state across refreshes. // Persist the expand/collapse state across refreshes.
@@ -109,21 +111,26 @@ export function PoliciesSection({
<div className="pol-list-head"> <div className="pol-list-head">
{leadingControl} {leadingControl}
<SectionHeader <SectionHeader
title="Policies" title={t("policies.sidebar.title", "Policies")}
count={`${configuredCount} active`} count={t("policies.sidebar.activeCount", "{{count}} active", {
count: configuredCount,
})}
collapsible collapsible
expanded={expanded} expanded={expanded}
onToggle={toggleExpanded} onToggle={toggleExpanded}
/> />
<AppTooltip <AppTooltip
content="A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps." content={t(
"policies.sidebar.infoTooltip",
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
)}
sidebarTooltip sidebarTooltip
pinOnClick pinOnClick
> >
<button <button
type="button" type="button"
className="pol-info-btn" className="pol-info-btn"
aria-label="What is a policy?" aria-label={t("policies.sidebar.infoAriaLabel", "What is a policy?")}
> >
<LocalIcon <LocalIcon
icon="info-outline-rounded" icon="info-outline-rounded"
@@ -149,10 +156,15 @@ export function PoliciesSection({
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}> <IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon} {cat.icon}
</IconBadge> </IconBadge>
<span className="pol-row-label">{cat.label}</span> <span className="pol-row-label">
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
<span className="pol-row-trail"> <span className="pol-row-trail">
<span className="pol-row-soon"> <span className="pol-row-soon">
Upgrade to enterprise {t(
"policies.sidebar.upgradeToEnterprise",
"Upgrade to enterprise",
)}
</span> </span>
</span> </span>
</div> </div>
@@ -169,16 +181,20 @@ export function PoliciesSection({
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}> <IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon} {cat.icon}
</IconBadge> </IconBadge>
<span className="pol-row-label">{cat.label}</span> <span className="pol-row-label">
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
<span className="pol-row-trail"> <span className="pol-row-trail">
{status === "setup" ? ( {status === "setup" ? (
<span className="pol-row-setup">Set up</span> <span className="pol-row-setup">
{t("policies.sidebar.setUp", "Set up")}
</span>
) : ( ) : (
<StatusBadge <StatusBadge
tone={status === "active" ? "success" : "warning"} tone={status === "active" ? "success" : "warning"}
size="sm" size="sm"
> >
{STATUS_LABEL[status]} {t(`policies.status.${status}`, STATUS_LABEL[status])}
</StatusBadge> </StatusBadge>
)} )}
<ChevronRightIcon <ChevronRightIcon
@@ -201,6 +217,7 @@ export function PoliciesSection({
* which replaces the Tools area while a policy is selected. * which replaces the Tools area while a policy is selected.
*/ */
export function PolicyDetailTakeover() { export function PolicyDetailTakeover() {
const { t } = useTranslation();
const pol = usePolicies(); const pol = usePolicies();
const { categories, configs, sources, docTypes } = usePolicyCatalog(); const { categories, configs, sources, docTypes } = usePolicyCatalog();
const { selectedId, detailView } = usePolicySelection(); const { selectedId, detailView } = usePolicySelection();
@@ -311,7 +328,9 @@ export function PolicyDetailTakeover() {
return ( return (
<div className="pol-detail"> <div className="pol-detail">
<div className="pol-scroll"> <div className="pol-scroll">
<p className="pol-desc">Loading</p> <p className="pol-desc">
{t("policies.sidebar.loading", "Loading…")}
</p>
</div> </div>
</div> </div>
); );
@@ -404,6 +423,7 @@ export function PoliciesCollapsedButton({
}: { }: {
onExpand: () => void; onExpand: () => void;
}) { }) {
const { t } = useTranslation();
const pol = usePolicies(); const pol = usePolicies();
const { categories } = usePolicyCatalog(); const { categories } = usePolicyCatalog();
@@ -416,16 +436,21 @@ export function PoliciesCollapsedButton({
.filter((cat) => !cat.comingSoon) .filter((cat) => !cat.comingSoon)
.map((cat) => { .map((cat) => {
const status = deriveRowStatus(pol.policies[cat.id]); const status = deriveRowStatus(pol.policies[cat.id]);
const label = t(`policies.catalog.${cat.id}`, cat.label);
const statusLabel = t(
`policies.status.${status}`,
STATUS_LABEL[status],
);
const suffix = const suffix =
status === "active" status === "active"
? " (Active)" ? t("policies.sidebar.railSuffixActive", " (Active)")
: status === "paused" : status === "paused"
? " (Paused)" ? t("policies.sidebar.railSuffixPaused", " (Paused)")
: ""; : "";
return ( return (
<AppTooltip <AppTooltip
key={cat.id} key={cat.id}
content={`${cat.label}${suffix}`} content={`${label}${suffix}`}
position="left" position="left"
arrow arrow
delay={300} delay={300}
@@ -434,7 +459,11 @@ export function PoliciesCollapsedButton({
type="button" type="button"
className="pol-crail-btn" className="pol-crail-btn"
data-status={status} data-status={status}
aria-label={`${cat.label} policy — ${STATUS_LABEL[status]}`} aria-label={t(
"policies.sidebar.railAriaLabel",
"{{label}} policy — {{status}}",
{ label, status: statusLabel },
)}
onClick={() => { onClick={() => {
selectPolicy(cat.id); selectPolicy(cat.id);
onExpand(); onExpand();
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import PublicIcon from "@mui/icons-material/Public"; import PublicIcon from "@mui/icons-material/Public";
import ScheduleIcon from "@mui/icons-material/Schedule"; import ScheduleIcon from "@mui/icons-material/Schedule";
import HistoryIcon from "@mui/icons-material/History"; import HistoryIcon from "@mui/icons-material/History";
@@ -66,7 +67,13 @@ function humanizeOperation(op: string): string {
* lengthy is clamped and collapsed by default with a Show more/less toggle. * lengthy is clamped and collapsed by default with a Show more/less toggle.
* Short messages (e.g. "Enforcement failed") render plainly with no toggle. * Short messages (e.g. "Enforcement failed") render plainly with no toggle.
*/ */
function ActivityError({ message }: { message: string }) { function ActivityError({
message,
t,
}: {
message: string;
t: (key: string, defaultValue: string) => string;
}) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const needsToggle = message.length > 80 || message.includes("\n"); const needsToggle = message.length > 80 || message.includes("\n");
if (!needsToggle) return <>{message}</>; if (!needsToggle) return <>{message}</>;
@@ -82,7 +89,9 @@ function ActivityError({ message }: { message: string }) {
className="pol-activity-error__toggle" className="pol-activity-error__toggle"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
{expanded ? "Show less" : "Show more"} {expanded
? t("policies.detail.showLess", "Show less")
: t("policies.detail.showMore", "Show more")}
</button> </button>
</span> </span>
); );
@@ -104,6 +113,7 @@ export function PolicyDetailPanel({
onDelete, onDelete,
onRetry, onRetry,
}: PolicyDetailPanelProps) { }: PolicyDetailPanelProps) {
const { t } = useTranslation();
const isPaused = status === "paused"; const isPaused = status === "paused";
// Real configured steps drive the flow; fall back to the preset's rule labels. // Real configured steps drive the flow; fall back to the preset's rule labels.
const enforceItems = const enforceItems =
@@ -123,7 +133,7 @@ export function PolicyDetailPanel({
<PanelHeader <PanelHeader
icon={category.icon} icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"} iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={category.label} title={t(`policies.catalog.${category.id}`, category.label)}
onBack={onBack} onBack={onBack}
actions={ actions={
<StatusBadge <StatusBadge
@@ -131,7 +141,9 @@ export function PolicyDetailPanel({
showDot showDot
pulse={!isPaused} pulse={!isPaused}
> >
{isPaused ? "Paused" : "Active"} {isPaused
? t("policies.detail.statusPaused", "Paused")
: t("policies.detail.statusActive", "Active")}
</StatusBadge> </StatusBadge>
} }
/> />
@@ -139,7 +151,9 @@ export function PolicyDetailPanel({
<div className="pol-scroll"> <div className="pol-scroll">
{/* Enforces */} {/* Enforces */}
<div> <div>
<p className="pol-section-label">Enforces</p> <p className="pol-section-label">
{t("policies.detail.enforces", "Enforces")}
</p>
<Card padding="default"> <Card padding="default">
<div className="pol-rule-flow"> <div className="pol-rule-flow">
<ChipFlow items={enforceItems} separator="arrow" /> <ChipFlow items={enforceItems} separator="arrow" />
@@ -151,19 +165,24 @@ export function PolicyDetailPanel({
</span> </span>
<span className="pol-meta-item"> <span className="pol-meta-item">
<ScheduleIcon sx={{ fontSize: "0.8rem" }} /> <ScheduleIcon sx={{ fontSize: "0.8rem" }} />
On every upload {t("policies.detail.onEveryUpload", "On every upload")}
</span> </span>
</div> </div>
<div className="pol-note"> <div className="pol-note">
<HistoryIcon sx={{ fontSize: "0.8rem" }} /> <HistoryIcon sx={{ fontSize: "0.8rem" }} />
Originals stay untouched Enforced version saved alongside {t(
"policies.detail.originalsNote",
"Originals stay untouched • Enforced version saved alongside",
)}
</div> </div>
</Card> </Card>
</div> </div>
{/* Recent Activity */} {/* Recent Activity */}
<div> <div>
<p className="pol-section-label">Recent Activity</p> <p className="pol-section-label">
{t("policies.detail.recentActivity", "Recent Activity")}
</p>
{activityItems.length > 0 ? ( {activityItems.length > 0 ? (
<Card padding="none"> <Card padding="none">
{activityItems.map((item, i) => ( {activityItems.map((item, i) => (
@@ -192,7 +211,7 @@ export function PolicyDetailPanel({
title={item.doc} title={item.doc}
description={ description={
item.status === "flagged" ? ( item.status === "flagged" ? (
<ActivityError message={item.action} /> <ActivityError message={item.action} t={t} />
) : ( ) : (
item.action item.action
) )
@@ -205,7 +224,7 @@ export function PolicyDetailPanel({
size="sm" size="sm"
onClick={() => onRetry(item)} onClick={() => onRetry(item)}
> >
Retry {t("policies.detail.retry", "Retry")}
</Button> </Button>
) : undefined ) : undefined
} }
@@ -217,8 +236,11 @@ export function PolicyDetailPanel({
<EmptyState <EmptyState
size="compact" size="compact"
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />} icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
title="No activity yet" title={t("policies.detail.noActivityTitle", "No activity yet")}
description="Documents will appear here once this policy runs." description={t(
"policies.detail.noActivityDescription",
"Documents will appear here once this policy runs.",
)}
/> />
</Card> </Card>
)} )}
@@ -232,15 +254,21 @@ export function PolicyDetailPanel({
<span className="pol-stat-value"> <span className="pol-stat-value">
{statValues.enforced.toLocaleString()} {statValues.enforced.toLocaleString()}
</span> </span>
<span className="pol-stat-label">Docs enforced</span> <span className="pol-stat-label">
{t("policies.detail.statDocsEnforced", "Docs enforced")}
</span>
</div> </div>
<div className="pol-stat"> <div className="pol-stat">
<span className="pol-stat-value">{statValues.dataProcessed}</span> <span className="pol-stat-value">{statValues.dataProcessed}</span>
<span className="pol-stat-label">Data processed</span> <span className="pol-stat-label">
{t("policies.detail.statDataProcessed", "Data processed")}
</span>
</div> </div>
<div className="pol-stat"> <div className="pol-stat">
<span className="pol-stat-value">{statValues.activeFor}</span> <span className="pol-stat-value">{statValues.activeFor}</span>
<span className="pol-stat-label">Active</span> <span className="pol-stat-label">
{t("policies.detail.statActive", "Active")}
</span>
</div> </div>
</div> </div>
</Card> </Card>
@@ -249,7 +277,10 @@ export function PolicyDetailPanel({
<Banner <Banner
tone="neutral" tone="neutral"
icon={<LockIcon sx={{ fontSize: "1rem" }} />} icon={<LockIcon sx={{ fontSize: "1rem" }} />}
description="Managed by your organization. Contact an admin to change this policy." description={t(
"policies.detail.managedByOrg",
"Managed by your organization. Contact an admin to change this policy.",
)}
/> />
)} )}
</div> </div>
@@ -265,14 +296,16 @@ export function PolicyDetailPanel({
onClick={onDelete} onClick={onDelete}
style={{ marginRight: "auto" }} style={{ marginRight: "auto" }}
> >
Delete {t("delete", "Delete")}
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={onTogglePause}> <Button variant="outline" size="sm" onClick={onTogglePause}>
{isPaused ? "Resume" : "Pause"} {isPaused
? t("policies.detail.resume", "Resume")
: t("policies.detail.pause", "Pause")}
</Button> </Button>
<Button variant="gradient" size="sm" onClick={onEditSettings}> <Button variant="gradient" size="sm" onClick={onEditSettings}>
Edit Settings {t("policies.detail.editSettings", "Edit Settings")}
</Button> </Button>
</div> </div>
)} )}
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "@shared/components/ToggleSwitch"; import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Select } from "@shared/components/Select"; import { Select } from "@shared/components/Select";
import { Input } from "@shared/components/Input"; import { Input } from "@shared/components/Input";
@@ -25,6 +26,11 @@ export function PolicyFieldRow({
onChange, onChange,
first, first,
}: PolicyFieldRowProps) { }: PolicyFieldRowProps) {
const { t } = useTranslation();
// Field labels and option labels come from the policy catalog data, so they're
// wrapped at the render site with data-keyed ids (English stays the fallback).
const fieldLabel = t(`policies.field.${field.key}`, field.label);
if (field.type === "chips") { if (field.type === "chips") {
const selected = Array.isArray(value) ? value : []; const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) => const toggle = (opt: string) =>
@@ -36,8 +42,12 @@ export function PolicyFieldRow({
return ( return (
<div className="pol-field" data-first={first || undefined}> <div className="pol-field" data-first={first || undefined}>
<div className="pol-field-chips-head"> <div className="pol-field-chips-head">
<span className="pol-field-label">{field.label}</span> <span className="pol-field-label">{fieldLabel}</span>
<span className="pol-field-count">{selected.length} selected</span> <span className="pol-field-count">
{t("policies.fields.selectedCount", "{{count}} selected", {
count: selected.length,
})}
</span>
</div> </div>
<div className="pol-field-chips"> <div className="pol-field-chips">
{(field.options ?? []).map((opt) => ( {(field.options ?? []).map((opt) => (
@@ -47,7 +57,7 @@ export function PolicyFieldRow({
size="sm" size="sm"
onClick={() => toggle(opt)} onClick={() => toggle(opt)}
> >
{opt} {t(`policies.fieldOption.${field.key}.${opt}`, opt)}
</Chip> </Chip>
))} ))}
</div> </div>
@@ -61,28 +71,31 @@ export function PolicyFieldRow({
size="sm" size="sm"
checked={Boolean(value)} checked={Boolean(value)}
onChange={(checked) => onChange(checked)} onChange={(checked) => onChange(checked)}
aria-label={field.label} aria-label={fieldLabel}
/> />
) : field.type === "select" ? ( ) : field.type === "select" ? (
<Select <Select
inputSize="sm" inputSize="sm"
options={(field.options ?? []).map((o) => ({ value: o, label: o }))} options={(field.options ?? []).map((o) => ({
value: o,
label: t(`policies.fieldOption.${field.key}.${o}`, o),
}))}
value={typeof value === "string" ? value : ""} value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
aria-label={field.label} aria-label={fieldLabel}
/> />
) : ( ) : (
<Input <Input
inputSize="sm" inputSize="sm"
value={typeof value === "string" ? value : ""} value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
aria-label={field.label} aria-label={fieldLabel}
/> />
); );
return ( return (
<div className="pol-field" data-first={first || undefined}> <div className="pol-field" data-first={first || undefined}>
<SettingsRow label={field.label} control={control} /> <SettingsRow label={fieldLabel} control={control} />
</div> </div>
); );
} }
@@ -1,4 +1,5 @@
import { MultiSelect } from "@mantine/core"; import { MultiSelect } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions"; import { PII_PRESETS } from "@app/data/policyDefinitions";
/** The set of preset regexes — used to separate preset words from custom ones. */ /** The set of preset regexes — used to separate preset words from custom ones. */
@@ -24,6 +25,7 @@ export function PolicyPiiField({
onChange, onChange,
disabled, disabled,
}: PolicyPiiFieldProps) { }: PolicyPiiFieldProps) {
const { t } = useTranslation();
const words = Array.isArray(parameters.wordsToRedact) const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[]) ? (parameters.wordsToRedact as string[])
: []; : [];
@@ -48,9 +50,14 @@ export function PolicyPiiField({
return ( return (
<MultiSelect <MultiSelect
size="sm" size="sm"
label="PII to redact" label={t("policies.pii.fieldLabel", "PII to redact")}
placeholder="Select PII types" placeholder={t("policies.pii.placeholder", "Select PII types")}
data={PII_PRESETS.map((p) => ({ value: p.value, label: p.label }))} data={PII_PRESETS.map((p) => ({
value: p.value,
// Preset labels are catalog data — keyed by preset value with the
// English label as fallback.
label: t(`policies.pii.${p.value}`, p.label),
}))}
value={selected} value={selected}
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
@@ -1,4 +1,5 @@
import { useState, useMemo, useRef } from "react"; import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { PanelHeader } from "@shared/components/PanelHeader"; import { PanelHeader } from "@shared/components/PanelHeader";
@@ -98,6 +99,7 @@ export function PolicySetupWizard({
onCommitConfig, onCommitConfig,
onSetupClassification, onSetupClassification,
}: PolicySetupWizardProps) { }: PolicySetupWizardProps) {
const { t } = useTranslation();
const isEdit = mode === "edit"; const isEdit = mode === "edit";
// Preset (tool-chain) policies render the locked tool config as their Workflow // Preset (tool-chain) policies render the locked tool config as their Workflow
// step instead of the add/remove builder. // step instead of the add/remove builder.
@@ -158,13 +160,27 @@ export function PolicySetupWizard({
<PanelHeader <PanelHeader
icon={category.icon} icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"} iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`} title={
isEdit
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
}
onBack={onCancel} onBack={onCancel}
/> />
<div className="pol-scroll"> <div className="pol-scroll">
<EmptyState <EmptyState
title="Managed by your organization" title={t(
description="Contact an admin to change this policy." "policies.wizard.lockedTitle",
"Managed by your organization",
)}
description={t(
"policies.wizard.lockedDescription",
"Contact an admin to change this policy.",
)}
/> />
</div> </div>
</div> </div>
@@ -212,7 +228,12 @@ export function PolicySetupWizard({
}), }),
).catch(() => { ).catch(() => {
setSubmitting(false); setSubmitting(false);
setSaveError("Couldn't save the policy. Please try again."); setSaveError(
t(
"policies.wizard.saveError",
"Couldn't save the policy. Please try again.",
),
);
}); });
}; };
@@ -243,7 +264,12 @@ export function PolicySetupWizard({
}), }),
).catch(() => { ).catch(() => {
setSubmitting(false); setSubmitting(false);
setSaveError("Couldn't save the policy. Please try again."); setSaveError(
t(
"policies.wizard.saveError",
"Couldn't save the policy. Please try again.",
),
);
}); });
}; };
@@ -260,7 +286,12 @@ export function PolicySetupWizard({
// the user back to the Workflow step to fix it. // the user back to the Workflow step to fix it.
const handleSaveFailed = () => { const handleSaveFailed = () => {
setSubmitting(false); setSubmitting(false);
setSaveError("Add at least one configured tool to the workflow first."); setSaveError(
t(
"policies.wizard.noToolsError",
"Add at least one configured tool to the workflow first.",
),
);
setStep(1); setStep(1);
}; };
@@ -269,14 +300,25 @@ export function PolicySetupWizard({
<PanelHeader <PanelHeader
icon={category.icon} icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"} iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`} title={
subtitle={`Step ${step} of ${TOTAL_STEPS}`} isEdit
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
label: t(`policies.catalog.${category.id}`, category.label),
})
}
subtitle={t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
step,
total: TOTAL_STEPS,
})}
onBack={back} onBack={back}
actions={ actions={
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label="Cancel" aria-label={t("cancel", "Cancel")}
onClick={onCancel} onClick={onCancel}
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />} leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
/> />
@@ -302,7 +344,10 @@ export function PolicySetupWizard({
{toolChain ? ( {toolChain ? (
<> <>
<p className="pol-desc"> <p className="pol-desc">
Configure the tools this policy runs on each document. {t(
"policies.wizard.toolChainDesc",
"Configure the tools this policy runs on each document.",
)}
</p> </p>
<PolicyToolConfigStep <PolicyToolConfigStep
chainIds={toolChain} chainIds={toolChain}
@@ -318,7 +363,10 @@ export function PolicySetupWizard({
) : ( ) : (
<> <>
<p className="pol-desc"> <p className="pol-desc">
Build the sequence of tools this policy runs on each document. {t(
"policies.wizard.builderDesc",
"Build the sequence of tools this policy runs on each document.",
)}
</p> </p>
<PolicyWorkflowStep <PolicyWorkflowStep
automation={seedAutomation} automation={seedAutomation}
@@ -351,14 +399,18 @@ export function PolicySetupWizard({
)} )}
{/* Real, working output + retry settings (applied by the engine). */} {/* Real, working output + retry settings (applied by the engine). */}
<p className="pol-section-label">Output &amp; retries</p> <p className="pol-section-label">
{t("policies.wizard.outputRetriesLabel", "Output & retries")}
</p>
<Card padding="none"> <Card padding="none">
{/* The editor event the policy runs on: input on upload, or {/* The editor event the policy runs on: input on upload, or
output on export (enforced before the file is exported). */} output on export (enforced before the file is exported). */}
<div className="pol-subhead">Run on</div> <div className="pol-subhead">
{t("policies.wizard.runOnSubhead", "Run on")}
</div>
<div className="pol-field" data-first> <div className="pol-field" data-first>
<SettingsRow <SettingsRow
label="Run on" label={t("policies.wizard.runOnLabel", "Run on")}
control={ control={
<Select <Select
inputSize="sm" inputSize="sm"
@@ -366,19 +418,27 @@ export function PolicySetupWizard({
onChange={(e) => onChange={(e) =>
setRunOn(e.target.value as "upload" | "export") setRunOn(e.target.value as "upload" | "export")
} }
aria-label="Run on" aria-label={t("policies.wizard.runOnLabel", "Run on")}
options={[ options={[
{ value: "upload", label: "Upload" }, {
{ value: "export", label: "Export" }, value: "upload",
label: t("policies.wizard.runOnUpload", "Upload"),
},
{
value: "export",
label: t("policies.wizard.runOnExport", "Export"),
},
]} ]}
/> />
} }
/> />
</div> </div>
<div className="pol-subhead">Output</div> <div className="pol-subhead">
{t("policies.wizard.outputSubhead", "Output")}
</div>
<div className="pol-field" data-first> <div className="pol-field" data-first>
<SettingsRow <SettingsRow
label="Output as" label={t("policies.wizard.outputAsLabel", "Output as")}
control={ control={
<Select <Select
inputSize="sm" inputSize="sm"
@@ -397,17 +457,31 @@ export function PolicySetupWizard({
setOutputNamePosition("suffix"); setOutputNamePosition("suffix");
} }
}} }}
aria-label="Output mode" aria-label={t(
"policies.wizard.outputModeAria",
"Output mode",
)}
options={[ options={[
{ value: "new_file", label: "New file" }, {
{ value: "new_version", label: "New version" }, value: "new_file",
label: t("policies.wizard.outputNewFile", "New file"),
},
{
value: "new_version",
label: t(
"policies.wizard.outputNewVersion",
"New version",
),
},
]} ]}
/> />
} }
/> />
</div> </div>
{/* Output filename: position + custom text together as one row. */} {/* Output filename: position + custom text together as one row. */}
<div className="pol-subhead">Output filename</div> <div className="pol-subhead">
{t("policies.wizard.outputFilenameSubhead", "Output filename")}
</div>
<div className="pol-field" data-first> <div className="pol-field" data-first>
<div className="pol-name-row"> <div className="pol-name-row">
<Select <Select
@@ -418,13 +492,30 @@ export function PolicySetupWizard({
e.target.value as "prefix" | "suffix" | "auto-number", e.target.value as "prefix" | "suffix" | "auto-number",
) )
} }
aria-label="Filename position" aria-label={t(
"policies.wizard.filenamePositionAria",
"Filename position",
)}
options={[ options={[
{ value: "prefix", label: "Prefix" }, {
{ value: "suffix", label: "Suffix" }, value: "prefix",
label: t("policies.wizard.filenamePrefix", "Prefix"),
},
{
value: "suffix",
label: t("policies.wizard.filenameSuffix", "Suffix"),
},
// Auto-number only makes sense for separate new files. // Auto-number only makes sense for separate new files.
...(outputMode === "new_file" ...(outputMode === "new_file"
? [{ value: "auto-number", label: "Auto-number" }] ? [
{
value: "auto-number",
label: t(
"policies.wizard.filenameAutoNumber",
"Auto-number",
),
},
]
: []), : []),
]} ]}
/> />
@@ -435,15 +526,21 @@ export function PolicySetupWizard({
inputSize="sm" inputSize="sm"
value={outputName} value={outputName}
onChange={(e) => setOutputName(e.target.value)} onChange={(e) => setOutputName(e.target.value)}
placeholder="Text to add (optional)" placeholder={t(
aria-label="Filename text" "policies.wizard.filenameTextPlaceholder",
"Text to add (optional)",
)}
aria-label={t(
"policies.wizard.filenameTextAria",
"Filename text",
)}
/> />
)} )}
</div> </div>
</div> </div>
<div className="pol-field"> <div className="pol-field">
<SettingsRow <SettingsRow
label="Max retries" label={t("policies.wizard.maxRetriesLabel", "Max retries")}
control={ control={
<Input <Input
type="number" type="number"
@@ -452,14 +549,20 @@ export function PolicySetupWizard({
onChange={(e) => onChange={(e) =>
setMaxRetries(Math.max(0, Number(e.target.value) || 0)) setMaxRetries(Math.max(0, Number(e.target.value) || 0))
} }
aria-label="Max retries" aria-label={t(
"policies.wizard.maxRetriesLabel",
"Max retries",
)}
/> />
} }
/> />
</div> </div>
<div className="pol-field"> <div className="pol-field">
<SettingsRow <SettingsRow
label="Retry delay (min)" label={t(
"policies.wizard.retryDelayLabel",
"Retry delay (min)",
)}
control={ control={
<Input <Input
type="number" type="number"
@@ -470,7 +573,10 @@ export function PolicySetupWizard({
Math.max(0, Number(e.target.value) || 0), Math.max(0, Number(e.target.value) || 0),
) )
} }
aria-label="Retry delay minutes" aria-label={t(
"policies.wizard.retryDelayAria",
"Retry delay minutes",
)}
/> />
} }
/> />
@@ -484,10 +590,14 @@ export function PolicySetupWizard({
{SOURCES_IN_FLOW && step === 3 && ( {SOURCES_IN_FLOW && step === 3 && (
<> <>
<p className="pol-desc"> <p className="pol-desc">
Choose where this policy runs and which document types it applies {t(
to. "policies.wizard.sourcesDesc",
"Choose where this policy runs and which document types it applies to.",
)}
</p>
<p className="pol-section-label">
{t("policies.wizard.sourcesLabel", "Sources")}
</p> </p>
<p className="pol-section-label">Sources</p>
<Card padding="none"> <Card padding="none">
{sourceDefs.map((src, i) => ( {sourceDefs.map((src, i) => (
<div <div
@@ -506,20 +616,31 @@ export function PolicySetupWizard({
))} ))}
</Card> </Card>
<p className="pol-section-label">Document types</p> <p className="pol-section-label">
{t("policies.wizard.docTypesLabel", "Document types")}
</p>
{!classificationEnabled ? ( {!classificationEnabled ? (
<Banner <Banner
tone="warning" tone="warning"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />} icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
title="All document types" title={t(
description="Enable the Classification policy to filter by document type." "policies.wizard.allDocTypesTitle",
"All document types",
)}
description={t(
"policies.wizard.allDocTypesDescription",
"Enable the Classification policy to filter by document type.",
)}
action={ action={
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onSetupClassification} onClick={onSetupClassification}
> >
Set up Classification {t(
"policies.wizard.setupClassification",
"Set up Classification",
)}
</Button> </Button>
} }
/> />
@@ -528,14 +649,23 @@ export function PolicySetupWizard({
<div className="pol-doctypes-head"> <div className="pol-doctypes-head">
<span className="pol-field-label"> <span className="pol-field-label">
{scopeTypes.length === 0 {scopeTypes.length === 0
? "All document types" ? t(
: `${scopeTypes.length} types selected`} "policies.wizard.allDocTypesTitle",
"All document types",
)
: t(
"policies.wizard.typesSelected",
"{{count}} types selected",
{ count: scopeTypes.length },
)}
</span> </span>
<button <button
className="pol-link" className="pol-link"
onClick={() => setScopeNarrow((v) => !v)} onClick={() => setScopeNarrow((v) => !v)}
> >
{scopeNarrow ? "Clear" : "Edit"} {scopeNarrow
? t("policies.wizard.clear", "Clear")
: t("policies.wizard.edit", "Edit")}
</button> </button>
</div> </div>
{scopeNarrow && ( {scopeNarrow && (
@@ -564,7 +694,9 @@ export function PolicySetupWizard({
<div className="pol-footer"> <div className="pol-footer">
<Button variant="ghost" size="sm" onClick={back}> <Button variant="ghost" size="sm" onClick={back}>
{step > 1 ? "Back" : "Cancel"} {step > 1
? t("policies.wizard.back", "Back")
: t("cancel", "Cancel")}
</Button> </Button>
{step < TOTAL_STEPS ? ( {step < TOTAL_STEPS ? (
<Button <Button
@@ -573,7 +705,7 @@ export function PolicySetupWizard({
style={{ marginLeft: "auto" }} style={{ marginLeft: "auto" }}
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))} onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
> >
Continue {t("policies.wizard.continue", "Continue")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -583,7 +715,9 @@ export function PolicySetupWizard({
onClick={submit} onClick={submit}
disabled={submitting} disabled={submitting}
> >
{isEdit ? "Save Changes" : "Enable Policy"} {isEdit
? t("policies.wizard.saveChanges", "Save Changes")
: t("policies.wizard.enablePolicy", "Enable Policy")}
</Button> </Button>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Loader } from "@mantine/core"; import { Loader } from "@mantine/core";
import { ToggleSwitch } from "@shared/components/ToggleSwitch"; import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Card } from "@shared/components/Card"; import { Card } from "@shared/components/Card";
@@ -9,13 +10,24 @@ import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkC
import type { ToolRegistry } from "@app/data/toolsTaxonomy"; import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { ToolId } from "@app/types/toolId"; import type { ToolId } from "@app/types/toolId";
/** Plain-language, non-technical descriptions shown by each tool's info button. */ /**
const TOOL_PLAIN_INFO: Record<string, string> = { * Plain-language, non-technical descriptions shown by each tool's info button.
redact: * Stored as [i18n key, English default] pairs so they can be resolved with `t`
* at render (the map lives at module scope, outside the component).
*/
const TOOL_PLAIN_INFO: Record<string, readonly [key: string, en: string]> = {
redact: [
"policies.toolConfig.info.redact",
"Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.", "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.",
sanitize: ],
sanitize: [
"policies.toolConfig.info.sanitize",
"Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.", "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.",
watermark: "Stamps a visible mark (e.g. “Confidential”) across every page.", ],
watermark: [
"policies.toolConfig.info.watermark",
"Stamps a visible mark (e.g. “Confidential”) across every page.",
],
}; };
/** One tool in a policy's fixed chain: whether it runs + its configured params. */ /** One tool in a policy's fixed chain: whether it runs + its configured params. */
@@ -51,6 +63,7 @@ export function PolicyToolConfig({
onChange, onChange,
editable = true, editable = true,
}: PolicyToolConfigProps) { }: PolicyToolConfigProps) {
const { t } = useTranslation();
const patchTool = (index: number, patch: Partial<PolicyToolState>) => const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t))); onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
@@ -59,23 +72,27 @@ export function PolicyToolConfig({
{tools.map((tool, index) => { {tools.map((tool, index) => {
const entry = toolRegistry[tool.operation as ToolId]; const entry = toolRegistry[tool.operation as ToolId];
const Settings = entry?.automationSettings ?? null; const Settings = entry?.automationSettings ?? null;
const toolName = entry?.name ?? tool.operation;
const plainInfo = TOOL_PLAIN_INFO[tool.operation];
return ( return (
<Card key={tool.operation} padding="none"> <Card key={tool.operation} padding="none">
<div className="pol-tool-head"> <div className="pol-tool-head">
<span className="pol-tool-icon">{entry?.icon}</span> <span className="pol-tool-icon">{entry?.icon}</span>
<span className="pol-tool-name"> <span className="pol-tool-name">{toolName}</span>
{entry?.name ?? tool.operation} {plainInfo && (
</span>
{TOOL_PLAIN_INFO[tool.operation] && (
<AppTooltip <AppTooltip
content={TOOL_PLAIN_INFO[tool.operation]} content={t(plainInfo[0], plainInfo[1])}
sidebarTooltip sidebarTooltip
pinOnClick pinOnClick
> >
<button <button
type="button" type="button"
className="pol-info-btn" className="pol-info-btn"
aria-label={`What does ${entry?.name ?? tool.operation} do?`} aria-label={t(
"policies.toolConfig.infoAriaLabel",
"What does {{tool}} do?",
{ tool: toolName },
)}
> >
<LocalIcon <LocalIcon
icon="info-outline-rounded" icon="info-outline-rounded"
@@ -91,7 +108,9 @@ export function PolicyToolConfig({
checked={tool.enabled} checked={tool.enabled}
disabled={!editable} disabled={!editable}
onChange={(checked) => patchTool(index, { enabled: checked })} onChange={(checked) => patchTool(index, { enabled: checked })}
aria-label={`Enable ${entry?.name ?? tool.operation}`} aria-label={t("policies.toolConfig.enableAriaLabel", "Enable {{tool}}", {
tool: toolName,
})}
/> />
</div> </div>
{tool.enabled && {tool.enabled &&