Compare commits

...
Author SHA1 Message Date
Anthony StirlingandGitHub 08643822c8 Merge branch 'main' into portal-api-key-import-ui 2026-07-10 10:27:13 +01:00
Anthony StirlingandGitHub 68ec176719 Portal empty states: add CTAs and hide stat boxes (#6952)
# Description of Changes

Empty-state polish across the four processor (portal) list pages, so a
fresh workspace gets clear next steps instead of a row of zeroed-out
stat boxes.

- **Sources / Pipelines** - hide the KPI stat strip when the list is
empty; the empty state now shows an icon plus a primary + secondary CTA
(Connect source / Read the docs; Create a pipeline / Connect a source).
Also closes a gap where a successfully-fetched empty list rendered stat
boxes over a blank page with no empty state at all.
- **Policies** - hide the summary stat strip until at least one policy
is configured; the catalogue cards stay as the "configure a policy"
CTAs.
- **Documents** - hide the filter-pill + search toolbar on an empty
queue; the empty state gains an icon plus Create a pipeline / Connect a
source CTAs.
- **Storybook** - added `Default` + `Empty` stories for all four views;
the preview now loads the real English copy so stories render shipped
text rather than raw i18n keys.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-10 08:54:05 +00:00
Anthony StirlingandGitHub d17c3f4fec Portal: wire remaining hardcoded strings to i18n (#6953)
# Description of Changes

- Audited every portal page/component for hardcoded UI strings not
routed through `t()`
- Wired the remaining ones to i18n (~80 new `portal.*` keys in
`en-US/translation.toml`):
- Infrastructure status/label maps (deploy, api-key, cert, key-mode,
attestation, audit, model, region, environment) + API-key permissions
- Procurement "Key documents" modal, editor-admin deploy targets, users
seats label, pipeline output-folder placeholder
- Follows the existing house pattern: label maps store i18n keys,
resolved via `t(MAP[value])` at the render site
- Documents CSV export now reuses the on-screen column keys, and fixes a
latent bug where the exported status leaked the raw key instead of the
translated label
- No UI-copy change: en-US values are identical to the previously
hardcoded strings; other locales fall back to en-US as before

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-10 08:54:02 +00:00
Anthony StirlingandGitHub 783a51950f Update translations for 40 languages via GPT-5.5 (#6954)
# Description of Changes

- Adds and updates translations across **40 languages** (~1,400–2,200
keys each) using GPT-5.5, filling previously-missing UI strings.
- Switches the translation scripts' default model from the year-old
`gpt-5` (5.0) to `gpt-5.5`, adding a `--model` flag and token/cost
reporting.
- Purely additive and validated: no existing translations changed, all
40 files match the en-US key structure, and no new placeholder issues
introduced.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [x] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-10 08:53:59 +00:00
Anthony Stirling 98d963699b Remove IP allowlist field from portal API key create modal 2026-07-09 23:09:43 +01:00
74 changed files with 91064 additions and 299 deletions
+7
View File
@@ -1 +1,8 @@
declare module "*.css" {}
// Vite `?raw` suffix imports a file's contents as a string (used in preview.tsx
// to load the English translation TOML synchronously).
declare module "*?raw" {
const content: string;
export default content;
}
+18 -4
View File
@@ -21,19 +21,33 @@ import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { parse as parseToml } from "smol-toml";
// Load the real English copy so stories render human text, not raw keys. Bundled
// synchronously via ?raw so it's present on the very first render (no async flash).
// eslint-disable-next-line no-restricted-imports -- Storybook-only: read the public i18n asset; no @-alias covers editor/public/.
import enTranslationToml from "../editor/public/locales/en-US/translation.toml?raw";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/tokens/base.css";
// Storybook-only: init react-i18next so t(key, fallback, vars) interpolates its
// English fallback (there's no backend here to load locale files). Without this,
// the default t() returns raw templates like "{{count}} people · led by {{owner}}".
// Storybook-only: init react-i18next with the real English resources parsed from
// the app's TOML, so t(key) renders the shipped copy (e.g. "No sources connected
// yet") rather than the raw key. Falls back to an empty bundle if parsing ever
// fails, so a malformed TOML can't take the whole Storybook down.
function parseEnTranslation(): Record<string, unknown> {
try {
return parseToml(enTranslationToml) as Record<string, unknown>;
} catch {
return {};
}
}
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
resources: { en: { translation: {} } },
resources: { en: { translation: parseEnTranslation() } },
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6783,6 +6783,8 @@ type = "Type"
user = "User"
[portal.documents.queue.empty]
connectSource = "Connect a source"
createPipeline = "Create a pipeline"
description = "As sources feed documents into your pipelines they'll appear here for review."
title = "No documents in the queue"
@@ -6958,6 +6960,12 @@ sectionsAriaLabel = "Infrastructure sections"
subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
title = "Infrastructure"
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
[portal.infrastructure.apiKeyPermission]
admin = "Admin"
read = "Read"
write = "Write"
[portal.infrastructure.apiKeys]
createKey = "Create key"
heading = "API keys"
@@ -6978,6 +6986,11 @@ usageToday = "Usage today"
description = "Create a scoped key to start calling the Stirling API."
title = "No API keys yet"
[portal.infrastructure.attestationLabel]
attested = "Attested"
inScope = "In scope"
notApplicable = "N/A"
[portal.infrastructure.audit]
filterAriaLabel = "Filter audit events by category"
heading = "Audit logs"
@@ -7034,12 +7047,28 @@ elevation = "Elevation"
processing = "Processing"
totalEvents = "Total events · 24h"
[portal.infrastructure.auditCatLabel]
auth = "Auth"
config = "Config"
elevation = "Elevation"
processing = "Processing"
security = "Security"
[portal.infrastructure.auditStatusLabel]
danger = "Error"
info = "Info"
success = "Success"
warning = "Warning"
[portal.infrastructure.certLabel]
certified = "Certified"
inProgress = "In progress"
notStarted = "Not started"
[portal.infrastructure.createKey]
cancel = "Cancel"
createKey = "Create key"
done = "Done"
ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
ipAllowlistLabel = "IP allowlist"
keyNameLabel = "Key name"
keyNamePlaceholder = "e.g. Production · ingest"
permissionsLabel = "Permissions"
@@ -7050,6 +7079,12 @@ subtitleCreated = "Copy this secret now — it won't be shown again."
title = "Create API key"
titleCreated = "Key created"
[portal.infrastructure.deployLabel]
live = "Live"
queued = "Queued"
rolledBack = "Rolled back"
rolling = "Rolling out"
[portal.infrastructure.deployments]
msValue = "{{value}} ms"
throughputValue = "{{value}}/min"
@@ -7085,6 +7120,16 @@ subheading = "Live health for every deployed Stirling region — latency, load,
description = "Deployed regions appear here once your workspace is provisioned."
title = "No regions deployed"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
rotateSoon = "Rotate soon"
[portal.infrastructure.modelLabel]
active = "Active"
degraded = "Degraded"
disabled = "Disabled"
[portal.infrastructure.models]
heading = "Models"
msValue = "{{value}} ms"
@@ -7112,6 +7157,10 @@ status = "Status"
type = "Type"
version = "Version"
[portal.infrastructure.models.cost]
perCall = "{{price}}/call"
perThousand = "{{price}}/1k"
[portal.infrastructure.models.metrics]
activeModels = "Active models"
avgLatency = "Avg latency"
@@ -7135,6 +7184,17 @@ modelForAria = "Model for {{operation}}"
operation = "Operation"
routedTo = "Routed to"
[portal.infrastructure.modelTypeLabel]
classification = "Classification"
extraction = "Extraction"
llm = "LLM"
ocr = "OCR"
[portal.infrastructure.regionLabel]
degraded = "Degraded"
down = "Down"
healthy = "Healthy"
[portal.infrastructure.security.access.byok]
description = "Supply a key from your own KMS. Stirling encrypts with it but can still read."
label = "Bring your own key (BYOK)"
@@ -7364,6 +7424,7 @@ run = "Run now"
[portal.pipelines.empty]
action = "Create a pipeline"
connectSource = "Connect a source"
description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
title = "No pipelines yet"
@@ -7809,6 +7870,56 @@ blurb = "Evaluate Stirling against your documents and workflows."
gatingAction = "Build your quote"
label = "Trial"
[portal.procurement.keyDocs]
oneTimeFee = " · one-time {{amount}}"
subtitle = "Everything for each stage of your rollout, in one place."
title = "Key documents"
[portal.procurement.keyDocs.docs.baa]
name = "Business Associate Agreement"
sub = "HIPAA · available on request"
[portal.procurement.keyDocs.docs.bankTransfer]
name = "Bank transfer instructions"
sub = "Wire details for your AP team"
[portal.procurement.keyDocs.docs.coi]
name = "Certificate of Insurance"
sub = "Cyber + E&O · current policy"
[portal.procurement.keyDocs.docs.formalQuote]
name = "Formal quote"
sub = "Built to your volume, term, and service level"
[portal.procurement.keyDocs.docs.msa]
name = "Master Services Agreement"
sub = "One signature - MSA, order form, EULA, and DPA combined"
[portal.procurement.keyDocs.docs.purchaseOrder]
name = "Purchase order"
sub = "Issuing a PO? Upload it and we invoice against it"
[portal.procurement.keyDocs.docs.securityReview]
name = "Custom security review"
sub = "We complete your questionnaire and join your review call"
[portal.procurement.keyDocs.docs.soc2]
name = "SOC 2 Type II report"
sub = "Audited · NDA-gated"
[portal.procurement.keyDocs.docs.w9]
name = "IRS Form W-9"
sub = "Stirling PDF Inc."
[portal.procurement.keyDocs.groups]
evaluation = "Supporting your evaluation"
yourDeal = "Your deal"
[portal.procurement.keyDocs.status]
action = "Action needed"
available = "Download"
request = "Request"
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
@@ -8114,6 +8225,10 @@ summary = "Owns a team — manages its members' resources and shared configs."
2 = "Portal access via the default policy"
3 = "Everything Member can do"
[portal.users.seats]
limited = "{{used}} / {{limit}}"
unlimited = "{{used}} · Unlimited"
[portal.welcome]
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -111,10 +111,10 @@ export function RecentActivity() {
<div className="portal-activity__subject">{event.target}</div>
<div className="portal-activity__detail-row">
<span className="portal-activity__detail">
{AUDIT_CAT_LABEL[event.category]} · {event.actor}
{t(AUDIT_CAT_LABEL[event.category])} · {event.actor}
</span>
<StatusBadge tone={AUDIT_TONE[event.status]} size="sm">
{AUDIT_STATUS_LABEL[event.status]}
{t(AUDIT_STATUS_LABEL[event.status])}
</StatusBadge>
</div>
</div>
@@ -22,7 +22,9 @@ export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
<StatTile
label={t("portal.documents.overview.action")}
value={
doc.product === "Editor" || !doc.action ? "Editor" : doc.action
doc.product === "Editor" || !doc.action
? t("portal.documents.table.editorAction")
: doc.action
}
/>
<StatTile
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import {
render as baseRender,
screen,
type RenderResult,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
import type { ReviewDocument } from "@portal/api/documents";
import { ReviewQueue } from "@portal/components/documents/ReviewQueue";
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
// Isolate ReviewQueue's own branching: stub the heavy children so the test
// doesn't need TierProvider (DocumentDrawer → useTier) or the real table body.
vi.mock("@portal/components/documents/DocumentDrawer", () => ({
DocumentDrawer: () => null,
}));
vi.mock("@portal/components/documents/ReviewQueueTable", () => ({
ReviewQueueTable: () => null,
}));
const render = (ui: ReactElement): RenderResult =>
baseRender(
<MemoryRouter>
<MantineProvider>{ui}</MantineProvider>
</MemoryRouter>,
);
const DOC: ReviewDocument = {
id: "doc-1",
name: "Invoice.pdf",
type: "PDF",
classification: "Invoice",
auto: true,
note: null,
product: "Editor",
action: null,
user: "you@acme.com",
status: "processed",
reviewer: null,
source: "Claims intake",
confidence: 0.98,
fieldsExtracted: 5,
time: "2 min ago",
sensitive: false,
extractions: [],
audit: [],
};
describe("ReviewQueue", () => {
it("hides the filter toolbar and shows CTAs when there are no documents", () => {
render(<ReviewQueue documents={[]} loading={false} />);
// Empty-state panel with both CTAs.
expect(
screen.getByText("portal.documents.queue.empty.title"),
).toBeInTheDocument();
expect(
screen.getByText("portal.documents.queue.empty.createPipeline"),
).toBeInTheDocument();
expect(
screen.getByText("portal.documents.queue.empty.connectSource"),
).toBeInTheDocument();
// The filter pills (counters over the list) are gone in the empty state.
expect(
screen.queryByText("portal.documents.filters.all"),
).not.toBeInTheDocument();
});
it("shows the filter toolbar when documents exist", () => {
render(<ReviewQueue documents={[DOC]} loading={false} />);
expect(
screen.getByText("portal.documents.filters.all"),
).toBeInTheDocument();
expect(
screen.queryByText("portal.documents.queue.empty.title"),
).not.toBeInTheDocument();
});
});
@@ -1,7 +1,18 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { EmptyState, Input, Skeleton, Tabs, type TabItem } from "@app/ui";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import {
Button,
EmptyState,
Input,
Skeleton,
Tabs,
type TabItem,
} from "@app/ui";
import type { DocumentStatus, ReviewDocument } from "@portal/api/documents";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { DocumentsIcon } from "@portal/components/icons";
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
@@ -40,6 +51,7 @@ function SearchIcon() {
*/
export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [filter, setFilter] = useState<QueueFilter>("all");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -94,24 +106,28 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
return (
<div className="portal-documents__queue">
<div className="portal-documents__toolbar">
<Tabs<QueueFilter>
items={filterItems}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel={t("portal.documents.filters.ariaLabel")}
/>
<Input
className="portal-documents__search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("portal.documents.search")}
aria-label={t("portal.documents.search")}
leadingIcon={<SearchIcon />}
inputSize="sm"
/>
</div>
{/* The filter pills + search are counters over the list, so hide them when
the list is empty — the empty state stands alone. */}
{!isLoading && !isEmpty && (
<div className="portal-documents__toolbar">
<Tabs<QueueFilter>
items={filterItems}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel={t("portal.documents.filters.ariaLabel")}
/>
<Input
className="portal-documents__search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("portal.documents.search")}
aria-label={t("portal.documents.search")}
leadingIcon={<SearchIcon />}
inputSize="sm"
/>
</div>
)}
{isLoading && (
<div className="portal-documents__table-skeleton" aria-hidden>
@@ -123,8 +139,31 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
{isEmpty && (
<EmptyState
icon={<DocumentsIcon size={28} />}
title={t("portal.documents.queue.empty.title")}
description={t("portal.documents.queue.empty.description")}
actions={
<>
<Button
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`)
}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.documents.queue.empty.createPipeline")}
</Button>
<Button
variant="secondary"
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
}
>
{t("portal.documents.queue.empty.connectSource")}
</Button>
</>
}
/>
)}
@@ -15,6 +15,7 @@ import {
type EditorInstance,
} from "@portal/api/editorDeploy";
// Deployment target names are product/brand terms, not localised.
const TARGET_LABEL: Record<EditorInstance["target"], string> = {
cloud: "Cloud",
docker: "Docker",
@@ -25,7 +25,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
</span>
<span className="portal-infra__key-head-right">
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
{KEY_LABEL[apiKey.status]}
{t(KEY_LABEL[apiKey.status])}
</StatusBadge>
<span
className={"portal-infra__chevron" + (open ? " is-open" : "")}
@@ -72,7 +72,10 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
<dd className="portal-infra__chips">
{apiKey.permissions.map((p) => (
<Chip key={p} size="sm">
{p}
{t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
</Chip>
))}
</dd>
@@ -68,7 +68,7 @@ export function AuditTab() {
render: (e) => (
<div className="portal-infra__event">
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
{AUDIT_CAT_LABEL[e.category]}
{t(AUDIT_CAT_LABEL[e.category])}
</StatusBadge>
<span>{e.action}</span>
</div>
@@ -89,7 +89,7 @@ export function AuditTab() {
header: t("portal.infrastructure.audit.columns.status"),
render: (e) => (
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
{AUDIT_STATUS_LABEL[e.status]}
{t(AUDIT_STATUS_LABEL[e.status])}
</StatusBadge>
),
},
@@ -0,0 +1,50 @@
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Deterministic i18n: keys returned verbatim, so assertions are stable.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
const K = "portal.infrastructure.createKey";
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
return render(
<MantineProvider>
<CreateKeyModal open onClose={() => {}} {...props} />
</MantineProvider>,
);
}
describe("CreateKeyModal", () => {
it("gates the create button on a name and at least one permission", () => {
renderModal({});
const cta = screen.getByRole("button", { name: `${K}.createKey` });
expect(cta).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
expect(cta).toBeEnabled();
});
it("reveals the generated secret with a store-it warning", () => {
renderModal({});
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
expect(screen.getByText(`${K}.secretWarning`)).toBeInTheDocument();
expect(
screen.getByText("sk_live_demo_key_rotate_in_prod"),
).toBeInTheDocument();
});
});
@@ -27,13 +27,11 @@ export function CreateKeyModal({
const { t } = useTranslation();
const [name, setName] = useState("");
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
const [ips, setIps] = useState("");
const [created, setCreated] = useState(false);
function reset() {
setName("");
setPerms(["Read"]);
setIps("");
setCreated(false);
}
@@ -50,8 +48,8 @@ export function CreateKeyModal({
}
function createKey() {
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
// and render the one-time secret from the response instead of the fixture.
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms } and render
// the one-time secret from the response instead of the fixture.
setCreated(true);
}
@@ -126,24 +124,16 @@ export function CreateKeyModal({
{PERMISSION_OPTS.map((p) => (
<Checkbox
key={p}
label={p}
label={t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
checked={perms.includes(p)}
onChange={() => togglePerm(p)}
/>
))}
</div>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
>
<Input
value={ips}
onChange={(e) => setIps(e.target.value)}
placeholder="52.14.0.0/16, 203.0.113.7/32"
/>
</FormField>
</div>
)}
</Modal>
@@ -22,8 +22,8 @@ import {
DEPLOY_LABEL,
DEPLOY_TONE,
pct,
REGION_LABEL,
REGION_TONE,
titleCase,
} from "@portal/components/infrastructure/infraFormat";
export function DeploymentsTab() {
@@ -79,7 +79,7 @@ export function DeploymentsTab() {
size="sm"
pulse={r.status === "healthy"}
>
{titleCase(r.status)}
{t(REGION_LABEL[r.status])}
</StatusBadge>
),
},
@@ -164,7 +164,7 @@ export function DeploymentsTab() {
header: t("portal.infrastructure.deployments.deployColumns.status"),
render: (d) => (
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
{DEPLOY_LABEL[d.status]}
{t(DEPLOY_LABEL[d.status])}
</StatusBadge>
),
},
@@ -57,7 +57,7 @@ export function ModelsTab() {
header: t("portal.infrastructure.models.columns.type"),
render: (m) => (
<Chip accent={MODEL_TYPE_TONE[m.type]} size="sm">
{MODEL_TYPE_LABEL[m.type]}
{t(MODEL_TYPE_LABEL[m.type])}
</Chip>
),
},
@@ -70,7 +70,7 @@ export function ModelsTab() {
size="sm"
pulse={m.status === "active"}
>
{MODEL_LABEL[m.status]}
{t(MODEL_LABEL[m.status])}
</StatusBadge>
),
},
@@ -101,7 +101,7 @@ export function ModelsTab() {
align: "right",
render: (m) => (
<span className="portal-infra__mono">
{modelCost(m.cost, m.costUnit)}
{modelCost(t, m.cost, m.costUnit)}
</span>
),
},
@@ -259,7 +259,7 @@ export function SecurityTab() {
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{c.name}</span>
<StatusBadge tone={CERT_TONE[c.status]} size="sm">
{CERT_LABEL[c.status]}
{t(CERT_LABEL[c.status])}
</StatusBadge>
</div>
<p className="portal-infra__cert-detail">{c.detail}</p>
@@ -283,7 +283,7 @@ export function SecurityTab() {
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{a.name}</span>
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
{ATTESTATION_LABEL[a.status]}
{t(ATTESTATION_LABEL[a.status])}
</StatusBadge>
</div>
<Chip size="sm">{a.framework}</Chip>
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type { ChipAccent, StatusTone } from "@app/ui";
import type {
ApiKeyStatus,
@@ -30,6 +31,13 @@ export const REGION_TONE: Record<RegionStatus, StatusTone> = {
down: "danger",
};
// Label maps hold i18n keys, resolved via t(MAP[value]) at the render sites.
export const REGION_LABEL: Record<RegionStatus, string> = {
healthy: "portal.infrastructure.regionLabel.healthy",
degraded: "portal.infrastructure.regionLabel.degraded",
down: "portal.infrastructure.regionLabel.down",
};
export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
live: "success",
rolling: "info",
@@ -38,10 +46,10 @@ export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
};
export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
live: "Live",
rolling: "Rolling out",
"rolled-back": "Rolled back",
queued: "Queued",
live: "portal.infrastructure.deployLabel.live",
rolling: "portal.infrastructure.deployLabel.rolling",
"rolled-back": "portal.infrastructure.deployLabel.rolledBack",
queued: "portal.infrastructure.deployLabel.queued",
};
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
@@ -51,9 +59,9 @@ export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
};
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "Active",
revoked: "Revoked",
"rotate-soon": "Rotate soon",
active: "portal.infrastructure.keyLabel.active",
revoked: "portal.infrastructure.keyLabel.revoked",
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
@@ -63,11 +71,12 @@ export const CERT_TONE: Record<CertStatus, StatusTone> = {
};
export const CERT_LABEL: Record<CertStatus, string> = {
certified: "Certified",
"in-progress": "In progress",
"not-started": "Not started",
certified: "portal.infrastructure.certLabel.certified",
"in-progress": "portal.infrastructure.certLabel.inProgress",
"not-started": "portal.infrastructure.certLabel.notStarted",
};
// Brand/acronym key-management modes are not localised.
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
managed: "Stirling-managed",
byok: "BYOK",
@@ -82,9 +91,9 @@ export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
};
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
attested: "Attested",
"in-scope": "In scope",
"not-applicable": "N/A",
attested: "portal.infrastructure.attestationLabel.attested",
"in-scope": "portal.infrastructure.attestationLabel.inScope",
"not-applicable": "portal.infrastructure.attestationLabel.notApplicable",
};
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
@@ -102,18 +111,18 @@ export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
// Human labels for the status badge: danger/warning are tones, not outcomes (read "Error").
export const AUDIT_STATUS_LABEL: Record<AuditStatus, string> = {
success: "Success",
warning: "Warning",
danger: "Error",
info: "Info",
success: "portal.infrastructure.auditStatusLabel.success",
warning: "portal.infrastructure.auditStatusLabel.warning",
danger: "portal.infrastructure.auditStatusLabel.danger",
info: "portal.infrastructure.auditStatusLabel.info",
};
export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
auth: "Auth",
config: "Config",
elevation: "Elevation",
processing: "Processing",
security: "Security",
auth: "portal.infrastructure.auditCatLabel.auth",
config: "portal.infrastructure.auditCatLabel.config",
elevation: "portal.infrastructure.auditCatLabel.elevation",
processing: "portal.infrastructure.auditCatLabel.processing",
security: "portal.infrastructure.auditCatLabel.security",
};
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
@@ -131,16 +140,16 @@ export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
};
export const MODEL_LABEL: Record<ModelStatus, string> = {
active: "Active",
degraded: "Degraded",
disabled: "Disabled",
active: "portal.infrastructure.modelLabel.active",
degraded: "portal.infrastructure.modelLabel.degraded",
disabled: "portal.infrastructure.modelLabel.disabled",
};
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
extraction: "Extraction",
classification: "Classification",
ocr: "OCR",
llm: "LLM",
extraction: "portal.infrastructure.modelTypeLabel.extraction",
classification: "portal.infrastructure.modelTypeLabel.classification",
ocr: "portal.infrastructure.modelTypeLabel.ocr",
llm: "portal.infrastructure.modelTypeLabel.llm",
};
export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
@@ -150,6 +159,7 @@ export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
llm: "warning",
};
// Provider names are proper nouns, not localised.
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
stirling: "Stirling",
openai: "OpenAI",
@@ -158,8 +168,14 @@ export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
};
/** Render a model's cost with the unit it's billed against. */
export function modelCost(cost: number, unit: ModelCostUnit): string {
if (cost === 0) return "Included";
export function modelCost(
t: TFunction,
cost: number,
unit: ModelCostUnit,
): string {
if (cost === 0) return t("portal.infrastructure.models.metrics.included");
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
return unit === "per-call" ? `${price}/call` : `${price}/1k`;
return unit === "per-call"
? t("portal.infrastructure.models.cost.perCall", { price })
: t("portal.infrastructure.models.cost.perThousand", { price });
}
@@ -77,70 +77,76 @@ function SideModal({
// ── Key documents ────────────────────────────────────────────────────────────
type DocStatus = "available" | "action" | "request";
interface DocRow {
name: string;
sub: string;
nameKey: string;
subKey: string;
status: DocStatus;
fee?: number;
}
const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
// Static demo catalogue for the pilot; the copy lives in the locale under
// portal.procurement.keyDocs and is resolved via t() at render time.
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
{
group: "Your deal",
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
docs: [
{
name: "Formal quote",
sub: "Built to your volume, term, and service level",
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
status: "available",
},
{
name: "Master Services Agreement",
sub: "One signature — MSA, order form, EULA, and DPA combined",
nameKey: "portal.procurement.keyDocs.docs.msa.name",
subKey: "portal.procurement.keyDocs.docs.msa.sub",
status: "action",
},
{
name: "Bank transfer instructions",
sub: "Wire details for your AP team",
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
status: "available",
},
{
name: "Purchase order",
sub: "Issuing a PO? Upload it and we invoice against it",
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
status: "request",
},
],
},
{
group: "Supporting your evaluation",
groupKey: "portal.procurement.keyDocs.groups.evaluation",
docs: [
{
name: "SOC 2 Type II report",
sub: "Audited · NDA-gated",
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
status: "available",
},
{
name: "Custom security review",
sub: "We complete your questionnaire and join your review call",
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
status: "request",
fee: 5000,
},
{
name: "Business Associate Agreement",
sub: "HIPAA · available on request",
nameKey: "portal.procurement.keyDocs.docs.baa.name",
subKey: "portal.procurement.keyDocs.docs.baa.sub",
status: "request",
fee: 2500,
},
{ name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
{
name: "Certificate of Insurance",
sub: "Cyber + E&O · current policy",
nameKey: "portal.procurement.keyDocs.docs.w9.name",
subKey: "portal.procurement.keyDocs.docs.w9.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.coi.name",
subKey: "portal.procurement.keyDocs.docs.coi.sub",
status: "available",
},
],
},
];
const STATUS_LABEL: Record<DocStatus, string> = {
available: "Download",
action: "Action needed",
request: "Request",
available: "portal.procurement.keyDocs.status.available",
action: "portal.procurement.keyDocs.status.action",
request: "portal.procurement.keyDocs.status.request",
};
export function KeyDocumentsModal({
@@ -150,31 +156,36 @@ export function KeyDocumentsModal({
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<SideModal
open={open}
onClose={onClose}
title="Key documents"
subtitle="Everything for each stage of your rollout, in one place."
title={t("portal.procurement.keyDocs.title")}
subtitle={t("portal.procurement.keyDocs.subtitle")}
>
{STAGE_DOCS.map((g) => (
<div key={g.group} className="portal-docs__group">
<div className="portal-docs__group-title">{g.group}</div>
<div key={g.groupKey} className="portal-docs__group">
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
<ul className="portal-docs__list">
{g.docs.map((d) => (
<li key={d.name} className="portal-docs__row">
<li key={d.nameKey} className="portal-docs__row">
<div className="portal-docs__row-text">
<span className="portal-docs__row-name">{d.name}</span>
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
<span className="portal-docs__row-sub">
{d.sub}
{d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""}
{t(d.subKey)}
{d.fee
? t("portal.procurement.keyDocs.oneTimeFee", {
amount: `$${d.fee.toLocaleString()}`,
})
: ""}
</span>
</div>
<span
className="portal-docs__row-action"
data-status={d.status}
>
{STATUS_LABEL[d.status]}
{t(STATUS_LABEL[d.status])}
</span>
</li>
))}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import "@portal/views/Procurement.css";
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
@@ -58,6 +59,7 @@ export function ProcurementModal({
subtitle?: string;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const trapRef = useFocusTrap(open);
useEffect(() => {
@@ -86,7 +88,7 @@ export function ProcurementModal({
type="button"
className="portal-procmodal__close"
onClick={onClose}
aria-label="Close"
aria-label={t("portal.procurement.modal.close")}
>
✕
</button>
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type { AvatarTone, ChipAccent } from "@app/ui";
import type { Member, RoleId } from "@portal/api/users";
import { ROLE_TONE } from "@portal/api/users";
@@ -23,8 +24,14 @@ export function chipAccentForRole(role: RoleId): ChipAccent {
}
/** Seats used / limit as display copy; null limit → "Unlimited". */
export function seatsLabel(used: number, limit: number | null): string {
return limit === null ? `${used} · Unlimited` : `${used} / ${limit}`;
export function seatsLabel(
t: TFunction,
used: number,
limit: number | null,
): string {
return limit === null
? t("portal.users.seats.unlimited", { used })
: t("portal.users.seats.limited", { used, limit });
}
/**
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { Documents } from "@portal/views/Documents";
const meta: Meta<typeof Documents> = {
title: "Portal/Views/Documents",
component: Documents,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Documents>;
/** Seeded mock data: the filter pills + a populated review queue. */
export const Default: Story = {};
/**
* A fresh workspace with nothing processed yet. The filter-pill toolbar + search
* stay hidden; the empty state drives the user to create a pipeline (primary) or
* connect a source (secondary) — the two things that feed the queue.
*/
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("*/api/v1/proprietary/ui-data/documents", () =>
HttpResponse.json({
summary: {
totalInQueue: 0,
processed: 0,
errors: 0,
processedToday: 0,
},
documents: [],
}),
),
],
},
},
};
+14 -10
View File
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
@@ -30,20 +31,23 @@ function csvCell(value: string): string {
}
/** Flatten the visible columns to CSV, matching the on-screen table. */
function toCsv(docs: ReviewDocument[]): string {
function toCsv(docs: ReviewDocument[], t: TFunction): string {
const header = [
"Document",
"Product",
"Pipeline / Action",
"User",
"Status",
"Time",
t("portal.documents.table.columns.document"),
t("portal.documents.table.columns.product"),
t("portal.documents.table.columns.action"),
t("portal.documents.table.columns.user"),
t("portal.documents.table.columns.status"),
t("portal.documents.table.columns.time"),
];
const lines = [header.map(csvCell).join(",")];
for (const d of docs) {
const action = d.product === "Editor" || !d.action ? "Editor" : d.action;
const action =
d.product === "Editor" || !d.action
? t("portal.documents.table.editorAction")
: d.action;
const status =
DOCUMENT_STATUS_LABEL[d.status] +
t(DOCUMENT_STATUS_LABEL[d.status]) +
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : "");
lines.push(
[d.name, d.product, action, d.user || "", status, d.time]
@@ -66,7 +70,7 @@ export function Documents() {
const documents = state.data?.documents ?? [];
function exportCsv() {
const blob = new Blob([toCsv(documents)], {
const blob = new Blob([toCsv(documents, t)], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { Pipelines } from "@portal/views/Pipelines";
const meta: Meta<typeof Pipelines> = {
title: "Portal/Views/Pipelines",
component: Pipelines,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Pipelines>;
/** Seeded mock data: the KPI strip plus a populated pipelines table. */
export const Default: Story = {};
/**
* A fresh workspace with no pipelines. The stat boxes stay hidden and the
* empty-state panel drives the user to create a pipeline (primary) or connect a
* source (secondary).
*/
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies/overview", () =>
HttpResponse.json({
kpis: [
{ value: 0, description: "" },
{ value: 0, description: "" },
{ value: 0, description: "" },
],
pipelines: [],
}),
),
],
},
},
};
@@ -84,4 +84,32 @@ describe("Pipelines view", () => {
fireEvent.click(await screen.findByText("Redaction sweep"));
expect(await screen.findByText("pipeline page")).toBeInTheDocument();
});
it("shows the KPI stat boxes when pipelines exist", async () => {
renderView();
await screen.findByText("Redaction sweep");
expect(screen.getByText("portal.pipelines.kpi.total")).toBeInTheDocument();
});
it("hides the stat boxes and shows create + connect-source CTAs when empty", async () => {
fetchPipelines.mockResolvedValue({
kpis: [
{ value: 0, description: "" },
{ value: 0, description: "" },
{ value: 0, description: "" },
],
pipelines: [],
});
renderView();
expect(
await screen.findByText("portal.pipelines.empty.title"),
).toBeInTheDocument();
expect(
screen.getByText("portal.pipelines.empty.connectSource"),
).toBeInTheDocument();
// The KPI strip is gone: no stat-box labels over an empty page.
expect(
screen.queryByText("portal.pipelines.kpi.total"),
).not.toBeInTheDocument();
});
});
+25 -7
View File
@@ -9,6 +9,7 @@ import {
type PipelineView,
} from "@portal/api/pipelines";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { PipelinesIcon } from "@portal/components/icons";
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable";
import "@portal/views/Pipelines.css";
@@ -18,12 +19,18 @@ export function Pipelines() {
const navigate = useNavigate();
const state = useAsync<PipelinesOverviewResponse>(() => fetchPipelines(), []);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const { isLoading } = useSectionFlags(state);
const pipelines = data?.pipelines ?? [];
// Empty once the fetch settles with no pipelines (or fails → no data). Gates
// both the KPI strip and the empty panel so no placeholder stat boxes sit
// above an empty page.
const showEmpty = !isLoading && pipelines.length === 0;
const openCreate = () =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`);
const connectSource = () =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`);
// A row opens that pipeline's own page (view / edit / run / delete live there).
const openPipeline = (pipeline: PipelineView) =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`);
@@ -47,7 +54,7 @@ export function Pipelines() {
</Button>
</header>
<KpiStrip data={data} loading={loading} />
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-pipelines__table-skeleton" aria-hidden>
@@ -57,19 +64,30 @@ export function Pipelines() {
</div>
)}
{isEmpty && (
{showEmpty && (
<EmptyState
icon={<PipelinesIcon size={28} />}
title={t("portal.pipelines.empty.title")}
description={t("portal.pipelines.empty.description")}
actions={
<Button onClick={openCreate}>
{t("portal.pipelines.empty.action")}
</Button>
<>
<Button
onClick={openCreate}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.empty.action")}
</Button>
<Button variant="secondary" onClick={connectSource}>
{t("portal.pipelines.empty.connectSource")}
</Button>
</>
}
/>
)}
{!isLoading && !isEmpty && pipelines.length > 0 && (
{!isLoading && pipelines.length > 0 && (
<PipelinesTable pipelines={pipelines} onRowClick={openPipeline} />
)}
</div>
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { Policies } from "@portal/views/Policies";
const meta: Meta<typeof Policies> = {
title: "Portal/Views/Policies",
component: Policies,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Policies>;
/** Seeded mock data: the summary strip plus the configured catalogue. */
export const Default: Story = {};
/**
* A fresh workspace with no policies configured. The summary stat boxes stay
* hidden; the catalogue cards remain, since each one is the CTA to configure
* that policy category.
*/
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", () => HttpResponse.json([])),
http.get("/api/v1/policies/runs", () => HttpResponse.json([])),
],
},
},
};
@@ -36,6 +36,10 @@ export function Policies() {
const catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []);
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
// but the summary strip is pure stat boxes: hide it until at least one policy
// is configured so a fresh workspace doesn't show a row of zeros.
const hasPolicies = !!data && data.summary.active + data.summary.paused > 0;
const displayCatalogue: CatalogueEntry[] =
catalogue.length > 0
@@ -123,7 +127,7 @@ export function Policies() {
{pageError && <Banner tone="danger" description={pageError} />}
<CatalogueSummary data={data} loading={loading} />
{hasPolicies && <CatalogueSummary data={data} loading={loading} />}
{isLoading && (
<div className="portal-policies__grid" aria-hidden>
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { Sources } from "@portal/views/Sources";
const meta: Meta<typeof Sources> = {
title: "Portal/Views/Sources",
component: Sources,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Sources>;
/** Seeded mock data: the KPI strip plus a populated sources table. */
export const Default: Story = {};
/**
* A fresh workspace with no sources connected. The stat boxes stay hidden and
* the empty-state panel drives the user to connect a source.
*/
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/sources", () =>
HttpResponse.json({
kpis: [
{ value: 0, description: "" },
{ value: 0, description: "" },
{ value: 0, description: "" },
],
sources: [],
}),
),
],
},
},
};
@@ -146,4 +146,31 @@ describe("Sources view", () => {
expect.objectContaining({ id: "src-referenced", enabled: false }),
);
});
it("shows the KPI stat boxes when sources exist", async () => {
fetchSources.mockResolvedValue(RESPONSE);
renderView();
await screen.findByText("Claims intake");
expect(screen.getByText("portal.sources.kpi.total")).toBeInTheDocument();
});
it("hides the stat boxes and shows the connect CTA when empty", async () => {
fetchSources.mockResolvedValue({
kpis: [
{ value: 0, description: "" },
{ value: 0, description: "" },
{ value: 0, description: "" },
],
sources: [],
});
renderView();
// The empty-state panel renders.
expect(
await screen.findByText("portal.sources.empty.title"),
).toBeInTheDocument();
// The KPI strip is gone: no stat-box labels over an empty page.
expect(
screen.queryByText("portal.sources.kpi.total"),
).not.toBeInTheDocument();
});
});
+14 -5
View File
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { SourcesIcon } from "@portal/components/icons";
import { errorMessage } from "@portal/api/http";
import {
createSource,
@@ -29,7 +30,7 @@ export function Sources() {
const [version, setVersion] = useState(0);
const state = useAsync<SourcesResponse>(() => fetchSources(), [version]);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const { isLoading } = useSectionFlags(state);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -43,6 +44,10 @@ export function Sources() {
const sources = data?.sources ?? [];
const expanded = sources.find((s) => s.id === expandedId) ?? null;
// Empty once the fetch settles with no sources (or fails → no data). Gates
// both the KPI strip and the empty panel so no placeholder stat boxes sit
// above an empty page.
const showEmpty = !isLoading && sources.length === 0;
// The 30-day sparkline series lives off the list endpoint; fetch it for the one
// expanded row only (empty while collapsed, so no request fires).
@@ -146,7 +151,7 @@ export function Sources() {
{pageError && <Banner tone="danger" description={pageError} />}
<KpiStrip data={data} loading={loading} />
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
@@ -156,19 +161,23 @@ export function Sources() {
</div>
)}
{isEmpty && (
{showEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.sources.empty.title")}
description={t("portal.sources.empty.description")}
actions={
<Button onClick={openCreate}>
<Button
onClick={openCreate}
leftSection={<span aria-hidden>+</span>}
>
{t("portal.sources.actions.connectSource")}
</Button>
}
/>
)}
{!isLoading && !isEmpty && sources.length > 0 && (
{!isLoading && sources.length > 0 && (
<SourcesTable
sources={sources}
expandedId={expandedId}
-4
View File
@@ -175,8 +175,6 @@ ignore = [
'certSign.collab.participant.certInvalid',
'certSign.collab.signRequest.steps.invisible',
'certSign.collab.signRequest.steps.visible',
'certSign.collab.signRequest.text.colorLabel',
'certSign.collab.signRequest.text.fontSizePlaceholder',
'common.error',
'compare.pixel.base',
'config.account.profilePicture.cropper.zoom',
@@ -205,7 +203,6 @@ ignore = [
'getPdfInfo.summary.tech.javaScript',
'getPdfInfo.summary.tech.multimedia',
'home.devApi.title',
'landing.heroTitle',
'language.direction',
'mobileScanner.flash',
'no',
@@ -482,7 +479,6 @@ ignore = [
'admin.settings.connections.saml2.label',
'admin.settings.security.xFrameOptions.label',
'certSign.collab.participant.certInvalid',
'certSign.collab.signRequest.text.fontSizePlaceholder',
'convert.cbrToPdf',
'convert.cbzToPdf',
'convert.dpi',
+15 -13
View File
@@ -29,7 +29,7 @@ python3 scripts/translations/auto_translate.py es-ES --no-cleanup
**What it does:**
1. Extracts untranslated entries from the language file
2. Splits into batches (default 500 entries each)
3. Translates each batch using GPT-5 with specialized prompts
3. Translates each batch using GPT-5.6 with specialized prompts
4. Validates placeholders are preserved
5. Merges translated batches
6. Applies translations to language file
@@ -39,7 +39,7 @@ python3 scripts/translations/auto_translate.py es-ES --no-cleanup
**Time:** ~8-10 minutes per language with 1200+ untranslated entries
**Cost:** ~$2-4 per language using GPT-5 (or use `gpt-5-mini` for lower cost)
**Cost:** ~$8-15 per language using GPT-5.5 (or use `gpt-5.6-luna` for ~$2-4 if your org has 5.6 access)
See [`auto_translate.py`](#auto_translatepy-automated-translation-pipeline) for full details.
@@ -248,7 +248,7 @@ python scripts/translations/compact_translator.py it-IT --output to_translate.js
### 5. `auto_translate.py` - Automated Translation Pipeline
**NEW: Fully automated translation workflow using GPT-5.**
**NEW: Fully automated translation workflow using GPT-5.6.**
Combines all translation steps into a single command that handles everything from extraction to verification.
@@ -276,7 +276,7 @@ python3 scripts/translations/auto_translate.py es-ES --skip-verification
**Features:**
- Fully automated end-to-end translation pipeline
- Uses GPT-5 with specialized prompts for Stirling PDF
- Uses GPT-5.6 with specialized prompts for Stirling PDF
- Preserves all placeholders ({n}, {{variable}}, etc.)
- Maintains consistent terminology
- Validates translations automatically
@@ -286,7 +286,7 @@ python3 scripts/translations/auto_translate.py es-ES --skip-verification
**Pipeline Steps:**
1. **Extract**: Finds all untranslated entries
2. **Split**: Divides into manageable batches (default: 500 entries)
3. **Translate**: Uses GPT-5 to translate each batch with specialized prompts
3. **Translate**: Uses GPT-5.6 to translate each batch with specialized prompts
4. **Validate**: Ensures placeholders are preserved
5. **Merge**: Combines all translated batches
6. **Apply**: Updates the language file
@@ -304,7 +304,7 @@ python3 scripts/translations/auto_translate.py es-ES --skip-verification
**Supported Languages:**
All language codes from `frontend/editor/public/locales/` (e.g., es-ES, de-DE, fr-FR, zh-CN, ar-AR, etc.)
### 6. `batch_translator.py` - GPT-5 Translation Engine
### 6. `batch_translator.py` - GPT-5.6 Translation Engine
Low-level translation script used by `auto_translate.py`. Can be used standalone for manual batch translation.
@@ -316,25 +316,27 @@ python3 scripts/translations/batch_translator.py my_batch.json --language es-ES
# Translate multiple batches
python3 scripts/translations/batch_translator.py batch_*.json --language de-DE --api-key YOUR_KEY
# Use different GPT model
python3 scripts/translations/batch_translator.py batch.json --language fr-FR --model gpt-5-mini
# Use a cheaper model
python3 scripts/translations/batch_translator.py batch.json --language fr-FR --model gpt-5.6-luna
# Skip validation
python3 scripts/translations/batch_translator.py batch.json --language it-IT --skip-validation
```
**Features:**
- Translates JSON batch files using OpenAI GPT-5
- Translates JSON batch files using OpenAI GPT-5.6
- Specialized system prompts for Stirling PDF translations
- Automatic placeholder validation
- Supports pattern matching for multiple files
- Configurable model selection (gpt-5, gpt-5-mini, gpt-5-nano)
- Configurable model selection (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna)
- Per-batch and per-run token + cost reporting
- Rate limiting with configurable delays
**Models:**
- `gpt-5` (default): Best quality, $1.25/1M input, $10/1M output
- `gpt-5-mini`: Balanced quality/cost
- `gpt-5-nano`: Fastest, most economical
- `gpt-5.5` (default): Previous flagship, strong translation quality, $5/1M input, $30/1M output
- `gpt-5.6-sol`: Newest flagship reasoning, $5/1M input, $30/1M output (requires org 5.6 access)
- `gpt-5.6-terra`: Balanced quality/cost, $2.50/1M input, $15/1M output (requires org 5.6 access)
- `gpt-5.6-luna`: Fastest/cheapest, $1/1M input, $6/1M output (requires org 5.6 access)
### 7. `json_beautifier.py`
Restructures and beautifies translation JSON files to match en-US structure exactly.
+13 -5
View File
@@ -140,12 +140,14 @@ def extract_untranslated(language_code, batch_size=500, include_existing=False):
return batch_files
def translate_batches(batch_files, language_code, api_key, timeout=600):
"""Translate all batch files using GPT-5."""
def translate_batches(
batch_files, language_code, api_key, timeout=600, model="gpt-5.5"
):
"""Translate all batch files using the given OpenAI model."""
if not batch_files:
return []
print(f"\n🤖 Translating {len(batch_files)} batches using GPT-5...")
print(f"\n🤖 Translating {len(batch_files)} batches using {model}...")
print(f"Timeout: {timeout}s ({timeout // 60} minutes) per batch")
translated_files = []
@@ -154,7 +156,7 @@ def translate_batches(batch_files, language_code, api_key, timeout=600):
print(f"\n[{i}/{len(batch_files)}] Translating {batch_file}...")
# Always pass API key since it's required
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}"'
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}'
# Run with timeout
result = subprocess.run(
@@ -308,6 +310,11 @@ Examples:
action="store_true",
help="Also retranslate existing keys that match English (default: only translate missing keys)",
)
parser.add_argument(
"--model",
default="gpt-5.5",
help="OpenAI model (default: gpt-5.5; gpt-5.6-sol/terra/luna if your org has 5.6 access)",
)
args = parser.parse_args()
@@ -322,6 +329,7 @@ Examples:
print("=" * 60)
print("Automated Translation Pipeline")
print(f"Language: {args.language}")
print(f"Model: {args.model}")
print(f"Batch Size: {args.batch_size} entries")
print("=" * 60)
@@ -341,7 +349,7 @@ Examples:
# Step 2: Translate all batches
translated_files = translate_batches(
batch_files, args.language, api_key, args.timeout
batch_files, args.language, api_key, args.timeout, args.model
)
if translated_files is None:
sys.exit(1)
+53 -4
View File
@@ -22,11 +22,32 @@ except ImportError:
sys.exit(1)
# USD per 1M tokens (input, output)
MODEL_PRICING = {
"gpt-5.5": (5.0, 30.0),
"gpt-5.6-sol": (5.0, 30.0),
"gpt-5.6-terra": (2.5, 15.0),
"gpt-5.6-luna": (1.0, 6.0),
"gpt-5": (1.25, 10.0),
}
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""Estimate USD cost for a call; 0.0 if model pricing is unknown."""
if model not in MODEL_PRICING:
return 0.0
in_price, out_price = MODEL_PRICING[model]
return (prompt_tokens * in_price + completion_tokens * out_price) / 1_000_000
class BatchTranslator:
def __init__(self, api_key: str, model: str = "gpt-5"):
def __init__(self, api_key: str, model: str = "gpt-5.5"):
"""Initialize translator with OpenAI API key."""
self.client = OpenAI(api_key=api_key)
self.model = model
self.total_prompt_tokens = 0
self.total_completion_tokens = 0
self.total_cost = 0.0
def get_translation_prompt(self, language_name: str, language_code: str) -> str:
"""Generate the system prompt for translation."""
@@ -79,6 +100,23 @@ CRITICAL RULES - MUST FOLLOW EXACTLY:
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object."""
def _record_usage(self, response) -> None:
"""Accumulate token usage/cost and print a per-batch line."""
usage = getattr(response, "usage", None)
if usage is None:
return
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
cost = estimate_cost(self.model, prompt_tokens, completion_tokens)
self.total_prompt_tokens += prompt_tokens
self.total_completion_tokens += completion_tokens
self.total_cost += cost
cost_note = f", ~${cost:.4f}" if cost else ""
print(f" Tokens: {prompt_tokens:,} in / {completion_tokens:,} out{cost_note}")
def translate_batch(
self, batch_data: dict, target_language: str, language_code: str
) -> dict:
@@ -90,7 +128,7 @@ Return ONLY the translated JSON. No markdown, no explanations, just the JSON obj
print(f"Input size: {len(input_json)} characters")
try:
# GPT-5 only supports temperature=1, so we don't include it
# GPT-5.x models only support the default temperature, so we omit it
response = self.client.chat.completions.create(
model=self.model,
messages=[
@@ -107,6 +145,8 @@ Return ONLY the translated JSON. No markdown, no explanations, just the JSON obj
],
)
self._record_usage(response)
translated_text = response.choices[0].message.content.strip()
# Remove markdown code blocks if present
@@ -250,8 +290,8 @@ Examples:
)
parser.add_argument(
"--model",
default="gpt-5",
help="OpenAI model to use (default: gpt-5, options: gpt-5-mini, gpt-5-nano)",
default="gpt-5.5",
help="OpenAI model (default: gpt-5.5; gpt-5.6-sol/terra/luna if your org has 5.6 access)",
)
parser.add_argument(
"--output-suffix",
@@ -354,6 +394,15 @@ Examples:
if failed > 0:
print(f"Failed: {failed}/{len(input_files)}")
# Cost summary
print("-" * 60)
print(
f"Total tokens: {translator.total_prompt_tokens:,} in / "
f"{translator.total_completion_tokens:,} out"
)
if translator.total_cost:
print(f"Estimated cost ({args.model}): ${translator.total_cost:.4f}")
sys.exit(0 if failed == 0 else 1)
+15 -5
View File
@@ -98,6 +98,7 @@ def translate_language(
timeout: int,
skip_verification: bool,
include_existing: bool,
model: str,
) -> Tuple[str, bool, str]:
"""
Translate a single language.
@@ -115,6 +116,8 @@ def translate_language(
str(batch_size),
"--timeout",
str(timeout),
"--model",
model,
]
if skip_verification:
@@ -139,11 +142,11 @@ def translate_language(
safe_print(f"[{language}] ✓ Success")
return (language, True, "Success")
else:
error_msg = (
result.stderr.strip() or result.stdout.strip() or "Unknown error"
)
safe_print(f"[{language}] ✗ Failed: {error_msg[:100]}")
return (language, False, error_msg[:200]) # Truncate long errors
# Show the actual error (last line of output), not the header
output = result.stderr.strip() or result.stdout.strip() or "Unknown error"
error_msg = output.splitlines()[-1][:200]
safe_print(f"[{language}] ✗ Failed: {error_msg}")
return (language, False, error_msg)
except subprocess.TimeoutExpired:
safe_print(f"[{language}] ✗ Timeout exceeded")
@@ -178,6 +181,11 @@ Note: Requires OPENAI_API_KEY environment variable or --api-key argument.
parser.add_argument(
"--api-key", help="OpenAI API key (or set OPENAI_API_KEY env var)"
)
parser.add_argument(
"--model",
default="gpt-5.5",
help="OpenAI model (default: gpt-5.5; gpt-5.6-sol/terra/luna if your org has 5.6 access)",
)
parser.add_argument(
"--parallel",
type=int,
@@ -277,6 +285,7 @@ Note: Requires OPENAI_API_KEY environment variable or --api-key argument.
print("Bulk Translation Configuration")
print(f"{'=' * 60}")
print(f"Languages to translate: {len(languages)}")
print(f"Model: {args.model}")
print(f"Parallel threads: {args.parallel}")
print(f"Batch size: {args.batch_size}")
print(f"Timeout per batch: {args.timeout}s")
@@ -308,6 +317,7 @@ Note: Requires OPENAI_API_KEY environment variable or --api-key argument.
args.timeout,
args.skip_verification,
args.include_existing,
args.model,
): lang
for lang in languages
}