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."
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]
title = "Print File"
@@ -4,6 +4,29 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
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
// preferences/sidebar contexts we don't set up here — passthrough it.
vi.mock("@app/components/shared/Tooltip", () => ({
@@ -13,6 +13,7 @@
*/
import { useState, useEffect, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import LocalIcon from "@app/components/shared/LocalIcon";
import { usePolicies } from "@app/hooks/usePolicies";
@@ -75,6 +76,7 @@ export function PoliciesSection({
* collapse button), mirroring the back-button + title in a policy. */
leadingControl?: ReactNode;
} = {}) {
const { t } = useTranslation();
const pol = usePolicies();
const { categories } = usePolicyCatalog();
// Persist the expand/collapse state across refreshes.
@@ -109,21 +111,26 @@ export function PoliciesSection({
<div className="pol-list-head">
{leadingControl}
<SectionHeader
title="Policies"
count={`${configuredCount} active`}
title={t("policies.sidebar.title", "Policies")}
count={t("policies.sidebar.activeCount", "{{count}} active", {
count: configuredCount,
})}
collapsible
expanded={expanded}
onToggle={toggleExpanded}
/>
<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
pinOnClick
>
<button
type="button"
className="pol-info-btn"
aria-label="What is a policy?"
aria-label={t("policies.sidebar.infoAriaLabel", "What is a policy?")}
>
<LocalIcon
icon="info-outline-rounded"
@@ -149,10 +156,15 @@ export function PoliciesSection({
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</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-soon">
Upgrade to enterprise
{t(
"policies.sidebar.upgradeToEnterprise",
"Upgrade to enterprise",
)}
</span>
</span>
</div>
@@ -169,16 +181,20 @@ export function PoliciesSection({
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</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">
{status === "setup" ? (
<span className="pol-row-setup">Set up</span>
<span className="pol-row-setup">
{t("policies.sidebar.setUp", "Set up")}
</span>
) : (
<StatusBadge
tone={status === "active" ? "success" : "warning"}
size="sm"
>
{STATUS_LABEL[status]}
{t(`policies.status.${status}`, STATUS_LABEL[status])}
</StatusBadge>
)}
<ChevronRightIcon
@@ -201,6 +217,7 @@ export function PoliciesSection({
* which replaces the Tools area while a policy is selected.
*/
export function PolicyDetailTakeover() {
const { t } = useTranslation();
const pol = usePolicies();
const { categories, configs, sources, docTypes } = usePolicyCatalog();
const { selectedId, detailView } = usePolicySelection();
@@ -311,7 +328,9 @@ export function PolicyDetailTakeover() {
return (
<div className="pol-detail">
<div className="pol-scroll">
<p className="pol-desc">Loading</p>
<p className="pol-desc">
{t("policies.sidebar.loading", "Loading…")}
</p>
</div>
</div>
);
@@ -404,6 +423,7 @@ export function PoliciesCollapsedButton({
}: {
onExpand: () => void;
}) {
const { t } = useTranslation();
const pol = usePolicies();
const { categories } = usePolicyCatalog();
@@ -416,16 +436,21 @@ export function PoliciesCollapsedButton({
.filter((cat) => !cat.comingSoon)
.map((cat) => {
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 =
status === "active"
? " (Active)"
? t("policies.sidebar.railSuffixActive", " (Active)")
: status === "paused"
? " (Paused)"
? t("policies.sidebar.railSuffixPaused", " (Paused)")
: "";
return (
<AppTooltip
key={cat.id}
content={`${cat.label}${suffix}`}
content={`${label}${suffix}`}
position="left"
arrow
delay={300}
@@ -434,7 +459,11 @@ export function PoliciesCollapsedButton({
type="button"
className="pol-crail-btn"
data-status={status}
aria-label={`${cat.label} policy — ${STATUS_LABEL[status]}`}
aria-label={t(
"policies.sidebar.railAriaLabel",
"{{label}} policy — {{status}}",
{ label, status: statusLabel },
)}
onClick={() => {
selectPolicy(cat.id);
onExpand();
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import PublicIcon from "@mui/icons-material/Public";
import ScheduleIcon from "@mui/icons-material/Schedule";
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.
* 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 needsToggle = message.length > 80 || message.includes("\n");
if (!needsToggle) return <>{message}</>;
@@ -82,7 +89,9 @@ function ActivityError({ message }: { message: string }) {
className="pol-activity-error__toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? "Show less" : "Show more"}
{expanded
? t("policies.detail.showLess", "Show less")
: t("policies.detail.showMore", "Show more")}
</button>
</span>
);
@@ -104,6 +113,7 @@ export function PolicyDetailPanel({
onDelete,
onRetry,
}: PolicyDetailPanelProps) {
const { t } = useTranslation();
const isPaused = status === "paused";
// Real configured steps drive the flow; fall back to the preset's rule labels.
const enforceItems =
@@ -123,7 +133,7 @@ export function PolicyDetailPanel({
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={category.label}
title={t(`policies.catalog.${category.id}`, category.label)}
onBack={onBack}
actions={
<StatusBadge
@@ -131,7 +141,9 @@ export function PolicyDetailPanel({
showDot
pulse={!isPaused}
>
{isPaused ? "Paused" : "Active"}
{isPaused
? t("policies.detail.statusPaused", "Paused")
: t("policies.detail.statusActive", "Active")}
</StatusBadge>
}
/>
@@ -139,7 +151,9 @@ export function PolicyDetailPanel({
<div className="pol-scroll">
{/* Enforces */}
<div>
<p className="pol-section-label">Enforces</p>
<p className="pol-section-label">
{t("policies.detail.enforces", "Enforces")}
</p>
<Card padding="default">
<div className="pol-rule-flow">
<ChipFlow items={enforceItems} separator="arrow" />
@@ -151,19 +165,24 @@ export function PolicyDetailPanel({
</span>
<span className="pol-meta-item">
<ScheduleIcon sx={{ fontSize: "0.8rem" }} />
On every upload
{t("policies.detail.onEveryUpload", "On every upload")}
</span>
</div>
<div className="pol-note">
<HistoryIcon sx={{ fontSize: "0.8rem" }} />
Originals stay untouched Enforced version saved alongside
{t(
"policies.detail.originalsNote",
"Originals stay untouched • Enforced version saved alongside",
)}
</div>
</Card>
</div>
{/* Recent Activity */}
<div>
<p className="pol-section-label">Recent Activity</p>
<p className="pol-section-label">
{t("policies.detail.recentActivity", "Recent Activity")}
</p>
{activityItems.length > 0 ? (
<Card padding="none">
{activityItems.map((item, i) => (
@@ -192,7 +211,7 @@ export function PolicyDetailPanel({
title={item.doc}
description={
item.status === "flagged" ? (
<ActivityError message={item.action} />
<ActivityError message={item.action} t={t} />
) : (
item.action
)
@@ -205,7 +224,7 @@ export function PolicyDetailPanel({
size="sm"
onClick={() => onRetry(item)}
>
Retry
{t("policies.detail.retry", "Retry")}
</Button>
) : undefined
}
@@ -217,8 +236,11 @@ export function PolicyDetailPanel({
<EmptyState
size="compact"
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
title="No activity yet"
description="Documents will appear here once this policy runs."
title={t("policies.detail.noActivityTitle", "No activity yet")}
description={t(
"policies.detail.noActivityDescription",
"Documents will appear here once this policy runs.",
)}
/>
</Card>
)}
@@ -232,15 +254,21 @@ export function PolicyDetailPanel({
<span className="pol-stat-value">
{statValues.enforced.toLocaleString()}
</span>
<span className="pol-stat-label">Docs enforced</span>
<span className="pol-stat-label">
{t("policies.detail.statDocsEnforced", "Docs enforced")}
</span>
</div>
<div className="pol-stat">
<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 className="pol-stat">
<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>
</Card>
@@ -249,7 +277,10 @@ export function PolicyDetailPanel({
<Banner
tone="neutral"
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>
@@ -265,14 +296,16 @@ export function PolicyDetailPanel({
onClick={onDelete}
style={{ marginRight: "auto" }}
>
Delete
{t("delete", "Delete")}
</Button>
)}
<Button variant="outline" size="sm" onClick={onTogglePause}>
{isPaused ? "Resume" : "Pause"}
{isPaused
? t("policies.detail.resume", "Resume")
: t("policies.detail.pause", "Pause")}
</Button>
<Button variant="gradient" size="sm" onClick={onEditSettings}>
Edit Settings
{t("policies.detail.editSettings", "Edit Settings")}
</Button>
</div>
)}
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Select } from "@shared/components/Select";
import { Input } from "@shared/components/Input";
@@ -25,6 +26,11 @@ export function PolicyFieldRow({
onChange,
first,
}: 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") {
const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) =>
@@ -36,8 +42,12 @@ export function PolicyFieldRow({
return (
<div className="pol-field" data-first={first || undefined}>
<div className="pol-field-chips-head">
<span className="pol-field-label">{field.label}</span>
<span className="pol-field-count">{selected.length} selected</span>
<span className="pol-field-label">{fieldLabel}</span>
<span className="pol-field-count">
{t("policies.fields.selectedCount", "{{count}} selected", {
count: selected.length,
})}
</span>
</div>
<div className="pol-field-chips">
{(field.options ?? []).map((opt) => (
@@ -47,7 +57,7 @@ export function PolicyFieldRow({
size="sm"
onClick={() => toggle(opt)}
>
{opt}
{t(`policies.fieldOption.${field.key}.${opt}`, opt)}
</Chip>
))}
</div>
@@ -61,28 +71,31 @@ export function PolicyFieldRow({
size="sm"
checked={Boolean(value)}
onChange={(checked) => onChange(checked)}
aria-label={field.label}
aria-label={fieldLabel}
/>
) : field.type === "select" ? (
<Select
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 : ""}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
aria-label={fieldLabel}
/>
) : (
<Input
inputSize="sm"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
aria-label={fieldLabel}
/>
);
return (
<div className="pol-field" data-first={first || undefined}>
<SettingsRow label={field.label} control={control} />
<SettingsRow label={fieldLabel} control={control} />
</div>
);
}
@@ -1,4 +1,5 @@
import { MultiSelect } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions";
/** The set of preset regexes — used to separate preset words from custom ones. */
@@ -24,6 +25,7 @@ export function PolicyPiiField({
onChange,
disabled,
}: PolicyPiiFieldProps) {
const { t } = useTranslation();
const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[])
: [];
@@ -48,9 +50,14 @@ export function PolicyPiiField({
return (
<MultiSelect
size="sm"
label="PII to redact"
placeholder="Select PII types"
data={PII_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
label={t("policies.pii.fieldLabel", "PII to redact")}
placeholder={t("policies.pii.placeholder", "Select PII types")}
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}
onChange={handleChange}
disabled={disabled}
@@ -1,4 +1,5 @@
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { PanelHeader } from "@shared/components/PanelHeader";
@@ -98,6 +99,7 @@ export function PolicySetupWizard({
onCommitConfig,
onSetupClassification,
}: PolicySetupWizardProps) {
const { t } = useTranslation();
const isEdit = mode === "edit";
// Preset (tool-chain) policies render the locked tool config as their Workflow
// step instead of the add/remove builder.
@@ -158,13 +160,27 @@ export function PolicySetupWizard({
<PanelHeader
icon={category.icon}
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}
/>
<div className="pol-scroll">
<EmptyState
title="Managed by your organization"
description="Contact an admin to change this policy."
title={t(
"policies.wizard.lockedTitle",
"Managed by your organization",
)}
description={t(
"policies.wizard.lockedDescription",
"Contact an admin to change this policy.",
)}
/>
</div>
</div>
@@ -212,7 +228,12 @@ export function PolicySetupWizard({
}),
).catch(() => {
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(() => {
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.
const handleSaveFailed = () => {
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);
};
@@ -269,14 +300,25 @@ export function PolicySetupWizard({
<PanelHeader
icon={category.icon}
iconAccent={ROW_ACCENT[category.id] ?? "blue"}
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
subtitle={`Step ${step} of ${TOTAL_STEPS}`}
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),
})
}
subtitle={t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
step,
total: TOTAL_STEPS,
})}
onBack={back}
actions={
<Button
variant="ghost"
size="sm"
aria-label="Cancel"
aria-label={t("cancel", "Cancel")}
onClick={onCancel}
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
/>
@@ -302,7 +344,10 @@ export function PolicySetupWizard({
{toolChain ? (
<>
<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>
<PolicyToolConfigStep
chainIds={toolChain}
@@ -318,7 +363,10 @@ export function PolicySetupWizard({
) : (
<>
<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>
<PolicyWorkflowStep
automation={seedAutomation}
@@ -351,14 +399,18 @@ export function PolicySetupWizard({
)}
{/* 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">
{/* The editor event the policy runs on: input on upload, or
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>
<SettingsRow
label="Run on"
label={t("policies.wizard.runOnLabel", "Run on")}
control={
<Select
inputSize="sm"
@@ -366,19 +418,27 @@ export function PolicySetupWizard({
onChange={(e) =>
setRunOn(e.target.value as "upload" | "export")
}
aria-label="Run on"
aria-label={t("policies.wizard.runOnLabel", "Run on")}
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 className="pol-subhead">Output</div>
<div className="pol-subhead">
{t("policies.wizard.outputSubhead", "Output")}
</div>
<div className="pol-field" data-first>
<SettingsRow
label="Output as"
label={t("policies.wizard.outputAsLabel", "Output as")}
control={
<Select
inputSize="sm"
@@ -397,17 +457,31 @@ export function PolicySetupWizard({
setOutputNamePosition("suffix");
}
}}
aria-label="Output mode"
aria-label={t(
"policies.wizard.outputModeAria",
"Output mode",
)}
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>
{/* 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-name-row">
<Select
@@ -418,13 +492,30 @@ export function PolicySetupWizard({
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
aria-label="Filename position"
aria-label={t(
"policies.wizard.filenamePositionAria",
"Filename position",
)}
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.
...(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"
value={outputName}
onChange={(e) => setOutputName(e.target.value)}
placeholder="Text to add (optional)"
aria-label="Filename text"
placeholder={t(
"policies.wizard.filenameTextPlaceholder",
"Text to add (optional)",
)}
aria-label={t(
"policies.wizard.filenameTextAria",
"Filename text",
)}
/>
)}
</div>
</div>
<div className="pol-field">
<SettingsRow
label="Max retries"
label={t("policies.wizard.maxRetriesLabel", "Max retries")}
control={
<Input
type="number"
@@ -452,14 +549,20 @@ export function PolicySetupWizard({
onChange={(e) =>
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
}
aria-label="Max retries"
aria-label={t(
"policies.wizard.maxRetriesLabel",
"Max retries",
)}
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Retry delay (min)"
label={t(
"policies.wizard.retryDelayLabel",
"Retry delay (min)",
)}
control={
<Input
type="number"
@@ -470,7 +573,10 @@ export function PolicySetupWizard({
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 && (
<>
<p className="pol-desc">
Choose where this policy runs and which document types it applies
to.
{t(
"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 className="pol-section-label">Sources</p>
<Card padding="none">
{sourceDefs.map((src, i) => (
<div
@@ -506,20 +616,31 @@ export function PolicySetupWizard({
))}
</Card>
<p className="pol-section-label">Document types</p>
<p className="pol-section-label">
{t("policies.wizard.docTypesLabel", "Document types")}
</p>
{!classificationEnabled ? (
<Banner
tone="warning"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
title="All document types"
description="Enable the Classification policy to filter by document type."
title={t(
"policies.wizard.allDocTypesTitle",
"All document types",
)}
description={t(
"policies.wizard.allDocTypesDescription",
"Enable the Classification policy to filter by document type.",
)}
action={
<Button
variant="ghost"
size="sm"
onClick={onSetupClassification}
>
Set up Classification
{t(
"policies.wizard.setupClassification",
"Set up Classification",
)}
</Button>
}
/>
@@ -528,14 +649,23 @@ export function PolicySetupWizard({
<div className="pol-doctypes-head">
<span className="pol-field-label">
{scopeTypes.length === 0
? "All document types"
: `${scopeTypes.length} types selected`}
? t(
"policies.wizard.allDocTypesTitle",
"All document types",
)
: t(
"policies.wizard.typesSelected",
"{{count}} types selected",
{ count: scopeTypes.length },
)}
</span>
<button
className="pol-link"
onClick={() => setScopeNarrow((v) => !v)}
>
{scopeNarrow ? "Clear" : "Edit"}
{scopeNarrow
? t("policies.wizard.clear", "Clear")
: t("policies.wizard.edit", "Edit")}
</button>
</div>
{scopeNarrow && (
@@ -564,7 +694,9 @@ export function PolicySetupWizard({
<div className="pol-footer">
<Button variant="ghost" size="sm" onClick={back}>
{step > 1 ? "Back" : "Cancel"}
{step > 1
? t("policies.wizard.back", "Back")
: t("cancel", "Cancel")}
</Button>
{step < TOTAL_STEPS ? (
<Button
@@ -573,7 +705,7 @@ export function PolicySetupWizard({
style={{ marginLeft: "auto" }}
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
>
Continue
{t("policies.wizard.continue", "Continue")}
</Button>
) : (
<Button
@@ -583,7 +715,9 @@ export function PolicySetupWizard({
onClick={submit}
disabled={submitting}
>
{isEdit ? "Save Changes" : "Enable Policy"}
{isEdit
? t("policies.wizard.saveChanges", "Save Changes")
: t("policies.wizard.enablePolicy", "Enable Policy")}
</Button>
)}
</div>
@@ -1,4 +1,5 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Loader } from "@mantine/core";
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
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 { ToolId } from "@app/types/toolId";
/** Plain-language, non-technical descriptions shown by each tool's info button. */
const TOOL_PLAIN_INFO: Record<string, string> = {
redact:
/**
* Plain-language, non-technical descriptions shown by each tool's info button.
* 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.",
sanitize:
],
sanitize: [
"policies.toolConfig.info.sanitize",
"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. */
@@ -51,6 +63,7 @@ export function PolicyToolConfig({
onChange,
editable = true,
}: PolicyToolConfigProps) {
const { t } = useTranslation();
const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
@@ -59,23 +72,27 @@ export function PolicyToolConfig({
{tools.map((tool, index) => {
const entry = toolRegistry[tool.operation as ToolId];
const Settings = entry?.automationSettings ?? null;
const toolName = entry?.name ?? tool.operation;
const plainInfo = TOOL_PLAIN_INFO[tool.operation];
return (
<Card key={tool.operation} padding="none">
<div className="pol-tool-head">
<span className="pol-tool-icon">{entry?.icon}</span>
<span className="pol-tool-name">
{entry?.name ?? tool.operation}
</span>
{TOOL_PLAIN_INFO[tool.operation] && (
<span className="pol-tool-name">{toolName}</span>
{plainInfo && (
<AppTooltip
content={TOOL_PLAIN_INFO[tool.operation]}
content={t(plainInfo[0], plainInfo[1])}
sidebarTooltip
pinOnClick
>
<button
type="button"
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
icon="info-outline-rounded"
@@ -91,7 +108,9 @@ export function PolicyToolConfig({
checked={tool.enabled}
disabled={!editable}
onChange={(checked) => patchTool(index, { enabled: checked })}
aria-label={`Enable ${entry?.name ?? tool.operation}`}
aria-label={t("policies.toolConfig.enableAriaLabel", "Enable {{tool}}", {
tool: toolName,
})}
/>
</div>
{tool.enabled &&