Merge branch 'main' into dev/ChangeBrowserLabelToMatchWorktree
This commit is contained in:
+10
-1
@@ -10,6 +10,7 @@ import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
|
||||
@@ -25,7 +26,15 @@ import lombok.Setter;
|
||||
* violation rather than a silent merge.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policy_processed_files")
|
||||
@Table(
|
||||
name = "policy_processed_files",
|
||||
indexes = {
|
||||
// presence cleanup: delete this policy's rows unseen since the sweep began
|
||||
@Index(name = "idx_processed_files_policy_seen", columnList = "policy_id, last_seen"),
|
||||
// cross-policy deletion consensus: existsByIdentityHashAndStatusNot filters
|
||||
// identity_hash on its own, so it cannot ride the (policy_id, identity_hash) PK
|
||||
@Index(name = "idx_processed_files_identity", columnList = "identity_hash")
|
||||
})
|
||||
@IdClass(ProcessedFileId.class)
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
|
||||
@@ -28,3 +28,9 @@ CREATE TABLE IF NOT EXISTS policy_processed_files (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processed_files_policy_seen
|
||||
ON policy_processed_files (policy_id, last_seen);
|
||||
|
||||
-- The cross-policy deletion-consensus check (existsByIdentityHashAndStatusNot) filters identity_hash
|
||||
-- alone, so it cannot use the (policy_id, identity_hash) primary key; it runs once per successfully
|
||||
-- consumed file, so index it to avoid a full scan on the hot path.
|
||||
CREATE INDEX IF NOT EXISTS idx_processed_files_identity
|
||||
ON policy_processed_files (identity_hash);
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
# VITE_EDITOR_URL=http://localhost:5173/).
|
||||
VITE_EDITOR_URL=/
|
||||
|
||||
# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default
|
||||
# (on in dev, off in production builds). Set "false" to run against the real
|
||||
# backend.
|
||||
VITE_PORTAL_MOCKS=
|
||||
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
|
||||
# linking (both values are public). Set per deploy; absent -> the account-link
|
||||
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
|
||||
# project the local backend links against.
|
||||
VITE_SAAS_SUPABASE_URL=
|
||||
VITE_SAAS_SUPABASE_ANON_KEY=
|
||||
|
||||
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
|
||||
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
|
||||
|
||||
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
|
||||
statusActive = "Active"
|
||||
statusPaused = "Paused"
|
||||
|
||||
[policies.docType]
|
||||
Contracts = "Contracts"
|
||||
"Financial reports" = "Financial reports"
|
||||
"HR records" = "HR records"
|
||||
Insurance = "Insurance"
|
||||
Invoices = "Invoices"
|
||||
"Legal filings" = "Legal filings"
|
||||
"Medical / PHI" = "Medical / PHI"
|
||||
"Tax documents" = "Tax documents"
|
||||
|
||||
[policies.enforcement]
|
||||
applying = "Applying {{names}}"
|
||||
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
|
||||
@@ -6001,6 +6011,65 @@ export = "Enforcing before export"
|
||||
input = "Enforcing on import"
|
||||
print = "Enforcing before print"
|
||||
|
||||
[policies.field]
|
||||
accessLog = "Access log"
|
||||
archiveAfter = "Archive after"
|
||||
auditTrail = "Audit trail"
|
||||
belowThreshold = "Below threshold"
|
||||
destination = "Destination"
|
||||
frameworks = "Frameworks"
|
||||
immutableHold = "Immutable hold"
|
||||
keepFor = "Keep for"
|
||||
minConfidence = "Min confidence"
|
||||
notify = "Notify on route"
|
||||
onViolation = "When non-compliant"
|
||||
webhookUrl = "Webhook URL"
|
||||
|
||||
[policies.fieldOption.archiveAfter]
|
||||
"1 year" = "1 year"
|
||||
"30 days" = "30 days"
|
||||
"90 days" = "90 days"
|
||||
Never = "Never"
|
||||
|
||||
[policies.fieldOption.belowThreshold]
|
||||
"Flag for review" = "Flag for review"
|
||||
Hold = "Hold"
|
||||
"Route to bucket" = "Route to bucket"
|
||||
|
||||
[policies.fieldOption.destination]
|
||||
Documents = "Documents"
|
||||
"S3 bucket" = "S3 bucket"
|
||||
SharePoint = "SharePoint"
|
||||
Webhook = "Webhook"
|
||||
|
||||
[policies.fieldOption.frameworks]
|
||||
FedRAMP = "FedRAMP"
|
||||
GDPR = "GDPR"
|
||||
HIPAA = "HIPAA"
|
||||
"ISO 27001" = "ISO 27001"
|
||||
"PCI DSS" = "PCI DSS"
|
||||
"SOC 2" = "SOC 2"
|
||||
|
||||
[policies.fieldOption.keepFor]
|
||||
"1 year" = "1 year"
|
||||
"3 years" = "3 years"
|
||||
"30 days" = "30 days"
|
||||
"7 years" = "7 years"
|
||||
Indefinite = "Indefinite"
|
||||
|
||||
[policies.fieldOption.minConfidence]
|
||||
"60%" = "60%"
|
||||
"70%" = "70%"
|
||||
"80%" = "80%"
|
||||
"90%" = "90%"
|
||||
"95%" = "95%"
|
||||
|
||||
[policies.fieldOption.onViolation]
|
||||
"Auto-redact PHI" = "Auto-redact PHI"
|
||||
"Block export" = "Block export"
|
||||
"Flag for review" = "Flag for review"
|
||||
"Quarantine document" = "Quarantine document"
|
||||
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
@@ -6526,6 +6595,19 @@ componentSpendMtd = "Component spend (MTD)"
|
||||
embedsThisMonth = "Embeds this month"
|
||||
inBeta = "In beta"
|
||||
|
||||
[portal.components.billingUnit]
|
||||
approval = "approval"
|
||||
check = "check"
|
||||
event = "event"
|
||||
render = "render"
|
||||
review = "review"
|
||||
session = "session"
|
||||
signature = "signature"
|
||||
|
||||
[portal.components.maturity]
|
||||
beta = "Beta"
|
||||
ga = "GA"
|
||||
|
||||
[portal.componentsView]
|
||||
subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props."
|
||||
title = "Components"
|
||||
@@ -6646,7 +6728,14 @@ subtitle = "Every document your org has processed, with its full processing reco
|
||||
title = "Documents"
|
||||
|
||||
[portal.documents.audit]
|
||||
approved = "Approved"
|
||||
archived = "Archived"
|
||||
elevation = "Elevation"
|
||||
empty = "No events recorded yet."
|
||||
extracted = "Processed"
|
||||
flagged = "Needs Review"
|
||||
ingested = "Ingested"
|
||||
reviewed = "In Review"
|
||||
|
||||
[portal.documents.drawer]
|
||||
sectionsAriaLabel = "Document detail sections"
|
||||
@@ -6697,6 +6786,12 @@ user = "User"
|
||||
description = "As sources feed documents into your pipelines they'll appear here for review."
|
||||
title = "No documents in the queue"
|
||||
|
||||
[portal.documents.status]
|
||||
error = "Error"
|
||||
flagged = "Needs Review"
|
||||
inReview = "In Review"
|
||||
processed = "Processed"
|
||||
|
||||
[portal.documents.table]
|
||||
auto = "Auto"
|
||||
editorAction = "Editor"
|
||||
@@ -6771,6 +6866,12 @@ title = "Service token"
|
||||
description = "A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline."
|
||||
title = "Rotate running instances"
|
||||
|
||||
[portal.editorAdmin.status]
|
||||
degraded = "Degraded"
|
||||
healthy = "Healthy"
|
||||
offline = "Offline"
|
||||
pairing = "Pairing"
|
||||
|
||||
[portal.editorAdmin.targets]
|
||||
instanceCount_one = "{{count}} instance"
|
||||
instanceCount_other = "{{count}} instances"
|
||||
@@ -7159,14 +7260,6 @@ models = "Models"
|
||||
security = "Security"
|
||||
storage = "Storage"
|
||||
|
||||
[portal.mocks.label]
|
||||
off = "Mocks OFF"
|
||||
on = "Mocks ON"
|
||||
|
||||
[portal.mocks.tooltip]
|
||||
off = "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
|
||||
on = "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
|
||||
|
||||
[portal.nav]
|
||||
agent-builder = "Agent Builder"
|
||||
components = "Components"
|
||||
@@ -7311,9 +7404,93 @@ subtitle = "Standing automations that enforce a tool pipeline on every document.
|
||||
title = "Policies"
|
||||
|
||||
[portal.policies.card]
|
||||
comingSoon = "Coming soon"
|
||||
comingSoon = "Upgrade to Enterprise"
|
||||
notSetUp = "Not set up"
|
||||
|
||||
[portal.policies.categories.compliance]
|
||||
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
|
||||
label = "Compliance"
|
||||
|
||||
[portal.policies.categories.ingestion]
|
||||
desc = "Classify documents, extract structured data, enforce naming conventions, and normalize pages."
|
||||
label = "Ingestion"
|
||||
|
||||
[portal.policies.categories.retention]
|
||||
desc = "Set how long documents are kept, when to archive, and when to delete."
|
||||
label = "Retention"
|
||||
|
||||
[portal.policies.categories.routing]
|
||||
desc = "Auto-route documents to the right team, folder, or system."
|
||||
label = "Routing"
|
||||
|
||||
[portal.policies.categories.security]
|
||||
desc = "Detect PII, redact, strip active content, and watermark documents."
|
||||
label = "Security"
|
||||
|
||||
[portal.policies.config]
|
||||
scopeAll = "All documents"
|
||||
|
||||
[portal.policies.config.compliance]
|
||||
summary = "Validates documents against regulatory frameworks before they leave the system."
|
||||
|
||||
[portal.policies.config.compliance.fields]
|
||||
accessLog = "Access log"
|
||||
auditTrail = "Audit trail"
|
||||
frameworks = "Frameworks"
|
||||
onViolation = "When non-compliant"
|
||||
|
||||
[portal.policies.config.compliance.rules]
|
||||
0 = "Framework scan"
|
||||
1 = "Enforce action"
|
||||
2 = "Audit trail"
|
||||
|
||||
[portal.policies.config.ingestion]
|
||||
summary = "Classifies documents, extracts structured data, enforces naming, and normalizes pages."
|
||||
|
||||
[portal.policies.config.ingestion.fields]
|
||||
belowThreshold = "Below threshold"
|
||||
minConfidence = "Min confidence"
|
||||
|
||||
[portal.policies.config.ingestion.rules]
|
||||
0 = "Classify"
|
||||
1 = "Extract"
|
||||
2 = "Name"
|
||||
3 = "Normalize"
|
||||
|
||||
[portal.policies.config.retention]
|
||||
summary = "Enforces how long documents are kept, when to archive, and when to delete."
|
||||
|
||||
[portal.policies.config.retention.fields]
|
||||
archiveAfter = "Archive after"
|
||||
immutableHold = "Immutable hold"
|
||||
keepFor = "Keep for"
|
||||
|
||||
[portal.policies.config.retention.rules]
|
||||
0 = "Retention hold"
|
||||
1 = "Auto-archive"
|
||||
2 = "Deletion block"
|
||||
|
||||
[portal.policies.config.routing]
|
||||
summary = "Routes documents to the right destination based on type and classification."
|
||||
|
||||
[portal.policies.config.routing.fields]
|
||||
destination = "Destination"
|
||||
notify = "Notify on route"
|
||||
webhookUrl = "Webhook URL"
|
||||
|
||||
[portal.policies.config.routing.rules]
|
||||
0 = "Auto-classify"
|
||||
1 = "Route to folder"
|
||||
2 = "Webhook notify"
|
||||
|
||||
[portal.policies.config.security]
|
||||
summary = "Detects and redacts PII, strips active content (JavaScript), and watermarks documents."
|
||||
|
||||
[portal.policies.config.security.rules]
|
||||
0 = "Redact PII"
|
||||
1 = "Remove JavaScript"
|
||||
2 = "Watermark"
|
||||
|
||||
[portal.policies.detail]
|
||||
enforces = "Enforces"
|
||||
onEveryExport = "On every export"
|
||||
@@ -7334,10 +7511,24 @@ pause = "Pause"
|
||||
resume = "Resume"
|
||||
runNow = "Run now"
|
||||
|
||||
[portal.policies.detail.clearHistory]
|
||||
body = "This policy will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone."
|
||||
cancel = "Cancel"
|
||||
confirm = "Clear history"
|
||||
title = "Clear processed history?"
|
||||
|
||||
[portal.policies.detail.emptyActivity]
|
||||
description = "Documents will appear here once this policy runs."
|
||||
title = "No activity yet"
|
||||
|
||||
[portal.policies.endpoints]
|
||||
addWatermark = "Watermark"
|
||||
autoRedact = "Redact PII"
|
||||
compressPdf = "Compress"
|
||||
flatten = "Flatten"
|
||||
ocrPdf = "OCR"
|
||||
sanitizePdf = "Remove JavaScript"
|
||||
|
||||
[portal.policies.offline]
|
||||
description = "Your policies are saved and will appear once the connection is restored."
|
||||
retry = "Retry"
|
||||
@@ -7375,14 +7566,29 @@ continue = "Continue"
|
||||
enablePolicy = "Enable policy"
|
||||
saveChanges = "Save changes"
|
||||
|
||||
[portal.policies.wizard.docTypes]
|
||||
allDescription = "Set up an Ingestion (classification) policy to narrow this to specific document types."
|
||||
allTitle = "All document types"
|
||||
clear = "Clear"
|
||||
heading = "Document types"
|
||||
narrow = "Narrow"
|
||||
selected_one = "{{count}} selected"
|
||||
selected_other = "{{count}} selected"
|
||||
[portal.policies.wizard.capability.compress]
|
||||
desc = "Compresses the document to a smaller file size."
|
||||
label = "Reduce file size"
|
||||
|
||||
[portal.policies.wizard.capability.flatten]
|
||||
desc = "Merges form fields and annotations into the page so they can't be edited."
|
||||
label = "Flatten the document"
|
||||
|
||||
[portal.policies.wizard.capability.ocr]
|
||||
desc = "Runs OCR so scanned pages become selectable, searchable text."
|
||||
label = "Make text searchable"
|
||||
|
||||
[portal.policies.wizard.capability.redact]
|
||||
desc = "Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read."
|
||||
label = "Redact sensitive information"
|
||||
|
||||
[portal.policies.wizard.capability.sanitize]
|
||||
desc = "Removes hidden JavaScript so nothing can run automatically when the document is opened."
|
||||
label = "Strip active content"
|
||||
|
||||
[portal.policies.wizard.capability.watermark]
|
||||
desc = "Stamps a visible mark (e.g. “Confidential”) across every page."
|
||||
label = "Apply a watermark"
|
||||
|
||||
[portal.policies.wizard.errors]
|
||||
noTools = "Enable at least one tool in the workflow first."
|
||||
@@ -7403,11 +7609,6 @@ label = "Output as"
|
||||
newFile = "New file"
|
||||
newVersion = "New version"
|
||||
|
||||
[portal.policies.wizard.output.retries]
|
||||
delayLabel = "Retry delay (min)"
|
||||
heading = "Retries"
|
||||
maxLabel = "Max retries"
|
||||
|
||||
[portal.policies.wizard.output.runOn]
|
||||
export = "Export"
|
||||
helper = "When the policy fires: on upload, or before export."
|
||||
@@ -7418,22 +7619,20 @@ upload = "Upload"
|
||||
heading = "Settings"
|
||||
|
||||
[portal.policies.wizard.sources]
|
||||
emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here."
|
||||
emptyTitle = "No sources available"
|
||||
heading = "Sources"
|
||||
loading = "Loading sources…"
|
||||
|
||||
[portal.policies.wizard.tabs]
|
||||
ariaLabel = "Setup steps"
|
||||
settings = "Settings"
|
||||
workflow = "Workflow"
|
||||
workflow = "Actions"
|
||||
|
||||
[portal.policies.wizard.title]
|
||||
edit = "Edit {{category}} policy"
|
||||
setUp = "Set up {{category}} policy"
|
||||
|
||||
[portal.policies.wizard.workflow]
|
||||
description = "The sequence of tools this policy runs on each document. Each tool is a Stirling endpoint; toggle the ones this policy should enforce."
|
||||
description = "Choose what this policy does to every document it processes."
|
||||
|
||||
[portal.policySummary]
|
||||
activeSummary = "{{active}} / {{total}} active"
|
||||
@@ -7442,7 +7641,7 @@ subtitle = "Standing automations every document passes through, regardless of wh
|
||||
title = "What runs on your PDFs"
|
||||
|
||||
[portal.policySummary.action]
|
||||
comingSoon = "Coming soon"
|
||||
comingSoon = "Upgrade to Enterprise"
|
||||
configure = "Configure"
|
||||
setUp = "Set up"
|
||||
|
||||
@@ -7585,6 +7784,31 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
|
||||
title = "From trial to live, one guided path"
|
||||
trialTitle = "Enterprise trial"
|
||||
|
||||
[portal.procurement.journeySteps.agreement]
|
||||
blurb = "One signature covers MSA, order form, EULA and DPA."
|
||||
gatingAction = "Review and sign your agreement"
|
||||
label = "Agreement"
|
||||
|
||||
[portal.procurement.journeySteps.implementation]
|
||||
blurb = "Provision your workspace and run the go-live playbook."
|
||||
gatingAction = "Provisioning your workspace"
|
||||
label = "Implementation"
|
||||
|
||||
[portal.procurement.journeySteps.payment]
|
||||
blurb = "Pay by card, bank transfer, or against a purchase order."
|
||||
gatingAction = "Confirm payment"
|
||||
label = "Payment"
|
||||
|
||||
[portal.procurement.journeySteps.quote]
|
||||
blurb = "Review committed-volume pricing and contract term."
|
||||
gatingAction = "Accept your quote"
|
||||
label = "Quote"
|
||||
|
||||
[portal.procurement.journeySteps.trial]
|
||||
blurb = "Evaluate Stirling against your documents and workflows."
|
||||
gatingAction = "Build your quote"
|
||||
label = "Trial"
|
||||
|
||||
[portal.procurement.license]
|
||||
copied = "Copied"
|
||||
copy = "Copy key"
|
||||
@@ -7697,122 +7921,11 @@ noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
|
||||
[portal.settings]
|
||||
ariaLabel = "Settings"
|
||||
cancel = "Cancel"
|
||||
enterpriseBadge = "Enterprise"
|
||||
footerNote = "Changes apply to this workspace."
|
||||
saveChanges = "Save changes"
|
||||
|
||||
[portal.settings.appearance]
|
||||
themeSub = "Choose how the portal looks on this device."
|
||||
themeTitle = "Theme"
|
||||
|
||||
[portal.settings.appearance.dark]
|
||||
hint = "Dim surfaces"
|
||||
label = "Dark"
|
||||
|
||||
[portal.settings.appearance.light]
|
||||
hint = "Bright surfaces"
|
||||
label = "Light"
|
||||
|
||||
[portal.settings.authentication]
|
||||
sessionTimeout = "Session timeout"
|
||||
sessionTimeoutHelper = "Members re-authenticate after this idle period."
|
||||
sub = "Organisation-wide authentication controls."
|
||||
title = "Sign-in policy"
|
||||
|
||||
[portal.settings.authentication.mfa]
|
||||
description = "Require every member to complete MFA at sign-in."
|
||||
label = "Enforce two-factor (MFA)"
|
||||
|
||||
[portal.settings.authentication.scim]
|
||||
description = "Sync members and roles from your directory."
|
||||
label = "SCIM provisioning"
|
||||
|
||||
[portal.settings.authentication.sso]
|
||||
description = "Federate sign-in through your identity provider."
|
||||
label = "Single sign-on (SAML)"
|
||||
|
||||
[portal.settings.authentication.timeout]
|
||||
1440 = "24 hours"
|
||||
240 = "4 hours"
|
||||
480 = "8 hours"
|
||||
60 = "1 hour"
|
||||
720 = "12 hours"
|
||||
|
||||
[portal.settings.earlyAccess]
|
||||
sub = "Opt into features still in preview."
|
||||
title = "Preview features"
|
||||
|
||||
[portal.settings.groups]
|
||||
account = "Account"
|
||||
admin = "Admin"
|
||||
workspace = "Workspace"
|
||||
|
||||
[portal.settings.notifications]
|
||||
sub = "Pick which events reach your inbox."
|
||||
title = "Email notifications"
|
||||
|
||||
[portal.settings.notifications.pipeline-failures]
|
||||
description = "A run errors out or a step times out."
|
||||
label = "Pipeline failures"
|
||||
|
||||
[portal.settings.notifications.pipeline-success]
|
||||
description = "Every successful pipeline run finishes."
|
||||
label = "Pipeline completions"
|
||||
|
||||
[portal.settings.notifications.product-updates]
|
||||
description = "New operations, sources, and release notes."
|
||||
label = "Product updates"
|
||||
|
||||
[portal.settings.notifications.security-alerts]
|
||||
description = "New API keys, sign-ins, or permission changes."
|
||||
label = "Security alerts"
|
||||
|
||||
[portal.settings.notifications.usage-alerts]
|
||||
description = "You approach a plan limit or rate cap."
|
||||
label = "Usage & quota alerts"
|
||||
|
||||
[portal.settings.notifications.weekly-digest]
|
||||
description = "A Monday summary of volume and health."
|
||||
label = "Weekly digest"
|
||||
|
||||
[portal.settings.profile]
|
||||
accountFallback = "Account"
|
||||
changePhoto = "Change photo"
|
||||
email = "Email"
|
||||
emailHelper = "Used for sign-in and notification delivery."
|
||||
emailPlaceholder = "you@company.com"
|
||||
fullName = "Full name"
|
||||
namePlaceholder = "Your name"
|
||||
|
||||
[portal.settings.sections]
|
||||
account-link = "Account link"
|
||||
appearance = "Appearance"
|
||||
authentication = "Authentication"
|
||||
early-access = "Early access"
|
||||
general = "General"
|
||||
notifications = "Notifications"
|
||||
profile = "Profile"
|
||||
sessions = "Active sessions"
|
||||
|
||||
[portal.settings.sessions]
|
||||
revoke = "Revoke"
|
||||
sub = "Devices currently signed in to this account."
|
||||
thisDevice = "This device"
|
||||
title = "Active sessions"
|
||||
|
||||
[portal.settings.workspace]
|
||||
manageBilling = "Manage billing"
|
||||
nameLabel = "Workspace name"
|
||||
namePlaceholder = "Workspace name"
|
||||
plan = "Plan"
|
||||
regionEnterpriseSuffix = "{{region}} · Enterprise"
|
||||
regionHelper = "Where documents are processed and stored at rest."
|
||||
regionLabel = "Data residency region"
|
||||
seats = "Seats"
|
||||
seatsUsed = "{{used}} of {{total}} used"
|
||||
|
||||
[portal.shell.header]
|
||||
accountFallback = "Account"
|
||||
@@ -7957,11 +8070,56 @@ title = "Session expired"
|
||||
[portal.users]
|
||||
title = "Users"
|
||||
|
||||
[portal.users.roles]
|
||||
subtitle = "Every role exists on every plan — what each one can do is fixed across the org."
|
||||
title = "Roles"
|
||||
|
||||
[portal.users.roles.admin]
|
||||
label = "Admin (Org owner)"
|
||||
summary = "Full governance over the workspace, settings and members."
|
||||
|
||||
[portal.users.roles.admin.permissions]
|
||||
0 = "Manage users, teams and roles"
|
||||
1 = "Manage all integrations incl. S3 connections"
|
||||
2 = "Grant or revoke portal access"
|
||||
3 = "Everything Team Owner can do"
|
||||
|
||||
[portal.users.roles.guest]
|
||||
label = "Guest"
|
||||
summary = "Limited or web-only access; cannot hold personal configs."
|
||||
|
||||
[portal.users.roles.guest.permissions]
|
||||
0 = "Web-only / demo usage"
|
||||
1 = "No API keys or integrations"
|
||||
2 = "No portal access"
|
||||
3 = "Read-only where shared"
|
||||
|
||||
[portal.users.roles.member]
|
||||
label = "Member"
|
||||
summary = "Regular user — works with shared resources and their own configs."
|
||||
|
||||
[portal.users.roles.member.permissions]
|
||||
0 = "Use the editor and shared integrations"
|
||||
1 = "Create personal API & MCP configs"
|
||||
2 = "See team configs shared with them"
|
||||
3 = "No S3 or workspace management"
|
||||
|
||||
[portal.users.roles.team_owner]
|
||||
label = "Team owner"
|
||||
summary = "Owns a team — manages its members' resources and shared configs."
|
||||
|
||||
[portal.users.roles.team_owner.permissions]
|
||||
0 = "Create & manage the team's S3 connections"
|
||||
1 = "Manage team-owned integration configs"
|
||||
2 = "Portal access via the default policy"
|
||||
3 = "Everything Member can do"
|
||||
|
||||
[portal.welcome]
|
||||
ariaLabel = "Welcome to Stirling PDF"
|
||||
badge = "Open-source"
|
||||
installEditor = "Install the Editor"
|
||||
inviteTeammates = "Invite teammates"
|
||||
openInBrowser = "Open in browser"
|
||||
perks = "Free forever · Self-hostable"
|
||||
subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO."
|
||||
title = "Welcome to"
|
||||
|
||||
@@ -499,6 +499,11 @@
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Payg Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/account-link": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Account Link Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
}
|
||||
},
|
||||
"byPath": {
|
||||
@@ -653,6 +658,7 @@
|
||||
"/settings/legal": "/settings/legal",
|
||||
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
|
||||
"/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses",
|
||||
"/settings/payg": "/settings/payg"
|
||||
"/settings/payg": "/settings/payg",
|
||||
"/settings/account-link": "/settings/account-link"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import {
|
||||
NavKey,
|
||||
VALID_NAV_KEYS,
|
||||
type ConfigNavSection,
|
||||
} from "@app/components/shared/config/types";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
@@ -31,6 +35,18 @@ import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Mirror the active section to /settings/<key> URLs (deep links, history
|
||||
* unwind on close). Hosts mounted away from the editor's /settings route —
|
||||
* the admin portal — turn this off and the modal keeps its section purely in
|
||||
* state.
|
||||
*/
|
||||
urlSync?: boolean;
|
||||
/** Section to land on when opening. Only honoured when urlSync is off (URL
|
||||
* deep links win otherwise). */
|
||||
initialSection?: NavKey | null;
|
||||
/** Host-specific sections appended after the build's registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
}
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
@@ -46,12 +62,18 @@ const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
urlSync = true,
|
||||
initialSection,
|
||||
extraSections,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Initialize from the URL so a deep link (`/settings/people`) lands on the
|
||||
// right tab without a one-frame "general" flicker.
|
||||
const [active, setActive] = useState<NavKey>(
|
||||
() => getSectionFromPath(window.location.pathname) ?? "general",
|
||||
() =>
|
||||
(urlSync ? getSectionFromPath(window.location.pathname) : null) ??
|
||||
initialSection ??
|
||||
"general",
|
||||
);
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
@@ -66,6 +88,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
// those update the URL via `history.replaceState` directly and never push
|
||||
// a new React Router location.
|
||||
useEffect(() => {
|
||||
if (!urlSync) return;
|
||||
const section = getSectionFromPath(location.pathname);
|
||||
if (opened && section) {
|
||||
setActive(section);
|
||||
@@ -77,7 +100,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
// If at /settings without a section, redirect to general
|
||||
navigate("/settings/general", { replace: true });
|
||||
}
|
||||
}, [location.pathname, opened, navigate]);
|
||||
}, [location.pathname, opened, navigate, urlSync]);
|
||||
|
||||
// Non-URL hosts land the modal on the section they asked for.
|
||||
useEffect(() => {
|
||||
if (opened && !urlSync && initialSection) {
|
||||
setActive(initialSection);
|
||||
}
|
||||
}, [opened, urlSync, initialSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
@@ -99,6 +129,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const switchSection = useCallback(
|
||||
(key: NavKey) => {
|
||||
setActive(key);
|
||||
if (!urlSync) return;
|
||||
const alreadyInSettings = stripBasePath(
|
||||
window.location.pathname,
|
||||
).startsWith("/settings");
|
||||
@@ -112,7 +143,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
navigate(`/settings/${key}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
[navigate, urlSync],
|
||||
);
|
||||
|
||||
// Backwards-compat: external `appConfig:navigate` events route through the
|
||||
@@ -156,7 +187,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
|
||||
// Only unwind history if settings was opened via the URL; opened via state
|
||||
// there's no /settings entry to pop and navigate(-1) would jump to /files.
|
||||
if (location.pathname.startsWith("/settings")) {
|
||||
if (urlSync && location.pathname.startsWith("/settings")) {
|
||||
// "default" key = first entry (deep link/refresh); nothing to pop to.
|
||||
if (location.key === "default") {
|
||||
navigate("/", { replace: true });
|
||||
@@ -165,7 +196,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}, [confirmIfDirty, location.key, location.pathname, navigate, onClose]);
|
||||
}, [
|
||||
confirmIfDirty,
|
||||
location.key,
|
||||
location.pathname,
|
||||
navigate,
|
||||
onClose,
|
||||
urlSync,
|
||||
]);
|
||||
|
||||
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
|
||||
const handleCloseSync = useCallback(() => {
|
||||
@@ -173,12 +211,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}, [handleClose]);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useConfigNavSections(
|
||||
const registrySections = useConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
handleCloseSync,
|
||||
);
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
extraSections?.length
|
||||
? [...registrySections, ...extraSections]
|
||||
: registrySections,
|
||||
[registrySections, extraSections],
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import type {
|
||||
ConfigNavSection,
|
||||
NavKey,
|
||||
} from "@app/components/shared/config/types";
|
||||
|
||||
// AppConfigModal pulls in the entire settings UI tree (admin sections,
|
||||
// account, supabase auth flows, etc.). We defer loading until the user first
|
||||
@@ -10,11 +14,20 @@ const AppConfigModal = lazy(
|
||||
interface AppConfigModalLazyProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** See AppConfigModal — off for hosts outside the /settings route. */
|
||||
urlSync?: boolean;
|
||||
/** Section to land on when opening (non-URL hosts). */
|
||||
initialSection?: NavKey | null;
|
||||
/** Host-specific sections appended after the build's registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
}
|
||||
|
||||
export default function AppConfigModalLazy({
|
||||
opened,
|
||||
onClose,
|
||||
urlSync,
|
||||
initialSection,
|
||||
extraSections,
|
||||
}: AppConfigModalLazyProps) {
|
||||
const [shouldMount, setShouldMount] = useState(false);
|
||||
|
||||
@@ -24,7 +37,15 @@ export default function AppConfigModalLazy({
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{shouldMount && <AppConfigModal opened={opened} onClose={onClose} />}
|
||||
{shouldMount && (
|
||||
<AppConfigModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
urlSync={urlSync}
|
||||
initialSection={initialSection}
|
||||
extraSections={extraSections}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||
@@ -9,22 +8,14 @@ import {
|
||||
BackendThirdPartyLicensesSection,
|
||||
FrontendThirdPartyLicensesSection,
|
||||
} from "@app/components/shared/config/configSections/ThirdPartyLicensesSection";
|
||||
import type {
|
||||
ConfigNavItem,
|
||||
ConfigNavSection,
|
||||
} from "@app/components/shared/config/types";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
badge?: string;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
// Re-exported for the many existing importers; the definitions live in
|
||||
// config/types so type-only consumers don't pull the section tree in.
|
||||
export type { ConfigNavItem, ConfigNavSection };
|
||||
|
||||
export interface ConfigColors {
|
||||
navBg: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type React from "react";
|
||||
|
||||
// Single source of truth for all valid nav keys
|
||||
export const VALID_NAV_KEYS = [
|
||||
"preferences",
|
||||
@@ -35,9 +37,29 @@ export const VALID_NAV_KEYS = [
|
||||
"backendThirdPartyLicenses",
|
||||
"frontendThirdPartyLicenses",
|
||||
"payg",
|
||||
"account-link",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
export type NavKey = (typeof VALID_NAV_KEYS)[number];
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
|
||||
// Nav structure of the settings modal. Lives here (not configNavSections) so
|
||||
// consumers that only need the shape don't pull the whole section-component
|
||||
// tree into their build's typecheck graph.
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
badge?: string;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
+18
-12
@@ -23,6 +23,8 @@ interface AddWatermarkSingleStepSettingsProps {
|
||||
disabled?: boolean;
|
||||
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
|
||||
showFlatten?: boolean;
|
||||
/** When true, lock to text watermarks: hide the type selector and image option (e.g. in policies). */
|
||||
textOnly?: boolean;
|
||||
}
|
||||
|
||||
const AddWatermarkSingleStepSettings = ({
|
||||
@@ -30,20 +32,24 @@ const AddWatermarkSingleStepSettings = ({
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
showFlatten = true,
|
||||
textOnly = false,
|
||||
}: AddWatermarkSingleStepSettingsProps) => {
|
||||
const isText = textOnly || parameters.watermarkType === "text";
|
||||
const isImage = !textOnly && parameters.watermarkType === "image";
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Watermark Type Selection */}
|
||||
<WatermarkTypeSettings
|
||||
watermarkType={parameters.watermarkType}
|
||||
onWatermarkTypeChange={(type) =>
|
||||
onParameterChange("watermarkType", type)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/* Watermark type selection — hidden when locked to text. */}
|
||||
{!textOnly && (
|
||||
<WatermarkTypeSettings
|
||||
watermarkType={parameters.watermarkType}
|
||||
onWatermarkTypeChange={(type) =>
|
||||
onParameterChange("watermarkType", type)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conditional settings based on watermark type */}
|
||||
{parameters.watermarkType === "text" && (
|
||||
{isText && (
|
||||
<>
|
||||
<WatermarkWording
|
||||
parameters={parameters}
|
||||
@@ -58,7 +64,7 @@ const AddWatermarkSingleStepSettings = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{parameters.watermarkType === "image" && (
|
||||
{isImage && (
|
||||
<WatermarkImageFile
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
@@ -67,7 +73,7 @@ const AddWatermarkSingleStepSettings = ({
|
||||
)}
|
||||
|
||||
{/* Formatting settings for both text and image */}
|
||||
{parameters.watermarkType && (
|
||||
{(textOnly || parameters.watermarkType) && (
|
||||
<WatermarkFormatting
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
|
||||
@@ -89,6 +89,21 @@ export const I18N_PROJECTS: TranslationProject[] = [
|
||||
// components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the
|
||||
// static scan.
|
||||
/^portal\.sources\.types\./,
|
||||
// Portal catalogue copy stored as i18n keys in api/<surface>.ts constants
|
||||
// (label maps, role/policy/journey catalogues) and rendered via
|
||||
// t(constant), invisible to the static scan.
|
||||
/^portal\.documents\.(status|audit)\./,
|
||||
/^portal\.editorAdmin\.status\./,
|
||||
/^portal\.components\.(maturity|billingUnit)\./,
|
||||
/^portal\.home\.(pipelineTemplates|pipelineStages)\./,
|
||||
/^portal\.procurement\.journeySteps\./,
|
||||
/^portal\.users\.roles\./,
|
||||
/^portal\.policies\.(categories|config|endpoints)\./,
|
||||
// Policy field/option/doc-type display copy is looked up with keys
|
||||
// derived from catalogue data (t(`policies.fieldOption.${key}.${opt}`))
|
||||
// in both PolicyFieldRows and the setup wizards — invisible to the
|
||||
// static scan. The raw catalogue value stays the stored fallback.
|
||||
/^policies\.(field|fieldOption|docType)\./,
|
||||
],
|
||||
minUsedKeys: 100,
|
||||
minLocaleKeys: 100,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from "react";
|
||||
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
|
||||
|
||||
export const VALID_NAV_KEYS = [
|
||||
@@ -7,3 +8,21 @@ export const VALID_NAV_KEYS = [
|
||||
] as const;
|
||||
|
||||
export type NavKey = (typeof VALID_NAV_KEYS)[number];
|
||||
|
||||
// Mirrors the core shape over the widened desktop NavKey union — see the core
|
||||
// module for why these live in types rather than configNavSections.
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
badge?: string;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PortalChrome } from "@portal/components/PortalChrome";
|
||||
*/
|
||||
export function PortalProviders() {
|
||||
return (
|
||||
<TierProvider initialTier="pro">
|
||||
<TierProvider>
|
||||
<UIProvider>
|
||||
<PortalChrome />
|
||||
</UIProvider>
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/set
|
||||
|
||||
/**
|
||||
* SaaS has no account-link concept — the signed-in account IS the SaaS account.
|
||||
* Null drops the "Account link" nav item and its panel from Settings (the shared
|
||||
* SettingsModal treats the seam as optional), so the link-only AccountLinkPanel
|
||||
* is never imported into the SaaS bundle.
|
||||
* Null drops the "Account link" nav item and its panel from the shared settings
|
||||
* modal (the portal host treats the seam as optional), so the link-only
|
||||
* AccountLinkPanel is never imported into the SaaS bundle.
|
||||
*/
|
||||
export const accountLinkSettings: AccountLinkSettingsSeam | null = null;
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
# Portal data layer — mock backend & handover
|
||||
|
||||
The portal is **mock-driven**. Every screen fetches real HTTP requests through a
|
||||
thin typed API layer; in dev and Storybook those requests are intercepted by
|
||||
[MSW](https://mswjs.io/) and answered with fixture data. Pointing the portal at a
|
||||
**real backend** is a matter of *not registering MSW* — no component or API-layer
|
||||
code changes.
|
||||
The mocks here are for **Storybook and tests** — the running portal hits the
|
||||
real network. Every screen fetches real HTTP requests through a thin typed API
|
||||
layer; in Storybook (via `msw-storybook-addon`, see `.storybook/preview.tsx`)
|
||||
and in vitest those requests are intercepted by [MSW](https://mswjs.io/) and
|
||||
answered with fixture data.
|
||||
|
||||
One deliberate in-app exception: `api/demoData.ts` can answer `apiClient`
|
||||
calls from these same handlers while explicitly enabled (the onboarding tour
|
||||
does this so views render populated). No service worker, no interception —
|
||||
msw and the fixtures load lazily on first enable and stop answering on
|
||||
disable.
|
||||
|
||||
## The three layers
|
||||
|
||||
```
|
||||
view (useAsync) ──► api/<surface>.ts ──► httpJson(fetch) ──► MSW handler (dev) ──► fixture builder
|
||||
(the contract) └► real backend (prod) ─┘
|
||||
view (useAsync) ──► api/<surface>.ts ──► httpJson(fetch) ──► real backend (app)
|
||||
(the contract) └► MSW handler (Storybook/tests) ──► fixture builder
|
||||
```
|
||||
|
||||
1. **`api/<surface>.ts`** — thin, typed `httpJson` wrappers. **This is the backend
|
||||
contract.** Each function documents its endpoint (method, path, query params)
|
||||
and its response type. Nothing else in the app issues fetches.
|
||||
1. **`api/<surface>.ts`** — thin, typed `httpJson` wrappers **plus the canonical
|
||||
TS types and app-owned constants. This is the backend contract.** Each
|
||||
function documents its endpoint (method, path, query params) and its response
|
||||
type. Nothing else in the app issues fetches, and nothing in the app imports
|
||||
from `mocks/`.
|
||||
2. **`mocks/handlers/<surface>.ts`** — MSW handlers that answer those endpoints
|
||||
with `mocks/<surface>.ts` fixtures. Registered in `mocks/handlers/index.ts`.
|
||||
3. **`mocks/<surface>.ts`** — fixture builders **and the canonical TS types**
|
||||
(re-exported through `api/<surface>.ts`, so consumers import types from `api/`).
|
||||
3. **`mocks/<surface>.ts`** — fixture builders only, importing their types from
|
||||
`api/<surface>.ts` (mocks depend on the contract, never the reverse).
|
||||
|
||||
`api/http.ts` is the single `fetch` wrapper (sets headers, throws `HttpError` on
|
||||
non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.ts`).
|
||||
@@ -34,15 +42,13 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
|
||||
**demo shells** (local state, no submit endpoint yet) — wire these to real
|
||||
POSTs during backend integration.
|
||||
|
||||
## Swapping in a real backend
|
||||
## Implementing a surface against the real backend
|
||||
|
||||
1. Stop registering MSW (`mocks/browser.ts` / the dev bootstrap) — or gate it on
|
||||
an env flag (it's already dev-only via `import.meta.env.DEV`).
|
||||
2. Make `httpJson` hit your API origin (add a `baseURL`/proxy in `api/http.ts`).
|
||||
3. Match the response shapes in `api/<surface>.ts` (the exported types are the spec).
|
||||
4. Delete `mocks/` once parity is confirmed. Optionally relocate the types from
|
||||
`mocks/<surface>.ts` into `api/` (or a `types/` module) so they no longer live
|
||||
beside fixtures — purely cosmetic; the `api/` re-exports already shield consumers.
|
||||
1. Make `httpJson` hit your API origin (add a `baseURL`/proxy in `api/http.ts`)
|
||||
and match the response shapes in `api/<surface>.ts` (the exported types are
|
||||
the spec).
|
||||
2. Keep the surface's handler in `mocks/handlers/` in sync — Storybook and the
|
||||
tests still answer through it.
|
||||
|
||||
## Endpoint catalogue
|
||||
|
||||
@@ -82,4 +88,4 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
|
||||
>
|
||||
> **Policies** targets the **real** backend base `/api/v1/policies` (Stirling's
|
||||
> `PolicyController`) rather than the mock `/v1/...` convention — its contract
|
||||
> mirrors the live policy engine, so MSW can be dropped with no code change.
|
||||
> mirrors the live policy engine.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { PortalProviders } from "@portal/PortalProviders";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
// Reset + typography, scoped to .portal-scope below.
|
||||
import "@portal/theme/base.css";
|
||||
|
||||
@@ -32,9 +33,13 @@ export function PortalApp() {
|
||||
<ThemedSuiProvider>
|
||||
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
|
||||
<div className="portal-scope">
|
||||
<PortalAuthBoundary>
|
||||
<PortalProviders />
|
||||
</PortalAuthBoundary>
|
||||
{/* Tool registry is read by portal views (e.g. the policy setup
|
||||
wizard); mount it above the per-flavor provider split. */}
|
||||
<ToolRegistryProvider>
|
||||
<PortalAuthBoundary>
|
||||
<PortalProviders />
|
||||
</PortalAuthBoundary>
|
||||
</ToolRegistryProvider>
|
||||
</div>
|
||||
</ThemedSuiProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -50,7 +50,7 @@ function LinkModalHost() {
|
||||
export function PortalProviders() {
|
||||
return (
|
||||
<LinkProvider initialState="unlinked">
|
||||
<TierProvider initialTier="pro">
|
||||
<TierProvider>
|
||||
<UIProvider>
|
||||
<AccountLinkProvider>
|
||||
<PortalChrome />
|
||||
|
||||
@@ -1,18 +1,123 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { AgentsResponse } from "@portal/mocks/agents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentVersion,
|
||||
AgentsResponse,
|
||||
AgentsSummary,
|
||||
EvalCase,
|
||||
Scenario,
|
||||
ToolMode,
|
||||
} from "@portal/mocks/agents";
|
||||
export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents";
|
||||
/*
|
||||
* An "agent" here is an AI agent that classifies, extracts from, and routes
|
||||
* documents. The builder is its lifecycle surface: scenarios (named test
|
||||
* cases), tool-access governance, an eval / golden set, and version history.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Domain types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AgentStatus = "draft" | "published";
|
||||
|
||||
/**
|
||||
* Tool-access posture. `broad` lets the agent call any tool it can reach;
|
||||
* `restricted` is allow-by-default minus an explicit deny list (the governance
|
||||
* mode enterprise tenants use to fence agents away from sensitive tools).
|
||||
*/
|
||||
export type ToolMode = "broad" | "restricted";
|
||||
|
||||
/** A named test case describing expected agent behaviour for a kind of input. */
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
/** What the agent is expected to do for this input. */
|
||||
expectation: string;
|
||||
/** Whether this scenario is currently exercised by the eval run. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** A single golden-set check with its last-run outcome. */
|
||||
export interface EvalCase {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Last observed pass/fail; null when the case has never been run. */
|
||||
passing: boolean | null;
|
||||
/** Mean latency of the last run in milliseconds. */
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface AgentVersion {
|
||||
/** Display label, e.g. "v3" or "v2-draft". */
|
||||
version: string;
|
||||
status: AgentStatus;
|
||||
/** ISO timestamp the version was created. */
|
||||
createdAt: string;
|
||||
author: string;
|
||||
/** One-line change summary. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
/** One-line role description shown under the name in the selector. */
|
||||
role: string;
|
||||
status: AgentStatus;
|
||||
/** Current working version, e.g. "v3" or "v2-draft". */
|
||||
version: string;
|
||||
model: string;
|
||||
scenarios: Scenario[];
|
||||
toolMode: ToolMode;
|
||||
/** Tools the agent may not call when `toolMode` is "restricted". */
|
||||
deniedTools: string[];
|
||||
/** Count of golden-set cases currently passing. */
|
||||
evalsPassing: number;
|
||||
/** Total golden-set cases. */
|
||||
evalsTotal: number;
|
||||
evalCases: EvalCase[];
|
||||
versions: AgentVersion[];
|
||||
}
|
||||
|
||||
export interface AgentsSummary {
|
||||
/** Agents in the "published" state. */
|
||||
activeAgents: number;
|
||||
/** Total agents regardless of status. */
|
||||
totalAgents: number;
|
||||
/** Mean eval pass-rate across all agents, 0..1. */
|
||||
avgPassRate: number;
|
||||
/** Total scenarios across all agents. */
|
||||
totalScenarios: number;
|
||||
/** Latest published version label across the fleet, e.g. "v3". */
|
||||
latestPublished: string;
|
||||
}
|
||||
|
||||
export interface AgentsResponse {
|
||||
summary: AgentsSummary;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (chip tone per status). Product copy, client-side. */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
|
||||
published: "success",
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
|
||||
* palette in restricted mode so the deny list is picked from a known set
|
||||
* rather than free-typed.
|
||||
*/
|
||||
export const TOOL_CATALOGUE = [
|
||||
"extract.fields",
|
||||
"classify.document",
|
||||
"route.pipeline",
|
||||
"lookup.crm",
|
||||
"send.email",
|
||||
"write.audit",
|
||||
"read.pii",
|
||||
"invoke.webhook",
|
||||
] as const;
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
|
||||
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disablePortalDemoData,
|
||||
enablePortalDemoData,
|
||||
isPortalDemoDataActive,
|
||||
resolveDemoResponse,
|
||||
} from "@portal/api/demoData";
|
||||
|
||||
describe("portal demo data seam", () => {
|
||||
afterEach(() => disablePortalDemoData());
|
||||
|
||||
it("is inert until enabled", async () => {
|
||||
expect(
|
||||
await resolveDemoResponse(
|
||||
new URL("/v1/agents?tier=pro", window.location.origin),
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(isPortalDemoDataActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("answers from the fixture handlers while enabled", async () => {
|
||||
await enablePortalDemoData();
|
||||
const res = await resolveDemoResponse(
|
||||
new URL("/v1/agents?tier=pro", window.location.origin),
|
||||
{},
|
||||
);
|
||||
expect(res?.status).toBe(200);
|
||||
const body = (await res?.json()) as { agents: unknown[] };
|
||||
expect(body.agents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("releases back to the network on disable", async () => {
|
||||
await enablePortalDemoData();
|
||||
disablePortalDemoData();
|
||||
expect(
|
||||
await resolveDemoResponse(
|
||||
new URL("/v1/agents?tier=pro", window.location.origin),
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for routes no handler matches", async () => {
|
||||
await enablePortalDemoData();
|
||||
expect(
|
||||
await resolveDemoResponse(
|
||||
new URL("/v1/nope", window.location.origin),
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Demo-data seam: while enabled, apiClient answers from the portal's MSW
|
||||
* fixture handlers instead of the network. There is no service worker and no
|
||||
* request interception — only fetches made through @portal/api/http see
|
||||
* fixture data, and only while the flag is on. msw and the handlers/fixtures
|
||||
* chunk are loaded on first enable, so ordinary sessions never pay for them.
|
||||
*
|
||||
* Built for the portal onboarding tour: enable on tour start so every view the
|
||||
* tour visits renders populated, disable on finish/skip — views refetch real
|
||||
* data when they next mount.
|
||||
*/
|
||||
import type { HttpRequestOptions } from "@portal/api/http";
|
||||
|
||||
type DemoResolver = (request: Request) => Promise<Response | undefined>;
|
||||
|
||||
let resolver: DemoResolver | null = null;
|
||||
let active = false;
|
||||
|
||||
/** Turn demo data on. Safe to call repeatedly; loads msw + fixtures once. */
|
||||
export async function enablePortalDemoData(): Promise<void> {
|
||||
if (!resolver) {
|
||||
const [{ getResponse }, { handlers }] = await Promise.all([
|
||||
import("msw"),
|
||||
import("@portal/mocks/handlers"),
|
||||
]);
|
||||
resolver = (request) => getResponse(handlers, request);
|
||||
}
|
||||
active = true;
|
||||
}
|
||||
|
||||
/** Turn demo data off. Views pick up real data on their next fetch. */
|
||||
export function disablePortalDemoData(): void {
|
||||
active = false;
|
||||
}
|
||||
|
||||
export function isPortalDemoDataActive(): boolean {
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture response for the request while demo data is on; undefined when demo
|
||||
* data is off or no handler matches (callers then hit the real network).
|
||||
*/
|
||||
export async function resolveDemoResponse(
|
||||
url: URL,
|
||||
options: HttpRequestOptions,
|
||||
): Promise<Response | undefined> {
|
||||
if (!active || !resolver) return undefined;
|
||||
const request = new Request(url, {
|
||||
method: options.method ?? "GET",
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
return resolver(request);
|
||||
}
|
||||
@@ -1,20 +1,116 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { CardAccent, CodeLang } from "@app/ui";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
|
||||
|
||||
export type {
|
||||
AgentSkill,
|
||||
ApiErrorRow,
|
||||
CodeSample,
|
||||
DocsContent,
|
||||
DocsNavItem,
|
||||
DocsNavSection,
|
||||
EmbedComponent,
|
||||
Playbook,
|
||||
RateLimit,
|
||||
Sdk,
|
||||
SdkStatus,
|
||||
} from "@portal/mocks/docs";
|
||||
/*
|
||||
* Developer Docs. Two payloads back the surface: the left-hand nav tree and
|
||||
* the data-driven reference content — code samples, SDK matrix, embeddable
|
||||
* components, playbooks, agent skills, the error table, and the tier-scaled
|
||||
* rate-limit grid.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Navigation */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A leaf entry in the docs nav — maps 1:1 to a content section. */
|
||||
export interface DocsNavItem {
|
||||
/** Stable id used as the in-page section anchor. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional badge shown to the right of the label (e.g. "New", "Beta"). */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/** A top-level grouping in the docs nav tree. */
|
||||
export interface DocsNavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Single-glyph icon shown beside the section header. */
|
||||
icon: string;
|
||||
items: DocsNavItem[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Reference content */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** One tab in a multi-language code snippet. */
|
||||
export interface CodeSample {
|
||||
/** Stable key used as the snippet tab id. */
|
||||
key: string;
|
||||
label: string;
|
||||
lang: CodeLang;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Per-tier request ceilings rendered by the rate-limits section. */
|
||||
export interface RateLimit {
|
||||
rpm: string;
|
||||
burst: string;
|
||||
concurrency: string;
|
||||
}
|
||||
|
||||
/** A single HTTP status row in the error table. */
|
||||
export interface ApiErrorRow {
|
||||
code: string;
|
||||
/** Severity colour — amber for recoverable, red for hard failures. */
|
||||
tone: "amber" | "red";
|
||||
meaning: string;
|
||||
}
|
||||
|
||||
export type SdkStatus = "ga" | "beta" | "deprecated";
|
||||
|
||||
/** An official client library in the SDK matrix. */
|
||||
export interface Sdk {
|
||||
name: string;
|
||||
/** Single-glyph icon shown beside the name. */
|
||||
icon: string;
|
||||
install: string;
|
||||
lang: CodeLang;
|
||||
status: SdkStatus;
|
||||
}
|
||||
|
||||
/** An embeddable UI component in the drop-in viewer library. */
|
||||
export interface EmbedComponent {
|
||||
name: string;
|
||||
blurb: string;
|
||||
/** Stack tag, e.g. "React" or "Web". */
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** A copy-paste, end-to-end pipeline recipe. */
|
||||
export interface Playbook {
|
||||
title: string;
|
||||
blurb: string;
|
||||
/** Ordered stages rendered as a chip flow. */
|
||||
steps: string[];
|
||||
accent: CardAccent;
|
||||
}
|
||||
|
||||
/** A bundled, named agent capability — a deterministic op chain. */
|
||||
export interface AgentSkill {
|
||||
name: string;
|
||||
blurb: string;
|
||||
/** Op chain shown as a mono string, e.g. "extract · validate". */
|
||||
ops: string;
|
||||
}
|
||||
|
||||
/** The complete data-driven docs payload for one tier. */
|
||||
export interface DocsContent {
|
||||
quickstartSamples: CodeSample[];
|
||||
quickstartResponse: string;
|
||||
rateLimit: RateLimit;
|
||||
errors: ApiErrorRow[];
|
||||
sdks: Sdk[];
|
||||
components: EmbedComponent[];
|
||||
playbooks: Playbook[];
|
||||
skills: AgentSkill[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** GET /v1/docs/nav — the docs nav tree. */
|
||||
export async function fetchDocsNav(): Promise<DocsNavSection[]> {
|
||||
|
||||
@@ -1,25 +1,132 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { DocumentsResponse } from "@portal/mocks/documents";
|
||||
import type { StatusTone, ChipAccent } from "@app/ui";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
DocAuditEvent,
|
||||
DocAuditKind,
|
||||
DocumentStatus,
|
||||
DocumentsResponse,
|
||||
DocumentsSummary,
|
||||
Extraction,
|
||||
ProductType,
|
||||
ReviewDocument,
|
||||
} from "@portal/mocks/documents";
|
||||
export {
|
||||
classificationTone,
|
||||
DOC_AUDIT_LABEL,
|
||||
DOC_AUDIT_TONE,
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
DOCUMENT_STATUS_TONE,
|
||||
PRODUCT_CHIP_TONE,
|
||||
} from "@portal/mocks/documents";
|
||||
export type DocumentStatus = "processed" | "flagged" | "in-review" | "error";
|
||||
|
||||
/** Which Stirling product ran the operation. */
|
||||
export type ProductType = "API" | "Editor";
|
||||
|
||||
/** A single field pulled out of the document by extraction. */
|
||||
export interface Extraction {
|
||||
field: string;
|
||||
value: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export type DocAuditKind =
|
||||
| "ingested"
|
||||
| "extracted"
|
||||
| "flagged"
|
||||
| "reviewed"
|
||||
| "approved"
|
||||
| "archived"
|
||||
| "elevation";
|
||||
|
||||
/** One event in a document's lifecycle, newest last. */
|
||||
export interface DocAuditEvent {
|
||||
id: string;
|
||||
kind: DocAuditKind;
|
||||
time: string;
|
||||
actor: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ReviewDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
/** File-type label, e.g. "PDF". */
|
||||
type: string;
|
||||
/** Auto-classification label (e.g. "Contract"), or null when not classified. */
|
||||
classification: string | null;
|
||||
/** True when the classification was assigned automatically. */
|
||||
auto: boolean;
|
||||
/** Short descriptive sub-line (editor action or flag reason), or null. */
|
||||
note: string | null;
|
||||
/** Where it was processed. */
|
||||
product: ProductType;
|
||||
/** Pipeline/action, e.g. "contract". Null (or Editor product) renders "Editor". */
|
||||
action: string | null;
|
||||
/** The user who ran it. */
|
||||
user: string;
|
||||
status: DocumentStatus;
|
||||
/** Reviewer name for in-review docs, e.g. "Sarah K.". */
|
||||
reviewer: string | null;
|
||||
/** Originating source name. */
|
||||
source: string;
|
||||
/** Overall confidence 0..1, or null (unsupported - never shown in the table). */
|
||||
confidence: number | null;
|
||||
fieldsExtracted: number;
|
||||
/** Relative-time string, e.g. "2 min ago". */
|
||||
time: string;
|
||||
sensitive: boolean;
|
||||
extractions: Extraction[];
|
||||
audit: DocAuditEvent[];
|
||||
}
|
||||
|
||||
export interface DocumentsSummary {
|
||||
totalInQueue: number;
|
||||
processed: number;
|
||||
errors: number;
|
||||
processedToday: number;
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
summary: DocumentsSummary;
|
||||
documents: ReviewDocument[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (label + chip tone) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const DOCUMENT_STATUS_LABEL: Record<DocumentStatus, string> = {
|
||||
processed: "portal.documents.status.processed",
|
||||
flagged: "portal.documents.status.flagged",
|
||||
"in-review": "portal.documents.status.inReview",
|
||||
error: "portal.documents.status.error",
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_TONE: Record<DocumentStatus, StatusTone> = {
|
||||
processed: "success",
|
||||
flagged: "warning",
|
||||
"in-review": "purple",
|
||||
error: "danger",
|
||||
};
|
||||
|
||||
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
|
||||
API: "brand",
|
||||
Editor: "success",
|
||||
};
|
||||
|
||||
/** Classification chip accent: danger when unclassified, warning when it needs a look. */
|
||||
export function classificationTone(doc: ReviewDocument): ChipAccent {
|
||||
if (doc.classification === "Unclassified") return "danger";
|
||||
if (doc.status === "processed") return "success";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const DOC_AUDIT_LABEL: Record<DocAuditKind, string> = {
|
||||
ingested: "portal.documents.audit.ingested",
|
||||
extracted: "portal.documents.audit.extracted",
|
||||
flagged: "portal.documents.audit.flagged",
|
||||
reviewed: "portal.documents.audit.reviewed",
|
||||
approved: "portal.documents.audit.approved",
|
||||
archived: "portal.documents.audit.archived",
|
||||
elevation: "portal.documents.audit.elevation",
|
||||
};
|
||||
|
||||
export const DOC_AUDIT_TONE: Record<DocAuditKind, StatusTone> = {
|
||||
ingested: "info",
|
||||
extracted: "success",
|
||||
flagged: "warning",
|
||||
reviewed: "purple",
|
||||
approved: "success",
|
||||
archived: "neutral",
|
||||
elevation: "purple",
|
||||
};
|
||||
|
||||
/** GET the audit-derived Documents feed; SaaS or local, scoped server-side. `tier` ignored. */
|
||||
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
|
||||
|
||||
@@ -1,25 +1,155 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
DeploymentTarget,
|
||||
DeploymentSummary,
|
||||
DeploymentSummaryMetric,
|
||||
EditorDeploymentResponse,
|
||||
EditorInstance,
|
||||
/*
|
||||
* This surface manages the org's deployment of the Stirling PDF *Editor*
|
||||
* product from the portal — where it runs (Managed Cloud / Docker /
|
||||
* Kubernetes), how self-hosted instances pair back to the org, the health of
|
||||
* each running instance, and the service credential / offline-activation
|
||||
* lifecycle.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deployment targets */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Where an Editor deployment can run. */
|
||||
export type TargetKind = "cloud" | "docker" | "kubernetes";
|
||||
|
||||
/**
|
||||
* Whether a target is usable on the current tier and, if so, whether the org
|
||||
* has actually stood it up. `locked` targets render an upgrade nudge instead of
|
||||
* a runnable snippet.
|
||||
*/
|
||||
export type TargetState = "running" | "available" | "locked";
|
||||
|
||||
export interface DeploymentTarget {
|
||||
kind: TargetKind;
|
||||
label: string;
|
||||
/** One-line positioning shown under the title. */
|
||||
tagline: string;
|
||||
state: TargetState;
|
||||
/** Minimum tier that unlocks this target — drives the upgrade nudge copy. */
|
||||
requiresTier: Tier;
|
||||
/** Install / run snippet for the target's CodeBlock. */
|
||||
snippet: string;
|
||||
/** Language hint for the CodeBlock chrome. */
|
||||
snippetLang: "bash" | "plain";
|
||||
/** Populated only when `state === "running"`. */
|
||||
runningVersion?: string;
|
||||
/** Count of instances currently reporting in for this target. */
|
||||
instanceCount?: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Pairing */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** How a self-hosted editor connects itself to the org. */
|
||||
export type PairingMethod = "token" | "shortcode" | "iac";
|
||||
|
||||
export interface PairingOption {
|
||||
method: PairingMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Minimum tier that unlocks this method. */
|
||||
requiresTier: Tier;
|
||||
/**
|
||||
* The current secret/handle to display. A long-lived pairing token, a
|
||||
* TV-style short code, or an IaC reference (e.g. a Terraform module input).
|
||||
* Pre-masked for token display — never carries the real secret.
|
||||
*/
|
||||
value: string;
|
||||
/** Short codes expire fast; tokens rotate on demand. Relative-time string. */
|
||||
expires?: string;
|
||||
/** Whether this option is currently usable on the active tier. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Running instances (deployment health) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type InstanceStatus = "healthy" | "degraded" | "offline" | "pairing";
|
||||
|
||||
export interface EditorInstance {
|
||||
id: string;
|
||||
/** Human host label, e.g. "edge-fra-01" or "Managed Cloud (us-east-1)". */
|
||||
host: string;
|
||||
target: TargetKind;
|
||||
version: string;
|
||||
region: string;
|
||||
status: InstanceStatus;
|
||||
/** Relative-time string, e.g. "12s ago". */
|
||||
lastSeen: string;
|
||||
activeUsers: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Summary metric strip */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DeploymentSummaryMetric {
|
||||
label: string;
|
||||
value: string | number;
|
||||
delta?: number;
|
||||
deltaDirection?: "up" | "down" | "flat";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DeploymentSummary {
|
||||
metrics: DeploymentSummaryMetric[];
|
||||
/** Masked service token + its rotation age, shown by the rotation card. */
|
||||
serviceToken: { masked: string; lastRotated: string };
|
||||
/** Air-gapped activation is enterprise-only; gate the card on this flag. */
|
||||
offlineActivationAvailable: boolean;
|
||||
/** Where users launch the Editor — the org workspace URL (Open in browser). */
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
export interface EditorDeploymentResponse {
|
||||
summary: DeploymentSummary;
|
||||
targets: DeploymentTarget[];
|
||||
pairings: PairingOption[];
|
||||
instances: EditorInstance[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (lives client-side — product copy, not data) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TargetMeta {
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple";
|
||||
}
|
||||
|
||||
export const TARGET_META: Record<TargetKind, TargetMeta> = {
|
||||
cloud: { icon: "☁", tone: "blue" },
|
||||
docker: { icon: "▣", tone: "neutral" },
|
||||
kubernetes: { icon: "⎈", tone: "purple" },
|
||||
};
|
||||
|
||||
export const INSTANCE_STATUS_TONE: Record<
|
||||
InstanceStatus,
|
||||
PairingMethod,
|
||||
PairingOption,
|
||||
TargetKind,
|
||||
TargetMeta,
|
||||
TargetState,
|
||||
} from "@portal/mocks/editorDeploy";
|
||||
export {
|
||||
INSTANCE_STATUS_LABEL,
|
||||
INSTANCE_STATUS_TONE,
|
||||
TARGET_META,
|
||||
} from "@portal/mocks/editorDeploy";
|
||||
"success" | "warning" | "danger" | "info" | "neutral"
|
||||
> = {
|
||||
healthy: "success",
|
||||
degraded: "warning",
|
||||
offline: "danger",
|
||||
pairing: "info",
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const INSTANCE_STATUS_LABEL: Record<InstanceStatus, string> = {
|
||||
healthy: "portal.editorAdmin.status.healthy",
|
||||
degraded: "portal.editorAdmin.status.degraded",
|
||||
offline: "portal.editorAdmin.status.offline",
|
||||
pairing: "portal.editorAdmin.status.pairing",
|
||||
};
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* GET /v1/editor/deployment?tier=… — the org's Editor deployment: summary
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
||||
*/
|
||||
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
|
||||
import { resolveDemoResponse } from "@portal/api/demoData";
|
||||
import { saasApiBase } from "@portal/api/saasApiBase";
|
||||
import {
|
||||
localAuthHeader,
|
||||
@@ -148,6 +149,11 @@ async function localJson<T>(
|
||||
path: string,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const demo = await resolveDemoResponse(
|
||||
new URL(`${localBaseUrl()}${path}`, window.location.origin),
|
||||
options,
|
||||
);
|
||||
if (demo) return unwrap<T>(demo);
|
||||
const res = await fetch(`${localBaseUrl()}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
@@ -214,6 +220,14 @@ async function saasJson<T>(
|
||||
path: string,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
// Resolved before the config/session gates so demo data works on an
|
||||
// unlinked or unconfigured org. http://saas.mock is the origin the SaaS
|
||||
// handlers are written against (same one Storybook injects).
|
||||
const demo = await resolveDemoResponse(
|
||||
new URL(path, "http://saas.mock"),
|
||||
options,
|
||||
);
|
||||
if (demo) return unwrap<T>(demo);
|
||||
const base = saasBaseUrl();
|
||||
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
|
||||
if (base === null) throw new SaasUnconfiguredError();
|
||||
|
||||
@@ -1,50 +1,264 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
AuditLogResponse,
|
||||
DeploymentRegion,
|
||||
ModelsResponse,
|
||||
RecentDeployment,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
} from "@portal/mocks/infrastructure";
|
||||
|
||||
export type {
|
||||
AccessPolicy,
|
||||
ApiKey,
|
||||
ApiKeyPermission,
|
||||
ApiKeyStatus,
|
||||
AttestationStatus,
|
||||
AuditCategory,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditStatus,
|
||||
AuditSummary,
|
||||
CertStatus,
|
||||
ComplianceAttestation,
|
||||
ComplianceCert,
|
||||
DataResidency,
|
||||
DeploymentRegion,
|
||||
DeploymentStatus,
|
||||
IpAllowEntry,
|
||||
KeyManagement,
|
||||
KeyMode,
|
||||
ModelCostUnit,
|
||||
ModelEntry,
|
||||
ModelProvider,
|
||||
ModelsResponse,
|
||||
ModelsSummary,
|
||||
ModelStatus,
|
||||
ModelType,
|
||||
RecentDeployment,
|
||||
RegionStatus,
|
||||
RetentionWindow,
|
||||
RoutingRule,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
StorageProvider,
|
||||
} from "@portal/mocks/infrastructure";
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deployments */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RegionStatus = "healthy" | "degraded" | "down";
|
||||
|
||||
export interface DeploymentRegion {
|
||||
name: string;
|
||||
code: string;
|
||||
/** Median request latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Current load as a fraction of provisioned capacity (0–1). */
|
||||
load: number;
|
||||
status: RegionStatus;
|
||||
/** Deployed Stirling engine version. */
|
||||
version: string;
|
||||
/** 30-day uptime as a fraction (0–1). */
|
||||
uptime: number;
|
||||
/** Running instance count. */
|
||||
instances: number;
|
||||
/** Sustained throughput, docs/min. */
|
||||
throughput: number;
|
||||
/** P99 latency, ms. */
|
||||
p99Ms: number;
|
||||
}
|
||||
|
||||
export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued";
|
||||
|
||||
export interface RecentDeployment {
|
||||
id: string;
|
||||
version: string;
|
||||
environment: "production" | "staging" | "canary";
|
||||
product: string;
|
||||
status: DeploymentStatus;
|
||||
deployedBy: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
usageToday: number;
|
||||
usageMonth: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Security */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AccessPolicy = "stirling" | "byok" | "hyok";
|
||||
export type DataResidency = "us" | "eu" | "apac";
|
||||
export type CertStatus = "certified" | "in-progress" | "not-started";
|
||||
|
||||
export interface ComplianceCert {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CertStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface IpAllowEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
cidr: string;
|
||||
addedBy: string;
|
||||
added: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is
|
||||
* surfaced separately because the key *custody model* (who can decrypt) is the
|
||||
* detail security teams scrutinise:
|
||||
* - `managed` — Stirling-owned KMS keys; zero key ops on the customer side.
|
||||
* - `byok` — customer key, but Stirling can use it to decrypt while processing.
|
||||
* - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext.
|
||||
*/
|
||||
export type KeyMode = "managed" | "byok" | "hyok";
|
||||
|
||||
export interface KeyManagement {
|
||||
mode: KeyMode;
|
||||
/** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */
|
||||
provider: string;
|
||||
/** ARN-style identifier for the active key. */
|
||||
keyId: string;
|
||||
/** Encryption algorithm in force. */
|
||||
algorithm: string;
|
||||
/** Relative last-rotation time, e.g. "32 days ago". */
|
||||
lastRotated: string;
|
||||
/** Rotation cadence summary, e.g. "Automatic · every 90 days". */
|
||||
rotationPolicy: string;
|
||||
/**
|
||||
* Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed
|
||||
* tiers see the posture but cannot change provider — only enterprise can.
|
||||
*/
|
||||
customerManaged: boolean;
|
||||
}
|
||||
|
||||
export type AttestationStatus = "attested" | "in-scope" | "not-applicable";
|
||||
|
||||
export interface ComplianceAttestation {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Framework family / short descriptor shown under the name. */
|
||||
framework: string;
|
||||
status: AttestationStatus;
|
||||
/** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */
|
||||
detail: string;
|
||||
/** Stub link to the downloadable report; null when none is available. */
|
||||
reportUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
accessPolicy: AccessPolicy;
|
||||
dataResidency: DataResidency;
|
||||
certs: ComplianceCert[];
|
||||
ipAllowlist: IpAllowEntry[];
|
||||
keyManagement: KeyManagement;
|
||||
attestations: ComplianceAttestation[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Storage */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RetentionWindow = "30" | "60" | "90" | "180" | "never";
|
||||
|
||||
export interface StorageProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "stirling" | "s3" | "azure";
|
||||
connected: boolean;
|
||||
detail: string;
|
||||
usedGb: number;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
/** Total used storage, GB. */
|
||||
usedGb: number;
|
||||
/** Quota ceiling, GB. */
|
||||
quotaGb: number;
|
||||
retention: RetentionWindow;
|
||||
providers: StorageProvider[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Audit Logs */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AuditCategory =
|
||||
| "auth"
|
||||
| "config"
|
||||
| "elevation"
|
||||
| "processing"
|
||||
| "security";
|
||||
|
||||
export type AuditStatus = "success" | "warning" | "danger" | "info";
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
category: AuditCategory;
|
||||
action: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
status: AuditStatus;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
totalEvents: number;
|
||||
processing: number;
|
||||
elevation: number;
|
||||
config: number;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
summary: AuditSummary;
|
||||
events: AuditEvent[];
|
||||
/** True for the whole-server (admin) view; gates the admin-only CSV export. */
|
||||
fullServer: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Models */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem";
|
||||
export type ModelType = "extraction" | "classification" | "ocr" | "llm";
|
||||
export type ModelStatus = "active" | "degraded" | "disabled";
|
||||
|
||||
/** Whether a model's cost is billed per 1k documents or per individual call. */
|
||||
export type ModelCostUnit = "per-1k-docs" | "per-call";
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
type: ModelType;
|
||||
status: ModelStatus;
|
||||
/** Median inference latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Cost in USD for the model's billing unit (see {@link costUnit}). */
|
||||
cost: number;
|
||||
costUnit: ModelCostUnit;
|
||||
version: string;
|
||||
/** Share of capacity this model is currently absorbing (0–1). */
|
||||
load: number;
|
||||
/** True for customer-registered bring-your-own / on-prem models. */
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
/** A binding from a processing operation (optionally a doc-type) to a model. */
|
||||
export interface RoutingRule {
|
||||
id: string;
|
||||
/** The operation or pipeline stage this rule governs. */
|
||||
operation: string;
|
||||
/** Doc-type scope, or "All document types" for a catch-all. */
|
||||
docType: string;
|
||||
/** id of the {@link ModelEntry} this operation routes to. */
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
/** Marks the fallback rule applied when no narrower rule matches. */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsSummary {
|
||||
activeModels: number;
|
||||
/** Capacity-weighted average latency across active models, ms. */
|
||||
avgLatencyMs: number;
|
||||
/** Projected monthly model spend, USD. */
|
||||
monthlySpend: number;
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
summary: ModelsSummary;
|
||||
models: ModelEntry[];
|
||||
routing: RoutingRule[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DeploymentsResponse {
|
||||
regions: DeploymentRegion[];
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type {
|
||||
LinkInstanceRequest,
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
LocalUsage,
|
||||
} from "@portal/mocks/link";
|
||||
|
||||
export type {
|
||||
LinkInstanceRequest,
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
LocalUsage,
|
||||
} from "@portal/mocks/link";
|
||||
/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */
|
||||
export interface LinkInstanceRequest {
|
||||
/** Admin's SaaS session JWT, obtained via the hosted-login popup. */
|
||||
supabaseJwt: string;
|
||||
/** Optional label for this instance. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Link status for this instance (GET /api/v1/account-link/status). */
|
||||
export interface LinkStatus {
|
||||
linked: boolean;
|
||||
/** Display name the local backend stored at link time; null when unset. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage).
|
||||
* The portal adds this on top of the SaaS-synced spend so "current usage"
|
||||
* includes work done since the last daily sync. Per-category unsynced units for
|
||||
* the current period; all zero when metering is off or nothing is pending.
|
||||
*/
|
||||
export interface LocalUsage {
|
||||
/** ISO timestamp of the current period start; null when unknown (not yet synced). */
|
||||
periodStart: string | null;
|
||||
apiUnsyncedUnits: number;
|
||||
aiUnsyncedUnits: number;
|
||||
automationUnsyncedUnits: number;
|
||||
totalUnsyncedUnits: number;
|
||||
}
|
||||
|
||||
/** A linked instance row (GET /api/v1/account-link/instances). */
|
||||
export interface LinkedInstanceRow {
|
||||
instanceId: number;
|
||||
deviceId: string;
|
||||
name: string | null;
|
||||
/** ISO timestamp the instance was registered. */
|
||||
createdAt: string | null;
|
||||
/** ISO timestamp the instance last presented its credential; null if never. */
|
||||
lastSeenAt: string | null;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-link client (combined-billing "Mode A"). Two distinct surfaces:
|
||||
@@ -33,9 +62,9 @@ export type {
|
||||
* - POST /api/v1/account-link/instances/{id}/revoke
|
||||
*
|
||||
* The team-wide endpoints are served by the hosted SaaS Java backend (the
|
||||
* local backend has no such routes), so they go through apiClient.saas. They're
|
||||
* MSW-intercepted in dev/Storybook via wildcard handlers that match both the
|
||||
* local and absolute SaaS URLs.
|
||||
* local backend has no such routes), so they go through apiClient.saas. In
|
||||
* Storybook/tests, wildcard MSW handlers match both the local and absolute
|
||||
* SaaS URLs.
|
||||
*/
|
||||
|
||||
const BASE = "/api/v1/account-link";
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationCategory,
|
||||
} from "@portal/mocks/notifications";
|
||||
|
||||
export type { Notification, NotificationCategory };
|
||||
export type NotificationCategory =
|
||||
| "pipeline"
|
||||
| "deploy"
|
||||
| "billing"
|
||||
| "audit"
|
||||
| "agent"
|
||||
| "doc";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
category: NotificationCategory;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Relative-time string. */
|
||||
time: string;
|
||||
}
|
||||
|
||||
/** GET /v1/notifications */
|
||||
export async function fetchNotifications(): Promise<Notification[]> {
|
||||
|
||||
@@ -6,9 +6,8 @@ import { apiClient } from "@portal/api/http";
|
||||
* A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java):
|
||||
* an ordered chain of tool steps with input sources, a trigger, and an output
|
||||
* destination. This surface lists EVERY backend policy (the user-facing Policies
|
||||
* page builds only a friendly subset of the same records). Like Sources, it calls
|
||||
* the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact
|
||||
* calls at the live backend.
|
||||
* page builds only a friendly subset of the same records). Like Sources, it
|
||||
* calls the real Stirling API base `/api/v1/policies`.
|
||||
*/
|
||||
|
||||
/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Policies service layer.
|
||||
*
|
||||
* The portal calls the real Stirling policy API (`/api/v1/policies`). MSW
|
||||
* intercepts these calls in dev/Storybook; dropping MSW is enough to hit the
|
||||
* live backend — no call-site changes needed.
|
||||
* The portal calls the real Stirling policy API (`/api/v1/policies`);
|
||||
* Storybook and tests intercept the same calls with MSW handlers.
|
||||
*
|
||||
* `fetchPolicies()` assembles the decorated catalogue client-side from the
|
||||
* backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same
|
||||
@@ -13,53 +12,404 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
import type { PolicyDecodedState, WirePolicy } from "@app/policies/types";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PoliciesResponse,
|
||||
type PoliciesSummary,
|
||||
type PolicySetupResult,
|
||||
type PolicyState,
|
||||
type PolicyStatus,
|
||||
} from "@portal/mocks/policies";
|
||||
import type { PolicyRunView } from "@app/policies/types";
|
||||
import type {
|
||||
PolicyDecodedState,
|
||||
PolicyRunView,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
|
||||
export type {
|
||||
CatalogueEntry,
|
||||
DecoratedPolicy,
|
||||
PoliciesResponse,
|
||||
PoliciesSummary,
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyDecodedState,
|
||||
PolicyField,
|
||||
PolicyFieldType,
|
||||
PolicyRowStatus,
|
||||
PolicyRunView,
|
||||
PolicySetupResult,
|
||||
PolicyState,
|
||||
PolicyStats,
|
||||
PolicyActivityItem,
|
||||
PolicyStatus,
|
||||
WirePolicy,
|
||||
PolicyDecodedState,
|
||||
PolicyRunView,
|
||||
PolicyStats,
|
||||
WireOutputOptions,
|
||||
WireOutputSpec,
|
||||
} from "@portal/mocks/policies";
|
||||
export {
|
||||
ENDPOINT_LABELS,
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
POLICY_DOC_TYPES,
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
} from "@portal/mocks/policies";
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
|
||||
// Re-export the wire step type under the legacy name components depend on.
|
||||
export type { WirePipelineStep as PipelineStep } from "@app/policies/types";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Catalogue model — portal-specific */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type PolicyStatus = "active" | "paused";
|
||||
|
||||
export type PolicyRowStatus = "active" | "paused" | "setup";
|
||||
|
||||
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
|
||||
|
||||
export interface PolicyField {
|
||||
label: string;
|
||||
key: string;
|
||||
type: PolicyFieldType;
|
||||
value: boolean | string | string[];
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
||||
desc: string;
|
||||
providesClassification?: boolean;
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyConfigDef {
|
||||
summary: string;
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
configured: boolean;
|
||||
status: PolicyStatus;
|
||||
sources: string[];
|
||||
scopeTypes: string[];
|
||||
reviewerEmail: string;
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
outputMode?: "new_file" | "new_version";
|
||||
outputName?: string;
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||
runOn?: "upload" | "export";
|
||||
maxRetries?: number;
|
||||
retryDelayMinutes?: number;
|
||||
backendId?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicySetupResult {
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
sources: string[];
|
||||
scopeTypes: string[];
|
||||
reviewerEmail: string;
|
||||
outputMode: "new_file" | "new_version";
|
||||
outputName: string;
|
||||
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
||||
runOn: "upload" | "export";
|
||||
maxRetries: number;
|
||||
retryDelayMinutes: number;
|
||||
steps: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface DecoratedPolicy {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
state: PolicyState;
|
||||
steps: WirePipelineStep[];
|
||||
stats: import("@app/policies/types").PolicyStats;
|
||||
activity: import("@app/policies/types").PolicyActivityItem[];
|
||||
}
|
||||
|
||||
export interface PoliciesSummary {
|
||||
active: number;
|
||||
paused: number;
|
||||
categories: number;
|
||||
docsEnforced: number;
|
||||
}
|
||||
|
||||
export interface PoliciesResponse {
|
||||
summary: PoliciesSummary;
|
||||
catalogue: CatalogueEntry[];
|
||||
}
|
||||
|
||||
export interface CatalogueEntry {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
policy: DecoratedPolicy | null;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tool → endpoint registry */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
redact: "/api/v1/security/auto-redact",
|
||||
sanitize: "/api/v1/security/sanitize-pdf",
|
||||
watermark: "/api/v1/security/add-watermark",
|
||||
ocr: "/api/v1/misc/ocr-pdf",
|
||||
flatten: "/api/v1/misc/flatten",
|
||||
compress: "/api/v1/misc/compress-pdf",
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
|
||||
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
|
||||
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
|
||||
"/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf",
|
||||
"/api/v1/misc/flatten": "portal.policies.endpoints.flatten",
|
||||
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
|
||||
};
|
||||
|
||||
export function humanizeEndpoint(
|
||||
path: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Catalogue definitions */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const DEFAULT_PII_PATTERNS: string[] = [
|
||||
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
|
||||
"\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b",
|
||||
];
|
||||
|
||||
/** `label`/`desc` values are i18n keys — render with t(). */
|
||||
export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "portal.policies.categories.ingestion.label",
|
||||
icon: "layers",
|
||||
tone: "blue",
|
||||
desc: "portal.policies.categories.ingestion.desc",
|
||||
providesClassification: true,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
label: "portal.policies.categories.security.label",
|
||||
icon: "shield",
|
||||
tone: "purple",
|
||||
desc: "portal.policies.categories.security.desc",
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
label: "portal.policies.categories.compliance.label",
|
||||
icon: "check",
|
||||
tone: "amber",
|
||||
desc: "portal.policies.categories.compliance.desc",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "routing",
|
||||
label: "portal.policies.categories.routing.label",
|
||||
icon: "route",
|
||||
tone: "green",
|
||||
desc: "portal.policies.categories.routing.desc",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "retention",
|
||||
label: "portal.policies.categories.retention.label",
|
||||
icon: "clock",
|
||||
tone: "neutral",
|
||||
desc: "portal.policies.categories.retention.desc",
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* `summary`/`rules`/`scopeLabel`/field `label` values are i18n keys — render
|
||||
* with t(). Field `value`/`options` strings are persisted policy state and
|
||||
* stay as stable values (translating them would corrupt saved configs).
|
||||
*/
|
||||
export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
ingestion: {
|
||||
summary: "portal.policies.config.ingestion.summary",
|
||||
rules: [
|
||||
"portal.policies.config.ingestion.rules.0",
|
||||
"portal.policies.config.ingestion.rules.1",
|
||||
"portal.policies.config.ingestion.rules.2",
|
||||
"portal.policies.config.ingestion.rules.3",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.minConfidence",
|
||||
key: "minConfidence",
|
||||
type: "select",
|
||||
value: "80%",
|
||||
options: ["60%", "70%", "80%", "90%", "95%"],
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.belowThreshold",
|
||||
key: "belowThreshold",
|
||||
type: "select",
|
||||
value: "Flag for review",
|
||||
options: ["Flag for review", "Route to bucket", "Hold"],
|
||||
},
|
||||
],
|
||||
},
|
||||
security: {
|
||||
summary: "portal.policies.config.security.summary",
|
||||
rules: [
|
||||
"portal.policies.config.security.rules.0",
|
||||
"portal.policies.config.security.rules.1",
|
||||
"portal.policies.config.security.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
parameters: {
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.sanitize,
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: false,
|
||||
removeMetadata: false,
|
||||
removeLinks: false,
|
||||
removeFonts: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.watermark,
|
||||
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||
parameters: {
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
compliance: {
|
||||
summary: "portal.policies.config.compliance.summary",
|
||||
rules: [
|
||||
"portal.policies.config.compliance.rules.0",
|
||||
"portal.policies.config.compliance.rules.1",
|
||||
"portal.policies.config.compliance.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.frameworks",
|
||||
key: "frameworks",
|
||||
type: "chips",
|
||||
value: ["HIPAA"],
|
||||
options: ["HIPAA", "GDPR", "SOC 2", "FedRAMP", "PCI DSS", "ISO 27001"],
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.onViolation",
|
||||
key: "onViolation",
|
||||
type: "select",
|
||||
value: "Flag for review",
|
||||
options: [
|
||||
"Flag for review",
|
||||
"Block export",
|
||||
"Auto-redact PHI",
|
||||
"Quarantine document",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.auditTrail",
|
||||
key: "auditTrail",
|
||||
type: "toggle",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.accessLog",
|
||||
key: "accessLog",
|
||||
type: "toggle",
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
routing: {
|
||||
summary: "portal.policies.config.routing.summary",
|
||||
rules: [
|
||||
"portal.policies.config.routing.rules.0",
|
||||
"portal.policies.config.routing.rules.1",
|
||||
"portal.policies.config.routing.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.destination",
|
||||
key: "destination",
|
||||
type: "select",
|
||||
value: "Documents",
|
||||
options: ["Documents", "S3 bucket", "SharePoint", "Webhook"],
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.webhookUrl",
|
||||
key: "webhookUrl",
|
||||
type: "text",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.notify",
|
||||
key: "notify",
|
||||
type: "toggle",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
retention: {
|
||||
summary: "portal.policies.config.retention.summary",
|
||||
rules: [
|
||||
"portal.policies.config.retention.rules.0",
|
||||
"portal.policies.config.retention.rules.1",
|
||||
"portal.policies.config.retention.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.keepFor",
|
||||
key: "keepFor",
|
||||
type: "select",
|
||||
value: "7 years",
|
||||
options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"],
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.archiveAfter",
|
||||
key: "archiveAfter",
|
||||
type: "select",
|
||||
value: "Never",
|
||||
options: ["30 days", "90 days", "1 year", "Never"],
|
||||
},
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.immutableHold",
|
||||
key: "immutableHold",
|
||||
type: "toggle",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const POLICY_DOC_TYPES: string[] = [
|
||||
"Contracts",
|
||||
"Invoices",
|
||||
"Tax documents",
|
||||
"HR records",
|
||||
"Insurance",
|
||||
"Medical / PHI",
|
||||
"Legal filings",
|
||||
"Financial reports",
|
||||
];
|
||||
|
||||
// ── Client-side catalogue assembly ───────────────────────────────────────────
|
||||
|
||||
function decoratePolicy(
|
||||
|
||||
@@ -1,28 +1,189 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
DealStage,
|
||||
DocAction,
|
||||
ProcurementResponse,
|
||||
} from "@portal/mocks/procurement";
|
||||
|
||||
export type {
|
||||
Deal,
|
||||
DealStage,
|
||||
DocAction,
|
||||
DocStatus,
|
||||
JourneyStep,
|
||||
LedgerDoc,
|
||||
LedgerGroup,
|
||||
ProcurementResponse,
|
||||
QuoteInfo,
|
||||
SolutionsEngineer,
|
||||
SupportingCategory,
|
||||
SupportingGroup,
|
||||
TrialInfo,
|
||||
} from "@portal/mocks/procurement";
|
||||
export { JOURNEY } from "@portal/mocks/procurement";
|
||||
/*
|
||||
* Procurement models the enterprise commercial journey, trial → quote →
|
||||
* agreement → payment → implementation, plus the paperwork ledger that rides
|
||||
* alongside it. The journey is enterprise-only; free/pro tiers receive a
|
||||
* minimal locked payload the view renders as an upgrade prompt.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Journey stages */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The five-stage enterprise journey. The id is the contract value the backend
|
||||
* advances; the labels below are the buyer-facing stage names (Agreement and
|
||||
* Payment read more plainly than the internal `security` / `procurement`).
|
||||
*/
|
||||
export type DealStage =
|
||||
| "trial"
|
||||
| "quote"
|
||||
| "security"
|
||||
| "procurement"
|
||||
| "active";
|
||||
|
||||
export interface JourneyStep {
|
||||
stage: DealStage;
|
||||
/** Buyer-facing stage name. */
|
||||
label: string;
|
||||
/** One-line description of what happens at this stage. */
|
||||
blurb: string;
|
||||
/**
|
||||
* Label for the single action that advances this stage. The current stage
|
||||
* surfaces its gating action; `active` is terminal (provisioning).
|
||||
*/
|
||||
gatingAction: string;
|
||||
}
|
||||
|
||||
/** Ordered journey definition, the stepper renders this verbatim. */
|
||||
/** `label`/`blurb`/`gatingAction` values are i18n keys — render with t(). */
|
||||
export const JOURNEY: JourneyStep[] = [
|
||||
{
|
||||
stage: "trial",
|
||||
label: "portal.procurement.journeySteps.trial.label",
|
||||
blurb: "portal.procurement.journeySteps.trial.blurb",
|
||||
gatingAction: "portal.procurement.journeySteps.trial.gatingAction",
|
||||
},
|
||||
{
|
||||
stage: "quote",
|
||||
label: "portal.procurement.journeySteps.quote.label",
|
||||
blurb: "portal.procurement.journeySteps.quote.blurb",
|
||||
gatingAction: "portal.procurement.journeySteps.quote.gatingAction",
|
||||
},
|
||||
{
|
||||
stage: "security",
|
||||
label: "portal.procurement.journeySteps.agreement.label",
|
||||
blurb: "portal.procurement.journeySteps.agreement.blurb",
|
||||
gatingAction: "portal.procurement.journeySteps.agreement.gatingAction",
|
||||
},
|
||||
{
|
||||
stage: "procurement",
|
||||
label: "portal.procurement.journeySteps.payment.label",
|
||||
blurb: "portal.procurement.journeySteps.payment.blurb",
|
||||
gatingAction: "portal.procurement.journeySteps.payment.gatingAction",
|
||||
},
|
||||
{
|
||||
stage: "active",
|
||||
label: "portal.procurement.journeySteps.implementation.label",
|
||||
blurb: "portal.procurement.journeySteps.implementation.blurb",
|
||||
gatingAction: "portal.procurement.journeySteps.implementation.gatingAction",
|
||||
},
|
||||
];
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deal header */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SolutionsEngineer {
|
||||
name: string;
|
||||
title: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface TrialInfo {
|
||||
/** License key seeded for the evaluation. */
|
||||
key: string;
|
||||
/** ISO date the trial began. */
|
||||
startedOn: string;
|
||||
/** ISO date the trial expires. */
|
||||
endsOn: string;
|
||||
/** Whole days remaining (derived in the fixture for a stable demo number). */
|
||||
daysLeft: number;
|
||||
extensionsUsed: number;
|
||||
maxExtensions: number;
|
||||
}
|
||||
|
||||
export interface QuoteInfo {
|
||||
number: string;
|
||||
/** Annual contract value, in USD. */
|
||||
amount: number;
|
||||
/** Contract term, e.g. "12 months". */
|
||||
term: string;
|
||||
/** ISO date the quote expires. */
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
export interface Deal {
|
||||
company: string;
|
||||
currentStage: DealStage;
|
||||
engineer: SolutionsEngineer;
|
||||
trial: TrialInfo;
|
||||
quote: QuoteInfo;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Document ledger + supporting pool */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Lifecycle of a single document.
|
||||
* available: ready to grab now (download/sign/pay/upload as the action says)
|
||||
* action: waiting on the buyer to act (the gating paperwork of a stage)
|
||||
* pending: issued, awaiting the other side / a system step
|
||||
* request: not generated yet; the buyer asks for it (some carry a fee)
|
||||
* complete: done, kept for the record
|
||||
*/
|
||||
export type DocStatus =
|
||||
| "available"
|
||||
| "action"
|
||||
| "pending"
|
||||
| "request"
|
||||
| "complete";
|
||||
|
||||
/** What pressing the document's button does. */
|
||||
export type DocAction = "download" | "sign" | "pay" | "upload" | "request";
|
||||
|
||||
export interface LedgerDoc {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Sub-line describing what the document is / what it covers. */
|
||||
sub: string;
|
||||
status: DocStatus;
|
||||
action: DocAction;
|
||||
/** Buyer-skippable paperwork (e.g. paid onboarding). */
|
||||
optional?: boolean;
|
||||
/** One-off fee in USD when the document/service is a paid add-on. */
|
||||
fee?: number;
|
||||
}
|
||||
|
||||
/** Document ledger grouped by the journey stage the paperwork belongs to. */
|
||||
export interface LedgerGroup {
|
||||
stage: DealStage;
|
||||
/** Buyer-facing stage name (matches JourneyStep.label). */
|
||||
label: string;
|
||||
docs: LedgerDoc[];
|
||||
}
|
||||
|
||||
/** Categories the stage-agnostic supporting pool is grouped under. */
|
||||
export type SupportingCategory =
|
||||
| "security"
|
||||
| "legal"
|
||||
| "corporate"
|
||||
| "procurement";
|
||||
|
||||
export interface SupportingGroup {
|
||||
category: SupportingCategory;
|
||||
label: string;
|
||||
docs: LedgerDoc[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Full procurement payload */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface ProcurementResponse {
|
||||
tier: Tier;
|
||||
/** True only for enterprise, gates the whole journey + ledger. */
|
||||
unlocked: boolean;
|
||||
/** Present only when unlocked. */
|
||||
deal: Deal | null;
|
||||
journey: JourneyStep[];
|
||||
ledger: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
}
|
||||
|
||||
/** GET /v1/procurement?tier=…, the deal, journey, ledger and supporting pool. */
|
||||
export async function fetchProcurement(
|
||||
|
||||
@@ -1,24 +1,134 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { ComponentsResponse } from "@portal/mocks/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
BillingUnit,
|
||||
ComponentMaturity,
|
||||
ComponentPricing,
|
||||
ComponentProp,
|
||||
ComponentsResponse,
|
||||
ComponentsSummary,
|
||||
Framework,
|
||||
MaturityMeta,
|
||||
SdkComponent,
|
||||
} from "@portal/mocks/sdkComponents";
|
||||
export {
|
||||
BILLING_UNIT_LABEL,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
isUnlocked,
|
||||
} from "@portal/mocks/sdkComponents";
|
||||
/*
|
||||
* "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops
|
||||
* into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel —
|
||||
* each metered per action (per render, per review, per signature). Every
|
||||
* component carries its npm package, maturity, supported frameworks, per-action
|
||||
* price, an install/usage snippet, and its key props.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ComponentMaturity = "ga" | "beta";
|
||||
|
||||
export type Framework = "React" | "Vue" | "Vanilla";
|
||||
|
||||
/** The action a component bills against — surfaces in the price unit label. */
|
||||
export type BillingUnit =
|
||||
| "render"
|
||||
| "review"
|
||||
| "approval"
|
||||
| "signature"
|
||||
| "check"
|
||||
| "event"
|
||||
| "session";
|
||||
|
||||
export interface ComponentProp {
|
||||
name: string;
|
||||
/** TypeScript-ish type expression, shown verbatim in the API table. */
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ComponentPricing {
|
||||
/** Price per billed action in USD. */
|
||||
pricePerAction: number;
|
||||
unit: BillingUnit;
|
||||
/** Free-tier monthly allowance before metering kicks in; 0 = none. */
|
||||
freeQuota: number;
|
||||
}
|
||||
|
||||
export interface SdkComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Package suffix — full name is `@stirling/<package>`. */
|
||||
package: string;
|
||||
description: string;
|
||||
maturity: ComponentMaturity;
|
||||
frameworks: Framework[];
|
||||
pricing: ComponentPricing;
|
||||
/** Install command (npm). */
|
||||
install: string;
|
||||
/** Minimal usage snippet shown under the Code tab. */
|
||||
usage: string;
|
||||
props: ComponentProp[];
|
||||
/**
|
||||
* Embeds attributed to this component over the trailing 30 days — drives the
|
||||
* per-card usage line. Zero for never-embedded components.
|
||||
*/
|
||||
embeds30d: number;
|
||||
/**
|
||||
* Tier at which the component becomes available. Components above the active
|
||||
* tier render locked with an upgrade nudge. `pro` is the default floor.
|
||||
*/
|
||||
minTier: Tier;
|
||||
}
|
||||
|
||||
export interface ComponentsSummary {
|
||||
/** Count of GA (production-ready) components available to the tier. */
|
||||
gaCount: number;
|
||||
/** Count of Beta components available to the tier. */
|
||||
betaCount: number;
|
||||
/** Total embeds across all components this month. */
|
||||
embedsThisMonth: number;
|
||||
/** Month-to-date spend attributed to component actions, in USD. */
|
||||
spendThisMonth: number;
|
||||
}
|
||||
|
||||
export interface ComponentsResponse {
|
||||
summary: ComponentsSummary;
|
||||
components: SdkComponent[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (client-side product copy, not data) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface MaturityMeta {
|
||||
label: string;
|
||||
tone: "success" | "info";
|
||||
}
|
||||
|
||||
/** `label` values are i18n keys — render with t(). */
|
||||
export const MATURITY_META: Record<ComponentMaturity, MaturityMeta> = {
|
||||
ga: { label: "portal.components.maturity.ga", tone: "success" },
|
||||
beta: { label: "portal.components.maturity.beta", tone: "info" },
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const BILLING_UNIT_LABEL: Record<BillingUnit, string> = {
|
||||
render: "portal.components.billingUnit.render",
|
||||
review: "portal.components.billingUnit.review",
|
||||
approval: "portal.components.billingUnit.approval",
|
||||
signature: "portal.components.billingUnit.signature",
|
||||
check: "portal.components.billingUnit.check",
|
||||
event: "portal.components.billingUnit.event",
|
||||
session: "portal.components.billingUnit.session",
|
||||
};
|
||||
|
||||
/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */
|
||||
export function formatPrice(
|
||||
pricing: ComponentPricing,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return `$${pricing.pricePerAction.toFixed(2)} / ${t(BILLING_UNIT_LABEL[pricing.unit])}`;
|
||||
}
|
||||
|
||||
const TIER_RANK: Record<Tier, number> = { free: 0, pro: 1, enterprise: 2 };
|
||||
|
||||
/** Whether a component is usable at the given tier (vs locked/upgrade). */
|
||||
export function isUnlocked(component: SdkComponent, tier: Tier): boolean {
|
||||
return TIER_RANK[tier] >= TIER_RANK[component.minTier];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
|
||||
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { QuickAction } from "@portal/mocks/search";
|
||||
|
||||
export type { QuickAction };
|
||||
export interface QuickAction {
|
||||
group: "Jump to" | "Create" | "Theme";
|
||||
label: string;
|
||||
/** Keyboard hint shown to the right. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** GET /v1/search/quick-actions */
|
||||
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { SettingsSnapshot } from "@portal/mocks/settings";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
ActiveSession,
|
||||
BetaFeature,
|
||||
NotificationDefault,
|
||||
RegionOption,
|
||||
SecuritySettings,
|
||||
SettingsSnapshot,
|
||||
} from "@portal/mocks/settings";
|
||||
|
||||
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
|
||||
export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> {
|
||||
return apiClient.local.json<SettingsSnapshot>(
|
||||
`/v1/settings?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,215 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { ROLES } from "@portal/mocks/users";
|
||||
import type { Member, RoleId, UsersResponse } from "@portal/mocks/users";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
AccessControls,
|
||||
Member,
|
||||
MemberStatus,
|
||||
/** The four org roles, most → least privileged, mapped onto the backend's
|
||||
* authorities + team leadership. Order drives the role select and grid. */
|
||||
export type RoleId = "admin" | "team_owner" | "member" | "guest";
|
||||
|
||||
export type MemberStatus = "active" | "invited" | "suspended";
|
||||
|
||||
/**
|
||||
* Effective portal (processor) access for a member:
|
||||
* admin — implicit, admins always have it
|
||||
* role — implicit via team-owner leadership (default policy)
|
||||
* team — inherited from a PORTAL grant on the member's whole team
|
||||
* granted — explicit per-user PORTAL grant
|
||||
* none — no access
|
||||
*/
|
||||
export type PortalAccessState = "admin" | "role" | "team" | "granted" | "none";
|
||||
|
||||
export const PORTAL_ACCESS_TONE: Record<
|
||||
PortalAccessState,
|
||||
Role,
|
||||
RoleId,
|
||||
UsersResponse,
|
||||
UsersSummary,
|
||||
} from "@portal/mocks/users";
|
||||
export {
|
||||
MEMBER_STATUS_TONE,
|
||||
PORTAL_ACCESS_TONE,
|
||||
ROLES,
|
||||
ROLE_LABEL,
|
||||
ROLE_TONE,
|
||||
} from "@portal/mocks/users";
|
||||
"success" | "info" | "neutral" | "warning"
|
||||
> = {
|
||||
admin: "info",
|
||||
role: "info",
|
||||
team: "info",
|
||||
granted: "success",
|
||||
none: "neutral",
|
||||
};
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: RoleId;
|
||||
status: MemberStatus;
|
||||
/** Effective portal access; set by the view from the grant list. */
|
||||
portalAccess?: PortalAccessState;
|
||||
/** Authoritative server-side portal access (roster DTO); drives whether a chip shows at all. */
|
||||
canAccessPortal?: boolean;
|
||||
/** The explicit PORTAL grant's id, for revoke (present when access = granted). */
|
||||
portalGrantId?: number;
|
||||
/** Relative-time string, e.g. "4m ago". Invited members read "—". */
|
||||
lastActive: string;
|
||||
/** Optional avatar image; falls back to initials when absent. */
|
||||
avatarUrl?: string;
|
||||
/** Backend linkage for row actions (absent on pure fixtures). */
|
||||
username?: string;
|
||||
teamId?: number;
|
||||
teamName?: string;
|
||||
/** Holds a LEADER membership on their team (independent of displayed role). */
|
||||
teamLead?: boolean;
|
||||
/** The signed-in admin's own row; self-directed actions are disabled. */
|
||||
isSelf?: boolean;
|
||||
/** Account locked after failed logins (admin can unlock). */
|
||||
locked?: boolean;
|
||||
/** MFA enrolled (admin can reset it). */
|
||||
mfaEnabled?: boolean;
|
||||
/** Auth provider: "web" (password), "oauth2", "saml2", etc. */
|
||||
authType?: string;
|
||||
/** Raw stored authority (e.g. ROLE_USER, ROLE_WEB_ONLY_USER); preserved on team moves. */
|
||||
authority?: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: RoleId;
|
||||
label: string;
|
||||
/** One-line summary of what the role can do. */
|
||||
summary: string;
|
||||
/** Concrete permission bullets shown in the reference grid. */
|
||||
permissions: string[];
|
||||
tone: "purple" | "blue" | "green" | "amber" | "neutral";
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Access controls (tier-scoped) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Access posture for the org, shaped by tier. Free exposes only the seat limit
|
||||
* and an upgrade nudge; pro adds session/MFA self-service; enterprise adds
|
||||
* SSO/SAML, SCIM provisioning, enforced MFA and a session policy. Fields are
|
||||
* optional so the panel renders whatever the tier returns.
|
||||
*/
|
||||
export interface AccessControls {
|
||||
tier: Tier;
|
||||
/** Seats consumed by active + invited members. */
|
||||
seatsUsed: number;
|
||||
/** Total seats on the plan; null = unlimited (enterprise). */
|
||||
seatLimit: number | null;
|
||||
/** Free only: copy for the upgrade nudge. */
|
||||
upgradeHint?: string;
|
||||
/** Pro+: end-user MFA available (self-service, not enforced). */
|
||||
mfaAvailable?: boolean;
|
||||
/** Enterprise: MFA enforced org-wide. */
|
||||
mfaEnforced?: boolean;
|
||||
/** Pro+: idle session timeout, e.g. "30 days" / "12 hours". */
|
||||
sessionTimeout?: string;
|
||||
/** Enterprise: SSO connection summary. */
|
||||
sso?: {
|
||||
provider: string;
|
||||
status: "connected" | "not_configured";
|
||||
/** Email domains that auto-route to SSO. */
|
||||
domains: string[];
|
||||
};
|
||||
/** Enterprise: SCIM directory provisioning. */
|
||||
scim?: {
|
||||
enabled: boolean;
|
||||
/** Where the directory syncs from, e.g. "Okta". */
|
||||
directory: string;
|
||||
lastSync: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsersSummary {
|
||||
totalMembers: number;
|
||||
pendingInvites: number;
|
||||
seatsUsed: number;
|
||||
/** null = unlimited. */
|
||||
seatLimit: number | null;
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
summary: UsersSummary;
|
||||
members: Member[];
|
||||
roles: Role[];
|
||||
access: AccessControls;
|
||||
/** Whether SMTP is configured (gates emailing passwords/invites). */
|
||||
mailEnabled: boolean;
|
||||
/** Whether email invites will work: SMTP on AND mail.enableInvites=true. Gates the
|
||||
* "Invite by email" option on self-hosted. */
|
||||
emailInvitesEnabled: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata — product copy, lives client-side */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const MEMBER_STATUS_TONE: Record<
|
||||
MemberStatus,
|
||||
"success" | "warning" | "danger" | "neutral" | "info"
|
||||
> = {
|
||||
active: "success",
|
||||
invited: "info",
|
||||
suspended: "danger",
|
||||
};
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Role catalogue */
|
||||
/* The same five roles exist on every tier — what varies is who can fill */
|
||||
/* them and how access is enforced, not the role definitions themselves. */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** `label`/`summary`/`permissions` values are i18n keys — render with t(). */
|
||||
export const ROLES: Role[] = [
|
||||
{
|
||||
id: "admin",
|
||||
label: "portal.users.roles.admin.label",
|
||||
summary: "portal.users.roles.admin.summary",
|
||||
permissions: [
|
||||
"portal.users.roles.admin.permissions.0",
|
||||
"portal.users.roles.admin.permissions.1",
|
||||
"portal.users.roles.admin.permissions.2",
|
||||
"portal.users.roles.admin.permissions.3",
|
||||
],
|
||||
tone: "purple",
|
||||
},
|
||||
{
|
||||
id: "team_owner",
|
||||
label: "portal.users.roles.team_owner.label",
|
||||
summary: "portal.users.roles.team_owner.summary",
|
||||
permissions: [
|
||||
"portal.users.roles.team_owner.permissions.0",
|
||||
"portal.users.roles.team_owner.permissions.1",
|
||||
"portal.users.roles.team_owner.permissions.2",
|
||||
"portal.users.roles.team_owner.permissions.3",
|
||||
],
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
id: "member",
|
||||
label: "portal.users.roles.member.label",
|
||||
summary: "portal.users.roles.member.summary",
|
||||
permissions: [
|
||||
"portal.users.roles.member.permissions.0",
|
||||
"portal.users.roles.member.permissions.1",
|
||||
"portal.users.roles.member.permissions.2",
|
||||
"portal.users.roles.member.permissions.3",
|
||||
],
|
||||
tone: "green",
|
||||
},
|
||||
{
|
||||
id: "guest",
|
||||
label: "portal.users.roles.guest.label",
|
||||
summary: "portal.users.roles.guest.summary",
|
||||
permissions: [
|
||||
"portal.users.roles.guest.permissions.0",
|
||||
"portal.users.roles.guest.permissions.1",
|
||||
"portal.users.roles.guest.permissions.2",
|
||||
"portal.users.roles.guest.permissions.3",
|
||||
],
|
||||
tone: "neutral",
|
||||
},
|
||||
];
|
||||
|
||||
export const ROLE_LABEL: Record<RoleId, string> = Object.fromEntries(
|
||||
ROLES.map((r) => [r.id, r.label]),
|
||||
) as Record<RoleId, string>;
|
||||
|
||||
export const ROLE_TONE: Record<RoleId, Role["tone"]> = Object.fromEntries(
|
||||
ROLES.map((r) => [r.id, r.tone]),
|
||||
) as Record<RoleId, Role["tone"]>;
|
||||
|
||||
/** Roles an admin can assign from the portal; guest is derived, not assigned. */
|
||||
export const ASSIGNABLE_ROLES: RoleId[] = ["admin", "team_owner", "member"];
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ChevronDownIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
|
||||
import { MocksToggle } from "@portal/components/MocksToggle";
|
||||
import "@portal/components/Header.css";
|
||||
|
||||
function ThemeToggle() {
|
||||
@@ -43,9 +42,9 @@ function ThemeToggle() {
|
||||
function TierSwitcher() {
|
||||
const { tier, setTier, isDerived } = useTier();
|
||||
const info = TIER_INFO[tier];
|
||||
// When mocks are off, the tier is derived from the real link/wallet state —
|
||||
// pair the dropdown with the mocks toggle (hidden in prod) so testing real
|
||||
// billing flows can't be perturbed by accidentally flipping the mock tier.
|
||||
// In the app the tier is derived from the real link/wallet state and can't
|
||||
// be switched by hand; the dropdown only renders where the tier is pinned
|
||||
// (Storybook / demo surfaces — see TierProvider's initialTier).
|
||||
if (isDerived) return null;
|
||||
return (
|
||||
<Dropdown.Root align="end">
|
||||
@@ -143,7 +142,6 @@ export function Header() {
|
||||
</Button>
|
||||
|
||||
<div className="portal-header__right">
|
||||
<MocksToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationsDropdown />
|
||||
<TierSwitcher />
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
.portal-mocks-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed transparent;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-on {
|
||||
color: var(--color-amber-dark);
|
||||
background: var(--color-amber-light);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-off {
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-bg-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle:hover {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-pending {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.portal-mocks-toggle__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-off .portal-mocks-toggle__dot {
|
||||
background: var(--color-text-5);
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
readMocksPreference,
|
||||
writeMocksPreference,
|
||||
} from "@portal/mocks/preference";
|
||||
import "@portal/components/MocksToggle.css";
|
||||
import { Button } from "@app/ui/Button";
|
||||
|
||||
/**
|
||||
* Dev-only header chip that flips MSW interception on and off. Persists the
|
||||
* preference to localStorage so it survives reloads. Hidden entirely in
|
||||
* production builds — there's no MSW worker to toggle there.
|
||||
*
|
||||
* Toggling reloads the page. Without a reload, components that already
|
||||
* fetched data via useAsync keep showing the cached result, which makes the
|
||||
* toggle feel like it does nothing. A reload gives a clean view of what the
|
||||
* app looks like with/without mocks.
|
||||
*/
|
||||
export function MocksToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled] = useState(() => readMocksPreference());
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
function toggle() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
writeMocksPreference(!enabled);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={
|
||||
"portal-mocks-toggle" +
|
||||
(enabled ? " is-on" : " is-off") +
|
||||
(pending ? " is-pending" : "")
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-pressed={enabled}
|
||||
title={
|
||||
enabled ? t("portal.mocks.tooltip.on") : t("portal.mocks.tooltip.off")
|
||||
}
|
||||
>
|
||||
<span className="portal-mocks-toggle__dot" aria-hidden />
|
||||
<span className="portal-mocks-toggle__label">
|
||||
{enabled ? t("portal.mocks.label.on") : t("portal.mocks.label.off")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useUI } from "@portal/contexts/UIContext";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { AssistantMount } from "@portal/components/AssistantMount";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { SettingsModal } from "@portal/components/SettingsModal";
|
||||
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
|
||||
/**
|
||||
@@ -35,18 +35,6 @@ function GlobalShortcuts() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bridges the Settings modal's open/close props to UIContext state. */
|
||||
function SettingsHost() {
|
||||
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||
return (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed view, wrapped in an error boundary so a single view crashing can't
|
||||
* white-screen the portal (the shell + nav stay alive). Keyed by route so
|
||||
@@ -80,7 +68,7 @@ export function PortalChrome() {
|
||||
</ToolRegistryProvider>
|
||||
<AssistantMount />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
<PortalSettingsHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AppConfigModalLazy from "@app/components/shared/AppConfigModalLazy";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import {
|
||||
VALID_NAV_KEYS,
|
||||
type ConfigNavSection,
|
||||
type NavKey,
|
||||
} from "@app/components/shared/config/types";
|
||||
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
/**
|
||||
* Mounts the editor's settings modal (the app-wide settings surface) inside the
|
||||
* portal. The portal deliberately lives outside the editor's AppProviders, so
|
||||
* this host supplies the contexts the settings tree needs: app config, user
|
||||
* preferences, the session provider the account sections read (flavor-resolved:
|
||||
* Spring on self-hosted, Supabase on SaaS — same underlying session the portal
|
||||
* is already signed in with), and the editor ThemeProvider (which also carries
|
||||
* the Mantine theme + toasts the sections expect). URL sync is off — the portal
|
||||
* owns its own route subtree, so the modal keeps its section purely in state.
|
||||
*
|
||||
* Everything (providers included) mounts on first open and stays mounted, so
|
||||
* the editor theme wiring never runs for portal sessions that never open
|
||||
* settings.
|
||||
*/
|
||||
export function PortalSettingsHost() {
|
||||
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||
const { t } = useTranslation();
|
||||
const [everOpened, setEverOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsOpen) setEverOpened(true);
|
||||
}, [settingsOpen]);
|
||||
|
||||
// Portal-only sections, appended after the build's registry sections. The
|
||||
// account-link seam is self-hosted-only (the saas overlay shadows it to null).
|
||||
const extraSections = useMemo<ConfigNavSection[]>(() => {
|
||||
if (!accountLinkSettings) return [];
|
||||
const { navKey, labelKey, icon, Body } = accountLinkSettings;
|
||||
return [
|
||||
{
|
||||
title: t("portal.settings.groups.admin", "Admin"),
|
||||
items: [
|
||||
{
|
||||
key: navKey,
|
||||
label: t(labelKey, "Account link"),
|
||||
icon,
|
||||
component: <Body />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const initialSection: NavKey | null =
|
||||
settingsInitialSection &&
|
||||
(VALID_NAV_KEYS as readonly string[]).includes(settingsInitialSection)
|
||||
? (settingsInitialSection as NavKey)
|
||||
: null;
|
||||
|
||||
if (!everOpened) return null;
|
||||
|
||||
return (
|
||||
<AppConfigProvider bootstrapMode="non-blocking">
|
||||
<AuthProvider>
|
||||
<PreferencesProvider>
|
||||
<ThemeProvider>
|
||||
<AppConfigModalLazy
|
||||
opened={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
urlSync={false}
|
||||
initialSection={initialSection}
|
||||
extraSections={extraSections}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</PreferencesProvider>
|
||||
</AuthProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/* The settings overlay hosts a full-bleed two-pane SettingsShell, so the
|
||||
modal frame contributes no padding of its own and lets the shell scroll. */
|
||||
.portal-settings .sui-modal__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-settings__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Footer note pushes the action buttons to the right. */
|
||||
.portal-settings__footer-note {
|
||||
margin-right: auto;
|
||||
align-self: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Profile identity row ─────────────────────────────────────────────── */
|
||||
.portal-settings__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.portal-settings__identity-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.portal-settings__identity-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-settings__identity-email {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Preference groups ────────────────────────────────────────────────── */
|
||||
.portal-settings__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-settings__group + .portal-settings__group {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-settings__group-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-settings__group-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-settings__group-sub {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Theme picker ─────────────────────────────────────────────────────── */
|
||||
.portal-settings__theme {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-settings__theme-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
box-shadow var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-settings__theme-card:hover {
|
||||
border-color: var(--color-border-strong, var(--color-border));
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-settings__theme-card.is-active {
|
||||
border-color: var(--color-blue);
|
||||
box-shadow: 0 0 0 1px var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-settings__theme-swatch {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.portal-settings__theme-swatch span {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.portal-settings__theme-swatch span:first-child {
|
||||
flex: 0 0 35%;
|
||||
}
|
||||
|
||||
.portal-settings__theme-swatch span:last-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.portal-settings__theme-swatch--light {
|
||||
background: #ffffff;
|
||||
}
|
||||
.portal-settings__theme-swatch--light span:first-child {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
.portal-settings__theme-swatch--light span:last-child {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.portal-settings__theme-swatch--dark {
|
||||
background: #0f172a;
|
||||
}
|
||||
.portal-settings__theme-swatch--dark span:first-child {
|
||||
background: #475569;
|
||||
}
|
||||
.portal-settings__theme-swatch--dark span:last-child {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.portal-settings__theme-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-settings__theme-text strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-settings__theme-text span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Notification rows ────────────────────────────────────────────────── */
|
||||
.portal-settings__notifs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-settings__notif-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.portal-settings__notif-row + .portal-settings__notif-row {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-settings__notif-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-settings__notif-text strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-settings__notif-text span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Label paired with a gating badge (e.g. "SCIM provisioning" + Enterprise). */
|
||||
.portal-settings__row-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Workspace plan card ──────────────────────────────────────────────── */
|
||||
.portal-settings__plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.portal-settings__plan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-settings__plan-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-settings__plan-value {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.portal-settings__theme {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,902 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
SettingsShell,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
type SelectOption,
|
||||
type SettingsNavSection,
|
||||
} from "@app/ui";
|
||||
import { useTier, type Tier } from "@portal/contexts/TierContext";
|
||||
import { useTheme, type Theme } from "@portal/contexts/ThemeContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchSettings,
|
||||
type ActiveSession,
|
||||
type BetaFeature,
|
||||
type SettingsSnapshot,
|
||||
} from "@portal/api/settings";
|
||||
import {
|
||||
UsersIcon,
|
||||
SunIcon,
|
||||
BellIcon,
|
||||
SettingsIcon,
|
||||
PoliciesIcon,
|
||||
InfrastructureIcon,
|
||||
SparklesIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
|
||||
import "@portal/components/SettingsModal.css";
|
||||
|
||||
type SettingsSection =
|
||||
| "profile"
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "general"
|
||||
| "authentication"
|
||||
| "sessions"
|
||||
| "early-access"
|
||||
| "account-link";
|
||||
|
||||
function isSettingsSection(value: string | null): value is SettingsSection {
|
||||
return (
|
||||
value === "profile" ||
|
||||
value === "appearance" ||
|
||||
value === "notifications" ||
|
||||
value === "general" ||
|
||||
value === "authentication" ||
|
||||
value === "sessions" ||
|
||||
value === "early-access" ||
|
||||
value === "account-link"
|
||||
);
|
||||
}
|
||||
|
||||
/** Org-wide auth posture the Admin sections edit, mirrored into local state. */
|
||||
interface SecurityForm {
|
||||
mfaEnforced: boolean;
|
||||
ssoEnabled: boolean;
|
||||
scimEnabled: boolean;
|
||||
sessionTimeoutMins: number;
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Optional section to land on when opening. When `null`/unsupported the modal
|
||||
* picks the default ("profile"). Set by callers like the sidebar's "Link
|
||||
* account" affordance → "account-link".
|
||||
*/
|
||||
initialSection?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification categories with known display copy, in the order the snapshot
|
||||
* exposes them. Labels and descriptions are resolved via i18n at render time,
|
||||
* keyed by id; ids absent from this list are skipped.
|
||||
*/
|
||||
const NOTIFICATION_IDS = [
|
||||
"pipeline-failures",
|
||||
"pipeline-success",
|
||||
"usage-alerts",
|
||||
"weekly-digest",
|
||||
"security-alerts",
|
||||
"product-updates",
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS: { value: Theme }[] = [
|
||||
{ value: "light" },
|
||||
{ value: "dark" },
|
||||
];
|
||||
|
||||
const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const;
|
||||
|
||||
/**
|
||||
* Account settings as a portal-wide overlay. A grouped left-nav (Account /
|
||||
* Workspace / Admin) over a tier-aware snapshot that seeds editable local form
|
||||
* state. Save is a no-op for the demo — it closes — but the theme control
|
||||
* writes straight through to ThemeProvider so the change is real and visible.
|
||||
*/
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
initialSection,
|
||||
}: SettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [section, setSection] = useState<SettingsSection>("profile");
|
||||
|
||||
const navSections = useMemo<SettingsNavSection[]>(
|
||||
() => [
|
||||
{
|
||||
title: t("portal.settings.groups.account"),
|
||||
items: [
|
||||
{
|
||||
key: "profile",
|
||||
label: t("portal.settings.sections.profile"),
|
||||
icon: <UsersIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "appearance",
|
||||
label: t("portal.settings.sections.appearance"),
|
||||
icon: <SunIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: t("portal.settings.sections.notifications"),
|
||||
icon: <BellIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("portal.settings.groups.workspace"),
|
||||
items: [
|
||||
{
|
||||
key: "general",
|
||||
label: t("portal.settings.sections.general"),
|
||||
icon: <SettingsIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("portal.settings.groups.admin"),
|
||||
items: [
|
||||
// Account-link is a self-hosted-only section; the SaaS build shadows
|
||||
// the seam to null, dropping the item entirely.
|
||||
...(accountLinkSettings
|
||||
? [
|
||||
{
|
||||
key: accountLinkSettings.navKey,
|
||||
label: t(accountLinkSettings.labelKey),
|
||||
icon: accountLinkSettings.icon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "authentication",
|
||||
label: t("portal.settings.sections.authentication"),
|
||||
icon: <PoliciesIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
label: t("portal.settings.sections.sessions"),
|
||||
icon: <InfrastructureIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "early-access",
|
||||
label: t("portal.settings.sections.early-access"),
|
||||
icon: <SparklesIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const { data: snapshot, loading } = useAsync<SettingsSnapshot>(
|
||||
() => fetchSettings(tier),
|
||||
[tier],
|
||||
);
|
||||
|
||||
// Editable copies seeded from the snapshot. Re-seed whenever a fresh snapshot
|
||||
// arrives (tier switch) or the modal is re-opened, so edits never leak across
|
||||
// sessions or stack on stale values.
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [workspaceName, setWorkspaceName] = useState("");
|
||||
const [region, setRegion] = useState("");
|
||||
const [notifications, setNotifications] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [security, setSecurity] = useState<SecurityForm>({
|
||||
mfaEnforced: false,
|
||||
ssoEnabled: false,
|
||||
scimEnabled: false,
|
||||
sessionTimeoutMins: 480,
|
||||
});
|
||||
const [betaToggles, setBetaToggles] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
setName(snapshot.profile.name);
|
||||
setEmail(snapshot.profile.email);
|
||||
setWorkspaceName(snapshot.workspace.name);
|
||||
setRegion(snapshot.workspace.region);
|
||||
setNotifications(
|
||||
Object.fromEntries(snapshot.notifications.map((n) => [n.id, n.enabled])),
|
||||
);
|
||||
setSecurity({
|
||||
mfaEnforced: snapshot.security.mfaEnforced,
|
||||
ssoEnabled: snapshot.security.ssoEnabled,
|
||||
scimEnabled: snapshot.security.scimEnabled,
|
||||
sessionTimeoutMins: snapshot.security.sessionTimeoutMins,
|
||||
});
|
||||
setBetaToggles(
|
||||
Object.fromEntries(snapshot.betaFeatures.map((f) => [f.id, f.enabled])),
|
||||
);
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const requested = initialSection ?? null;
|
||||
setSection(isSettingsSection(requested) ? requested : "profile");
|
||||
}, [open, initialSection]);
|
||||
|
||||
const regionOptions = useMemo<SelectOption[]>(() => {
|
||||
if (!snapshot) return [];
|
||||
return snapshot.regions.map((r) => ({
|
||||
value: r.value,
|
||||
label:
|
||||
r.enterpriseOnly && tier !== "enterprise"
|
||||
? t("portal.settings.workspace.regionEnterpriseSuffix", {
|
||||
region: r.label,
|
||||
})
|
||||
: r.label,
|
||||
disabled: r.enterpriseOnly && tier !== "enterprise",
|
||||
}));
|
||||
}, [snapshot, tier, t]);
|
||||
|
||||
const isLoading = loading && !snapshot;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
ariaLabel={t("portal.settings.ariaLabel")}
|
||||
className="portal-settings"
|
||||
>
|
||||
<SettingsShell
|
||||
sections={navSections}
|
||||
activeKey={section}
|
||||
onSelect={(k) => setSection(k as SettingsSection)}
|
||||
title={t(`portal.settings.sections.${section}`)}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<span className="portal-settings__footer-note">
|
||||
{t("portal.settings.footerNote")}
|
||||
</span>
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{t("portal.settings.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" accent="premium" onClick={onClose}>
|
||||
{t("portal.settings.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{section === "profile" && (
|
||||
<ProfilePanel
|
||||
loading={isLoading}
|
||||
name={name}
|
||||
email={email}
|
||||
role={snapshot?.profile.role}
|
||||
avatarUrl={snapshot?.profile.avatarUrl ?? undefined}
|
||||
onName={setName}
|
||||
onEmail={setEmail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "appearance" && (
|
||||
<AppearancePanel theme={theme} onTheme={setTheme} />
|
||||
)}
|
||||
|
||||
{section === "notifications" && (
|
||||
<NotificationsPanel
|
||||
loading={isLoading}
|
||||
notifications={notifications}
|
||||
order={snapshot?.notifications.map((n) => n.id) ?? []}
|
||||
onToggle={(id, value) =>
|
||||
setNotifications((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "general" && (
|
||||
<WorkspacePanel
|
||||
loading={isLoading}
|
||||
workspaceName={workspaceName}
|
||||
onWorkspaceName={setWorkspaceName}
|
||||
region={region}
|
||||
onRegion={setRegion}
|
||||
regionOptions={regionOptions}
|
||||
planLabel={snapshot?.workspace.planLabel}
|
||||
seats={snapshot?.workspace.seats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "authentication" && (
|
||||
<AuthenticationPanel
|
||||
loading={isLoading}
|
||||
tier={tier}
|
||||
security={security}
|
||||
onSecurity={(patch) => setSecurity((s) => ({ ...s, ...patch }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "sessions" && (
|
||||
<SessionsPanel
|
||||
loading={isLoading}
|
||||
sessions={snapshot?.security.activeSessions ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "early-access" && (
|
||||
<EarlyAccessPanel
|
||||
loading={isLoading}
|
||||
tier={tier}
|
||||
betaFeatures={snapshot?.betaFeatures ?? []}
|
||||
betaToggles={betaToggles}
|
||||
onBeta={(id, value) =>
|
||||
setBetaToggles((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "account-link" && accountLinkSettings && (
|
||||
<accountLinkSettings.Body />
|
||||
)}
|
||||
</SettingsShell>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Profile */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function ProfilePanel({
|
||||
loading,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
avatarUrl,
|
||||
onName,
|
||||
onEmail,
|
||||
}: {
|
||||
loading: boolean;
|
||||
name: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
avatarUrl?: string;
|
||||
onName: (v: string) => void;
|
||||
onEmail: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__identity">
|
||||
<Skeleton width="3.5rem" height="3.5rem" shape="circle" />
|
||||
<div className="portal-settings__identity-meta">
|
||||
<Skeleton width="9rem" />
|
||||
<Skeleton width="12rem" height="0.625rem" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__identity">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={name || t("portal.settings.profile.accountFallback")}
|
||||
size="lg"
|
||||
tone="blue"
|
||||
/>
|
||||
<div className="portal-settings__identity-meta">
|
||||
<div className="portal-settings__identity-name">
|
||||
{name || t("portal.settings.profile.accountFallback")}
|
||||
{role && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{role}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<span className="portal-settings__identity-email">{email}</span>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
{t("portal.settings.profile.changePhoto")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormField label={t("portal.settings.profile.fullName")}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onName(e.target.value)}
|
||||
placeholder={t("portal.settings.profile.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.settings.profile.email")}
|
||||
helperText={t("portal.settings.profile.emailHelper")}
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => onEmail(e.target.value)}
|
||||
placeholder={t("portal.settings.profile.emailPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Appearance */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function AppearancePanel({
|
||||
theme,
|
||||
onTheme,
|
||||
}: {
|
||||
theme: Theme;
|
||||
onTheme: (theme: Theme) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("portal.settings.appearance.themeTitle")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
{t("portal.settings.appearance.themeSub")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="portal-settings__theme"
|
||||
role="radiogroup"
|
||||
aria-label={t("portal.settings.appearance.themeTitle")}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="quiet"
|
||||
role="radio"
|
||||
aria-checked={theme === opt.value}
|
||||
className={
|
||||
"portal-settings__theme-card" +
|
||||
(theme === opt.value ? " is-active" : "")
|
||||
}
|
||||
onClick={() => onTheme(opt.value)}
|
||||
>
|
||||
<span
|
||||
className={`portal-settings__theme-swatch portal-settings__theme-swatch--${opt.value}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="portal-settings__theme-text">
|
||||
<strong>
|
||||
{t(`portal.settings.appearance.${opt.value}.label`)}
|
||||
</strong>
|
||||
<span>{t(`portal.settings.appearance.${opt.value}.hint`)}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Notifications */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function NotificationsPanel({
|
||||
loading,
|
||||
notifications,
|
||||
order,
|
||||
onToggle,
|
||||
}: {
|
||||
loading: boolean;
|
||||
notifications: Record<string, boolean>;
|
||||
order: string[];
|
||||
onToggle: (id: string, value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("portal.settings.notifications.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
{t("portal.settings.notifications.sub")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="portal-settings__notifs">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<Skeleton width="9rem" />
|
||||
<Skeleton width="14rem" height="0.625rem" />
|
||||
</div>
|
||||
<Skeleton width="2.25rem" height="1.25rem" shape="rect" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="portal-settings__notifs">
|
||||
{order.map((id) => {
|
||||
if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={id} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>
|
||||
{t(`portal.settings.notifications.${id}.label`)}
|
||||
</strong>
|
||||
<span>
|
||||
{t(`portal.settings.notifications.${id}.description`)}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={notifications[id] ?? false}
|
||||
onChange={(v) => onToggle(id, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Workspace */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function WorkspacePanel({
|
||||
loading,
|
||||
workspaceName,
|
||||
onWorkspaceName,
|
||||
region,
|
||||
onRegion,
|
||||
regionOptions,
|
||||
planLabel,
|
||||
seats,
|
||||
}: {
|
||||
loading: boolean;
|
||||
workspaceName: string;
|
||||
onWorkspaceName: (v: string) => void;
|
||||
region: string;
|
||||
onRegion: (v: string) => void;
|
||||
regionOptions: SelectOption[];
|
||||
planLabel?: string;
|
||||
seats?: { used: number; total: number };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="4rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<FormField label={t("portal.settings.workspace.nameLabel")}>
|
||||
<Input
|
||||
value={workspaceName}
|
||||
onChange={(e) => onWorkspaceName(e.target.value)}
|
||||
placeholder={t("portal.settings.workspace.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.settings.workspace.regionLabel")}
|
||||
helperText={t("portal.settings.workspace.regionHelper")}
|
||||
>
|
||||
<Select
|
||||
value={region}
|
||||
onChange={(value) => onRegion(value ?? "")}
|
||||
options={regionOptions}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="portal-settings__plan">
|
||||
<div className="portal-settings__plan-row">
|
||||
<span className="portal-settings__plan-label">
|
||||
{t("portal.settings.workspace.plan")}
|
||||
</span>
|
||||
<StatusBadge tone="purple" size="sm">
|
||||
{planLabel ?? "—"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{seats && (
|
||||
<div className="portal-settings__plan-row">
|
||||
<span className="portal-settings__plan-label">
|
||||
{t("portal.settings.workspace.seats")}
|
||||
</span>
|
||||
<span className="portal-settings__plan-value">
|
||||
{t("portal.settings.workspace.seatsUsed", {
|
||||
used: seats.used,
|
||||
total: seats.total,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
{t("portal.settings.workspace.manageBilling")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Admin · Authentication */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function AuthenticationPanel({
|
||||
loading,
|
||||
tier,
|
||||
security,
|
||||
onSecurity,
|
||||
}: {
|
||||
loading: boolean;
|
||||
tier: Tier;
|
||||
security: SecurityForm;
|
||||
onSecurity: (patch: Partial<SecurityForm>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SSO/SCIM are enterprise capabilities; below it they render locked with a
|
||||
// badge rather than disappearing, so the upgrade path stays visible.
|
||||
const isEnterprise = tier === "enterprise";
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("portal.settings.authentication.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
{t("portal.settings.authentication.sub")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notifs">
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>{t("portal.settings.authentication.mfa.label")}</strong>
|
||||
<span>{t("portal.settings.authentication.mfa.description")}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={security.mfaEnforced}
|
||||
onChange={(v) => onSecurity({ mfaEnforced: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>{t("portal.settings.authentication.sso.label")}</strong>
|
||||
{!isEnterprise && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{t("portal.settings.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>{t("portal.settings.authentication.sso.description")}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isEnterprise && security.ssoEnabled}
|
||||
disabled={!isEnterprise}
|
||||
onChange={(v) => onSecurity({ ssoEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>
|
||||
{t("portal.settings.authentication.scim.label")}
|
||||
</strong>
|
||||
{!isEnterprise && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{t("portal.settings.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t("portal.settings.authentication.scim.description")}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isEnterprise && security.scimEnabled}
|
||||
disabled={!isEnterprise}
|
||||
onChange={(v) => onSecurity({ scimEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label={t("portal.settings.authentication.sessionTimeout")}
|
||||
helperText={t("portal.settings.authentication.sessionTimeoutHelper")}
|
||||
>
|
||||
<Select
|
||||
value={String(security.sessionTimeoutMins)}
|
||||
onChange={(value) =>
|
||||
onSecurity({ sessionTimeoutMins: Number(value ?? "0") })
|
||||
}
|
||||
options={SESSION_TIMEOUT_VALUES.map((value) => ({
|
||||
value,
|
||||
label: t(`portal.settings.authentication.timeout.${value}`),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Admin · Active sessions */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SessionsPanel({
|
||||
loading,
|
||||
sessions,
|
||||
}: {
|
||||
loading: boolean;
|
||||
sessions: ActiveSession[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("portal.settings.sessions.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
{t("portal.settings.sessions.sub")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-settings__notifs">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>{s.device}</strong>
|
||||
<span>
|
||||
{s.location} · {s.lastActive}
|
||||
</span>
|
||||
</div>
|
||||
{s.current ? (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.settings.sessions.thisDevice")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
// TODO(backend): DELETE /v1/settings/sessions/{id}
|
||||
<Button variant="tertiary" size="sm">
|
||||
{t("portal.settings.sessions.revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Admin · Early access */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function EarlyAccessPanel({
|
||||
loading,
|
||||
tier,
|
||||
betaFeatures,
|
||||
betaToggles,
|
||||
onBeta,
|
||||
}: {
|
||||
loading: boolean;
|
||||
tier: Tier;
|
||||
betaFeatures: BetaFeature[];
|
||||
betaToggles: Record<string, boolean>;
|
||||
onBeta: (id: string, value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEnterprise = tier === "enterprise";
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("portal.settings.earlyAccess.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
{t("portal.settings.earlyAccess.sub")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-settings__notifs">
|
||||
{betaFeatures.map((f) => {
|
||||
const locked = Boolean(f.enterpriseOnly) && !isEnterprise;
|
||||
return (
|
||||
<div key={f.id} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>{f.label}</strong>
|
||||
{locked && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{t("portal.settings.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>{f.description}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={!locked && (betaToggles[f.id] ?? false)}
|
||||
disabled={locked}
|
||||
onChange={(v) => onBeta(f.id, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import {
|
||||
DownloadIcon,
|
||||
UsersIcon,
|
||||
EditorIcon,
|
||||
ExternalLinkIcon,
|
||||
SearchIcon,
|
||||
SourcesIcon,
|
||||
PoliciesIcon,
|
||||
@@ -156,13 +158,22 @@ export function WelcomeBanner({ footer }: WelcomeBannerProps) {
|
||||
<div className="portal-welcome__cta">
|
||||
<Button
|
||||
variant="primary"
|
||||
leftSection={<ExternalLinkIcon size={15} />}
|
||||
onClick={() => {
|
||||
window.location.href = EDITOR_URL;
|
||||
}}
|
||||
>
|
||||
{t("portal.welcome.openInBrowser")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftSection={<DownloadIcon size={15} />}
|
||||
onClick={() => setActiveView("editor")}
|
||||
>
|
||||
{t("portal.welcome.installEditor")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="tertiary"
|
||||
leftSection={<UsersIcon size={15} />}
|
||||
onClick={() => setActiveView("users")}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { listInstances, type LinkedInstanceRow } from "@portal/mocks/link";
|
||||
import type { LinkedInstanceRow } from "@portal/api/link";
|
||||
import { listInstances } from "@portal/mocks/link";
|
||||
import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable";
|
||||
import "@portal/views/AccountLink.css";
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ComponentCard({
|
||||
<div className="portal-components__card-head">
|
||||
<h3 className="portal-components__card-name">{component.name}</h3>
|
||||
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
|
||||
{maturity.label}
|
||||
{t(maturity.label)}
|
||||
</StatusBadge>
|
||||
{!unlocked && (
|
||||
<span
|
||||
@@ -60,7 +60,7 @@ export function ComponentCard({
|
||||
|
||||
<div className="portal-components__card-meta">
|
||||
<span className="portal-components__price">
|
||||
{formatPrice(component.pricing)}
|
||||
{formatPrice(component.pricing, t)}
|
||||
</span>
|
||||
<span className="portal-components__pkg">
|
||||
@stirling/{component.package}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ComponentDetailModal({
|
||||
<span className="portal-components__modal-title">
|
||||
{component.name}
|
||||
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
|
||||
{maturity.label}
|
||||
{t(maturity.label)}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function ComponentDetailModal({
|
||||
unlocked ? (
|
||||
<div className="portal-components__modal-footer">
|
||||
<span className="portal-components__price">
|
||||
{formatPrice(component.pricing)}
|
||||
{formatPrice(component.pricing, t)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -155,11 +155,11 @@ export function ComponentDetailModal({
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.maturity")}
|
||||
value={maturity.label}
|
||||
value={t(maturity.label)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.price")}
|
||||
value={formatPrice(component.pricing)}
|
||||
value={formatPrice(component.pricing, t)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.freeQuota")}
|
||||
@@ -201,7 +201,7 @@ export function ComponentDetailModal({
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.perAction")}
|
||||
value={formatPrice(component.pricing)}
|
||||
value={formatPrice(component.pricing, t)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.billedOn")}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function DocumentAudit({ doc }: { doc: ReviewDocument }) {
|
||||
<div className="portal-documents__timeline-body">
|
||||
<div className="portal-documents__timeline-head">
|
||||
<StatusBadge tone={DOC_AUDIT_TONE[event.kind]} size="sm">
|
||||
{DOC_AUDIT_LABEL[event.kind]}
|
||||
{t(DOC_AUDIT_LABEL[event.kind])}
|
||||
</StatusBadge>
|
||||
<span className="portal-documents__timeline-time">
|
||||
{event.time}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function DocumentDrawer({ doc, onClose }: DocumentDrawerProps) {
|
||||
<div className="portal-documents__drawer">
|
||||
<div className="portal-documents__drawer-status">
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[doc.status]} size="sm">
|
||||
{DOCUMENT_STATUS_LABEL[doc.status]}
|
||||
{t(DOCUMENT_STATUS_LABEL[doc.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
|
||||
<div className="portal-documents__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.documents.overview.status")}
|
||||
value={DOCUMENT_STATUS_LABEL[doc.status]}
|
||||
value={t(DOCUMENT_STATUS_LABEL[doc.status])}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.documents.overview.product")}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function ReviewQueueTable({
|
||||
width: "10rem",
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
|
||||
{DOCUMENT_STATUS_LABEL[d.status]}
|
||||
{t(DOCUMENT_STATUS_LABEL[d.status])}
|
||||
{d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""}
|
||||
</StatusBadge>
|
||||
),
|
||||
|
||||
@@ -73,7 +73,7 @@ export function InstanceHealthTable({ instances }: Props) {
|
||||
size="sm"
|
||||
pulse={i.status === "healthy"}
|
||||
>
|
||||
{INSTANCE_STATUS_LABEL[i.status]}
|
||||
{t(INSTANCE_STATUS_LABEL[i.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon;
|
||||
const status = policy?.state.status;
|
||||
const enforces = config.rules.join(" · ");
|
||||
const enforces = config.rules.map((r) => t(r)).join(" · ");
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -43,7 +43,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
</span>
|
||||
|
||||
<div className="portal-policies__card-identity">
|
||||
<h2 className="portal-policies__card-title">{category.label}</h2>
|
||||
<h2 className="portal-policies__card-title">{t(category.label)}</h2>
|
||||
{enforces && (
|
||||
<span className="portal-policies__card-enforces">{enforces}</span>
|
||||
)}
|
||||
|
||||
@@ -111,6 +111,7 @@ export function PolicyDetailPanel({
|
||||
onRetry,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmingClear, setConfirmingClear] = useState(false);
|
||||
if (!policy) return null;
|
||||
const { category, config, state, steps, stats, activity } = policy;
|
||||
const isPaused = state.status === "paused";
|
||||
@@ -136,200 +137,241 @@ export function PolicyDetailPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={category.label}
|
||||
footer={
|
||||
<div className="portal-policies__detail-foot">
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
style={{ marginRight: "auto" }}
|
||||
>
|
||||
{t("portal.policies.detail.actions.delete")}
|
||||
</Button>
|
||||
)}
|
||||
{onRun && (
|
||||
<>
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={t(category.label)}
|
||||
footer={
|
||||
<div className="portal-policies__detail-foot">
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
style={{ marginRight: "auto" }}
|
||||
>
|
||||
{t("portal.policies.detail.actions.delete")}
|
||||
</Button>
|
||||
)}
|
||||
{onRun && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
style={canDelete ? undefined : { marginRight: "auto" }}
|
||||
>
|
||||
{t("portal.policies.detail.actions.runNow")}
|
||||
</Button>
|
||||
)}
|
||||
{canClearHistory && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingClear(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("portal.policies.detail.actions.clearHistory")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
style={canDelete ? undefined : { marginRight: "auto" }}
|
||||
>
|
||||
{t("portal.policies.detail.actions.runNow")}
|
||||
</Button>
|
||||
)}
|
||||
{canClearHistory && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClearHistory}
|
||||
onClick={onTogglePause}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("portal.policies.detail.actions.clearHistory")}
|
||||
{isPaused
|
||||
? t("portal.policies.detail.actions.resume")
|
||||
: t("portal.policies.detail.actions.pause")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onTogglePause}
|
||||
disabled={busy}
|
||||
<Button size="sm" onClick={onEdit} disabled={busy}>
|
||||
{t("portal.policies.detail.actions.editSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Status + trigger strip */}
|
||||
<div className="portal-policies__detail-status">
|
||||
<StatusBadge
|
||||
tone={isPaused ? "warning" : "success"}
|
||||
pulse={!isPaused}
|
||||
>
|
||||
{isPaused
|
||||
? t("portal.policies.detail.actions.resume")
|
||||
: t("portal.policies.detail.actions.pause")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit} disabled={busy}>
|
||||
{t("portal.policies.detail.actions.editSettings")}
|
||||
</Button>
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
</StatusBadge>
|
||||
{hasEditorSource && (
|
||||
<>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">{trigger}</span>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">
|
||||
{outputLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Status + trigger strip */}
|
||||
<div className="portal-policies__detail-status">
|
||||
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
|
||||
{isPaused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
</StatusBadge>
|
||||
{hasEditorSource && (
|
||||
<>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">{trigger}</span>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">{outputLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enforces — plain text, no pills */}
|
||||
<div className="portal-policies__detail-inline">
|
||||
<span className="portal-policies__detail-inline-label">
|
||||
{t("portal.policies.detail.enforces")}
|
||||
</span>
|
||||
<span className="portal-policies__detail-inline-value">
|
||||
{enforceItems
|
||||
? enforceItems.map((op, i) => (
|
||||
<span key={op}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="portal-policies__enforce-arrow"
|
||||
aria-hidden
|
||||
>
|
||||
{" "}
|
||||
→{" "}
|
||||
</span>
|
||||
)}
|
||||
{humanizeEndpoint(op)}
|
||||
</span>
|
||||
))
|
||||
: config.rules.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
{state.sources.length > 0 && (
|
||||
{/* Enforces — plain text, no pills */}
|
||||
<div className="portal-policies__detail-inline">
|
||||
<span className="portal-policies__detail-inline-label">
|
||||
{t("portal.policies.detail.sources")}
|
||||
{t("portal.policies.detail.enforces")}
|
||||
</span>
|
||||
<span className="portal-policies__detail-inline-value">
|
||||
{state.sources.map(sourceLabel).join(" · ")}
|
||||
{enforceItems
|
||||
? enforceItems.map((op, i) => (
|
||||
<span key={op}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="portal-policies__enforce-arrow"
|
||||
aria-hidden
|
||||
>
|
||||
{" "}
|
||||
→{" "}
|
||||
</span>
|
||||
)}
|
||||
{humanizeEndpoint(op, t)}
|
||||
</span>
|
||||
))
|
||||
: config.rules.map((r) => t(r)).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("portal.policies.detail.recentActivity")}
|
||||
</h3>
|
||||
{/* Sources */}
|
||||
{state.sources.length > 0 && (
|
||||
<div className="portal-policies__detail-inline">
|
||||
<span className="portal-policies__detail-inline-label">
|
||||
{t("portal.policies.detail.sources")}
|
||||
</span>
|
||||
<span className="portal-policies__detail-inline-value">
|
||||
{state.sources.map(sourceLabel).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activity.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activity.map((item, i) => (
|
||||
<div
|
||||
key={`${item.doc}-${i}`}
|
||||
className="portal-policies__activity-row"
|
||||
>
|
||||
<span
|
||||
className={`portal-policies__activity-icon portal-policies__activity-icon--${
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}`}
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("portal.policies.detail.recentActivity")}
|
||||
</h3>
|
||||
|
||||
{activity.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activity.map((item, i) => (
|
||||
<div
|
||||
key={`${item.doc}-${i}`}
|
||||
className="portal-policies__activity-row"
|
||||
>
|
||||
{item.status === "flagged" ? (
|
||||
<WarnIcon />
|
||||
) : item.status === "processing" ? (
|
||||
<SpinIcon />
|
||||
) : (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</span>
|
||||
<span className="portal-policies__activity-text">
|
||||
<span className="portal-policies__activity-doc">
|
||||
{item.doc}
|
||||
</span>
|
||||
<span className="portal-policies__activity-action">
|
||||
<span
|
||||
className={`portal-policies__activity-icon portal-policies__activity-icon--${
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}`}
|
||||
>
|
||||
{item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} />
|
||||
<WarnIcon />
|
||||
) : item.status === "processing" ? (
|
||||
<SpinIcon />
|
||||
) : (
|
||||
item.action
|
||||
<CheckIcon />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-policies__activity-time">
|
||||
{item.time}
|
||||
</span>
|
||||
{item.status === "flagged" && onRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="portal-policies__link portal-policies__activity-retry"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("portal.policies.detail.retry")}
|
||||
</Button>
|
||||
<span className="portal-policies__activity-text">
|
||||
<span className="portal-policies__activity-doc">
|
||||
{item.doc}
|
||||
</span>
|
||||
<span className="portal-policies__activity-action">
|
||||
{item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} />
|
||||
) : (
|
||||
item.action
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-policies__activity-time">
|
||||
{item.time}
|
||||
</span>
|
||||
{item.status === "flagged" && onRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="portal-policies__link portal-policies__activity-retry"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("portal.policies.detail.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.policies.detail.emptyActivity.title")}
|
||||
description={t(
|
||||
"portal.policies.detail.emptyActivity.description",
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.policies.detail.emptyActivity.title")}
|
||||
description={t("portal.policies.detail.emptyActivity.description")}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padding="none" className="portal-policies__detail-stats">
|
||||
<StatTile
|
||||
label={t("portal.policies.stats.docsEnforced")}
|
||||
value={stats.enforced.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.policies.stats.dataProcessed")}
|
||||
value={stats.dataProcessed}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.policies.stats.activeFor")}
|
||||
value={stats.activeFor}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padding="none" className="portal-policies__detail-stats">
|
||||
<StatTile
|
||||
label={t("portal.policies.stats.docsEnforced")}
|
||||
value={stats.enforced.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.policies.stats.dataProcessed")}
|
||||
value={stats.dataProcessed}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.policies.stats.activeFor")}
|
||||
value={stats.activeFor}
|
||||
/>
|
||||
</Card>
|
||||
</Modal>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={confirmingClear}
|
||||
onClose={() => setConfirmingClear(false)}
|
||||
width="sm"
|
||||
title={t("portal.policies.detail.clearHistory.title")}
|
||||
footer={
|
||||
<div className="portal-policies__detail-foot">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingClear(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("portal.policies.detail.clearHistory.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setConfirmingClear(false);
|
||||
onClearHistory?.();
|
||||
}}
|
||||
>
|
||||
{t("portal.policies.detail.clearHistory.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{t("portal.policies.detail.clearHistory.body")}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, FormField, Input, Select, ToggleSwitch } from "@app/ui";
|
||||
import type { PolicyField } from "@portal/api/policies";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -19,13 +20,14 @@ export function PolicyFieldRow({
|
||||
value,
|
||||
onChange,
|
||||
}: PolicyFieldRowProps) {
|
||||
const { t } = useTranslation();
|
||||
if (field.type === "toggle") {
|
||||
return (
|
||||
<div className="portal-policies__toggle-row">
|
||||
<ToggleSwitch
|
||||
checked={Boolean(value)}
|
||||
onChange={onChange}
|
||||
label={field.label}
|
||||
label={t(field.label)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -40,7 +42,7 @@ export function PolicyFieldRow({
|
||||
: [...selected, opt],
|
||||
);
|
||||
return (
|
||||
<FormField label={field.label}>
|
||||
<FormField label={t(field.label)}>
|
||||
<div className="portal-policies__field-chips">
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<Chip
|
||||
@@ -49,7 +51,7 @@ export function PolicyFieldRow({
|
||||
size="sm"
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
{opt}
|
||||
{t(`policies.fieldOption.${field.key}.${opt}`, opt)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
@@ -59,11 +61,14 @@ export function PolicyFieldRow({
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<FormField label={field.label}>
|
||||
<FormField label={t(field.label)}>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o,
|
||||
label: t(`policies.fieldOption.${field.key}.${o}`, o),
|
||||
}))}
|
||||
onChange={(value) => onChange(value ?? "")}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -71,7 +76,7 @@ export function PolicyFieldRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField label={field.label}>
|
||||
<FormField label={t(field.label)}>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
@@ -12,6 +13,15 @@ const meta: Meta<typeof PolicySetupWizard> = {
|
||||
title: "Portal/Policies/PolicySetupWizard",
|
||||
component: PolicySetupWizard,
|
||||
parameters: { layout: "fullscreen" },
|
||||
// The wizard reads the tool registry (for capability fallback names/icons),
|
||||
// so stories must supply the provider the app mounts in PortalApp.
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ToolRegistryProvider>
|
||||
<Story />
|
||||
</ToolRegistryProvider>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
onClose: () => {},
|
||||
onSubmit: async () => {},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -12,18 +11,23 @@ import {
|
||||
Tabs,
|
||||
ToggleSwitch,
|
||||
} from "@app/ui";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import {
|
||||
POLICY_DOC_TYPES,
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
@@ -65,6 +69,58 @@ function resolveFieldValues(
|
||||
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
|
||||
/**
|
||||
* Policy-facing framing for each capability a policy can include. Labels and
|
||||
* descriptions describe what the policy DOES to a document — deliberately not
|
||||
* naming the underlying tool — so the setup reads as the policy's own settings
|
||||
* rather than an assembled chain of tools. Endpoints with no entry fall back to
|
||||
* the humanised endpoint name with no description.
|
||||
*/
|
||||
const CAPABILITY_META: Record<
|
||||
string,
|
||||
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
|
||||
> = {
|
||||
[TOOL_ENDPOINTS.redact]: {
|
||||
labelKey: "portal.policies.wizard.capability.redact.label",
|
||||
labelEn: "Redact sensitive information",
|
||||
descKey: "portal.policies.wizard.capability.redact.desc",
|
||||
descEn:
|
||||
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.sanitize]: {
|
||||
labelKey: "portal.policies.wizard.capability.sanitize.label",
|
||||
labelEn: "Strip active content",
|
||||
descKey: "portal.policies.wizard.capability.sanitize.desc",
|
||||
descEn:
|
||||
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.watermark]: {
|
||||
labelKey: "portal.policies.wizard.capability.watermark.label",
|
||||
labelEn: "Apply a watermark",
|
||||
descKey: "portal.policies.wizard.capability.watermark.desc",
|
||||
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.ocr]: {
|
||||
labelKey: "portal.policies.wizard.capability.ocr.label",
|
||||
labelEn: "Make text searchable",
|
||||
descKey: "portal.policies.wizard.capability.ocr.desc",
|
||||
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.flatten]: {
|
||||
labelKey: "portal.policies.wizard.capability.flatten.label",
|
||||
labelEn: "Flatten the document",
|
||||
descKey: "portal.policies.wizard.capability.flatten.desc",
|
||||
descEn:
|
||||
"Merges form fields and annotations into the page so they can't be edited.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.compress]: {
|
||||
labelKey: "portal.policies.wizard.capability.compress.label",
|
||||
labelEn: "Reduce file size",
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
descEn: "Compresses the document to a smaller file size.",
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
@@ -117,6 +173,19 @@ function PolicySetupWizardBody({
|
||||
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { allTools: toolRegistry } = useToolRegistry();
|
||||
|
||||
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
|
||||
// Build a reverse map so we can look up icons and display names by endpoint.
|
||||
const registryByEndpoint = useMemo(() => {
|
||||
const map = new Map<string, ToolRegistryEntry>();
|
||||
for (const entry of Object.values(toolRegistry)) {
|
||||
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
|
||||
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
|
||||
}
|
||||
return map;
|
||||
}, [toolRegistry]);
|
||||
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
|
||||
@@ -146,12 +215,9 @@ function PolicySetupWizardBody({
|
||||
};
|
||||
return [editorSource, ...backendSources];
|
||||
}, [sourcesAsync.data, t]);
|
||||
const [scopeNarrow, setScopeNarrow] = useState(
|
||||
(policy?.state.scopeTypes.length ?? 0) > 0,
|
||||
);
|
||||
const [scopeTypes, setScopeTypes] = useState<string[]>(
|
||||
policy?.state.scopeTypes ?? [],
|
||||
);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
|
||||
// TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]).
|
||||
// Store username (which is the email in Spring Security) as reviewerEmail.
|
||||
// See UserSelector.tsx in the editor for the grouping/display pattern.
|
||||
@@ -166,10 +232,10 @@ function PolicySetupWizardBody({
|
||||
const [runOn, setRunOn] = useState<"upload" | "export">(
|
||||
policy?.state.runOn ?? "upload",
|
||||
);
|
||||
const [maxRetries, setMaxRetries] = useState(policy?.state.maxRetries ?? 3);
|
||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
||||
policy?.state.retryDelayMinutes ?? 5,
|
||||
);
|
||||
// Policies run once; retry config has no UI. Preserve any saved values on
|
||||
// edit and default new policies to no retries (run once).
|
||||
const [maxRetries] = useState(policy?.state.maxRetries ?? 0);
|
||||
const [retryDelayMinutes] = useState(policy?.state.retryDelayMinutes ?? 0);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -188,12 +254,6 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
}
|
||||
|
||||
function toggleScopeType(dt: string) {
|
||||
setScopeTypes((prev) =>
|
||||
prev.includes(dt) ? prev.filter((d) => d !== dt) : [...prev, dt],
|
||||
);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
if (enabledTools.length === 0) {
|
||||
@@ -211,7 +271,7 @@ function PolicySetupWizardBody({
|
||||
await onSubmit(entry, {
|
||||
fieldValues,
|
||||
sources,
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
scopeTypes,
|
||||
reviewerEmail,
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
@@ -227,8 +287,6 @@ function PolicySetupWizardBody({
|
||||
}
|
||||
}
|
||||
|
||||
const docTypesEnabled = category.providesClassification === true;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
@@ -241,14 +299,14 @@ function PolicySetupWizardBody({
|
||||
</span>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.title.edit", {
|
||||
category: category.label,
|
||||
category: t(category.label),
|
||||
})
|
||||
: t("portal.policies.wizard.title.setUp", {
|
||||
category: category.label,
|
||||
category: t(category.label),
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
subtitle={config.summary}
|
||||
subtitle={t(config.summary)}
|
||||
footer={
|
||||
<div className="portal-policies__wizard-foot">
|
||||
<Button variant="tertiary" size="sm" onClick={onClose}>
|
||||
@@ -304,26 +362,70 @@ function PolicySetupWizardBody({
|
||||
{step === "workflow" && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
{t("portal.policies.wizard.workflow.description")}
|
||||
{t(
|
||||
"portal.policies.wizard.workflow.description",
|
||||
"Choose what this policy does to every document it processes.",
|
||||
)}
|
||||
</p>
|
||||
{tools.map((tl) => (
|
||||
<Card key={tl.operation} padding="tight">
|
||||
<div className="portal-policies__tool-head">
|
||||
<span className="portal-policies__tool-name">
|
||||
{humanizeEndpoint(tl.operation)}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<Card padding="none">
|
||||
<div className="portal-policies__capabilities">
|
||||
{tools.map((tl) => {
|
||||
const meta = CAPABILITY_META[tl.operation];
|
||||
const label = meta
|
||||
? t(meta.labelKey, meta.labelEn)
|
||||
: (registryByEndpoint.get(tl.operation)?.name ??
|
||||
humanizeEndpoint(tl.operation, t));
|
||||
const description = meta
|
||||
? t(meta.descKey, meta.descEn)
|
||||
: undefined;
|
||||
const hasConfig =
|
||||
tl.operation === TOOL_ENDPOINTS.redact ||
|
||||
tl.operation === TOOL_ENDPOINTS.watermark;
|
||||
return (
|
||||
<div
|
||||
key={tl.operation}
|
||||
className="portal-policies__capability"
|
||||
data-on={tl.enabled || undefined}
|
||||
>
|
||||
<SettingsRow
|
||||
label={label}
|
||||
description={description}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{tl.enabled && hasConfig && (
|
||||
<div className="portal-policies__capability-config">
|
||||
{tl.operation === TOOL_ENDPOINTS.redact && (
|
||||
<PolicyRedactConfig
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tl.operation === TOOL_ENDPOINTS.watermark && (
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,25 +454,23 @@ function PolicySetupWizardBody({
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("portal.policies.wizard.sources.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__sources">
|
||||
{sourcesAsync.loading && !sourcesAsync.data ? (
|
||||
<p className="portal-policies__sources-loading">
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : availableSources.length === 1 ? (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("portal.policies.wizard.sources.emptyTitle")}
|
||||
description={t(
|
||||
"portal.policies.wizard.sources.emptyDescription",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
availableSources.map((src) => (
|
||||
{sourcesAsync.loading && !sourcesAsync.data ? (
|
||||
<p className="portal-policies__sources-loading">
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : (
|
||||
// The editor is always an available source (unconditionally prepended
|
||||
// to availableSources), so the list is never empty — no "no sources"
|
||||
// state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{availableSources.map((src) => (
|
||||
// A selectable multi-line tile (icon + name + type + check).
|
||||
// Uses the shared Button (raw <button> is lint-banned); the tile
|
||||
// CSS overrides the Button's fixed height for the two-line layout.
|
||||
<Button
|
||||
key={src.id}
|
||||
type="button"
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(sources.includes(src.id)
|
||||
@@ -391,55 +491,8 @@ function PolicySetupWizardBody({
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("portal.policies.wizard.docTypes.heading")}
|
||||
</h3>
|
||||
{!docTypesEnabled ? (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("portal.policies.wizard.docTypes.allTitle")}
|
||||
description={t("portal.policies.wizard.docTypes.allDescription")}
|
||||
/>
|
||||
) : (
|
||||
<Card padding="tight">
|
||||
<div className="portal-policies__doctypes-head">
|
||||
<span>
|
||||
{scopeTypes.length === 0
|
||||
? t("portal.policies.wizard.docTypes.allTitle")
|
||||
: t("portal.policies.wizard.docTypes.selected", {
|
||||
count: scopeTypes.length,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="portal-policies__link"
|
||||
onClick={() => setScopeNarrow((v) => !v)}
|
||||
>
|
||||
{scopeNarrow
|
||||
? t("portal.policies.wizard.docTypes.clear")
|
||||
: t("portal.policies.wizard.docTypes.narrow")}
|
||||
</Button>
|
||||
</div>
|
||||
{scopeNarrow && (
|
||||
<div className="portal-policies__doctypes">
|
||||
{POLICY_DOC_TYPES.map((dt) => (
|
||||
<Chip
|
||||
key={dt}
|
||||
accent={scopeTypes.includes(dt) ? "default" : "neutral"}
|
||||
size="sm"
|
||||
onClick={() => toggleScopeType(dt)}
|
||||
>
|
||||
{dt}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
@@ -560,33 +613,6 @@ function PolicySetupWizardBody({
|
||||
</>
|
||||
)}
|
||||
{/* TODO: reviewer user-picker goes here */}
|
||||
<h4 className="portal-policies__wizard-subheading">
|
||||
{t("portal.policies.wizard.output.retries.heading")}
|
||||
</h4>
|
||||
<FormField
|
||||
label={t("portal.policies.wizard.output.retries.maxLabel")}
|
||||
>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
type="number"
|
||||
value={String(maxRetries)}
|
||||
onChange={(e) =>
|
||||
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.policies.wizard.output.retries.delayLabel")}
|
||||
>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
type="number"
|
||||
value={String(retryDelayMinutes)}
|
||||
onChange={(e) =>
|
||||
setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,10 @@ import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
seedPolicies,
|
||||
seedPolicyRuns,
|
||||
type DecoratedPolicy,
|
||||
type PolicyState,
|
||||
} from "@portal/mocks/policies";
|
||||
} from "@portal/api/policies";
|
||||
import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies";
|
||||
|
||||
export { POLICY_CATEGORIES, POLICY_CONFIG };
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export function DealJourney({
|
||||
{isTerminal
|
||||
? t("portal.procurement.journey.live")
|
||||
: t("portal.procurement.journey.nextStep", {
|
||||
action: currentStep?.gatingAction ?? "",
|
||||
action: currentStep ? t(currentStep.gatingAction) : "",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export function DealJourney({
|
||||
loading={advancing}
|
||||
onClick={() => onAdvance(currentStage)}
|
||||
>
|
||||
{currentStep.gatingAction}
|
||||
{t(currentStep.gatingAction)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,9 @@ export function DocumentLedger({
|
||||
{group.label}
|
||||
</span>
|
||||
{blurb && (
|
||||
<span className="portal-proc__stage-hint">· {blurb}</span>
|
||||
<span className="portal-proc__stage-hint">
|
||||
· {t(blurb)}
|
||||
</span>
|
||||
)}
|
||||
{cur && (
|
||||
<Chip accent="premium" size="sm">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LockedState } from "@portal/components/procurement/LockedState";
|
||||
import { JOURNEY } from "@portal/mocks/procurement";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof LockedState> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import { JOURNEY } from "@portal/mocks/procurement";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof StageStepper> = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DealStage, JourneyStep } from "@portal/api/procurement";
|
||||
|
||||
/** Status of a step relative to the deal's current stage. */
|
||||
@@ -19,6 +20,7 @@ export function StageStepper({
|
||||
currentStage: DealStage;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const order = journey.map((s) => s.stage);
|
||||
const curIdx = locked ? -1 : order.indexOf(currentStage);
|
||||
|
||||
@@ -40,7 +42,7 @@ export function StageStepper({
|
||||
)}
|
||||
<div className={`portal-proc__step portal-proc__step--${state}`}>
|
||||
<span className="portal-proc__step-dot" aria-hidden />
|
||||
<span className="portal-proc__step-label">{step.label}</span>
|
||||
<span className="portal-proc__step-label">{t(step.label)}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { LinkIcon } from "@portal/components/icons";
|
||||
import type { ComponentType } from "react";
|
||||
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
|
||||
|
||||
export interface AccountLinkSettingsSeam {
|
||||
/** Section key in the Settings nav + body switch. */
|
||||
navKey: string;
|
||||
/** Nav key in the shared settings modal (registered in config/types.ts). */
|
||||
navKey: "account-link";
|
||||
/** i18n key for the nav label; resolved with `t()` at the call site. */
|
||||
labelKey: string;
|
||||
icon: ReactNode;
|
||||
/** LocalIcon name for the nav item. */
|
||||
icon: string;
|
||||
/** The section body — the account-link panel. */
|
||||
Body: ComponentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin "Account link" section of Settings (self-hosted only). The SaaS
|
||||
* build shadows this file with `null`: the signed-in account IS the SaaS
|
||||
* account, so there is no instance to link — the nav item and its panel both
|
||||
* drop out, and nothing imports the link-only AccountLinkPanel.
|
||||
* The admin "Account link" section of the shared settings modal (self-hosted
|
||||
* only). The SaaS build shadows this file with `null`: the signed-in account IS
|
||||
* the SaaS account, so there is no instance to link — the nav item and its
|
||||
* panel both drop out, and nothing imports the link-only AccountLinkPanel.
|
||||
*/
|
||||
export const accountLinkSettings: AccountLinkSettingsSeam | null = {
|
||||
navKey: "account-link",
|
||||
labelKey: "portal.settings.sections.account-link",
|
||||
icon: <LinkIcon size={16} />,
|
||||
icon: "link-rounded",
|
||||
Body: AccountLinkPanel,
|
||||
};
|
||||
|
||||
@@ -41,9 +41,10 @@ interface InviteMemberModalProps {
|
||||
type InviteRole = "member" | "admin";
|
||||
type Mode = "email" | "direct";
|
||||
|
||||
const ROLE_SELECT_OPTIONS: { value: InviteRole; label: string }[] = [
|
||||
{ value: "member", label: ROLE_LABEL.member },
|
||||
{ value: "admin", label: ROLE_LABEL.admin },
|
||||
// Values hold i18n keys; resolved with t() where the select renders.
|
||||
const ROLE_SELECT_OPTIONS: { value: InviteRole; labelKey: string }[] = [
|
||||
{ value: "member", labelKey: ROLE_LABEL.member },
|
||||
{ value: "admin", labelKey: ROLE_LABEL.admin },
|
||||
];
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -117,9 +118,11 @@ export function InviteMemberModal({
|
||||
}, [email, username, password]);
|
||||
|
||||
// Drop the "admin" (Org Owner) option where it can't be assigned (SaaS).
|
||||
const roleOptions = adminRole
|
||||
? ROLE_SELECT_OPTIONS
|
||||
: ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin");
|
||||
const roleOptions = (
|
||||
adminRole
|
||||
? ROLE_SELECT_OPTIONS
|
||||
: ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin")
|
||||
).map((o) => ({ value: o.value, label: t(o.labelKey) }));
|
||||
|
||||
const authTypeOptions: { value: AuthType; label: string }[] = [
|
||||
{ value: "WEB", label: t("users.invite.authWeb", "Password") },
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { readMocksPreference } from "@portal/mocks/preference";
|
||||
import { usePlanTier } from "@portal/contexts/usePlanTier";
|
||||
|
||||
export type Tier = "free" | "pro" | "enterprise";
|
||||
@@ -26,9 +24,9 @@ export const TIER_INFO: Record<Tier, TierInfo> = {
|
||||
|
||||
interface TierContextValue {
|
||||
tier: Tier;
|
||||
/** No-op when MSW mocks are off (tier is derived from real link state). */
|
||||
/** No-op when the tier is derived from the real plan (i.e. in the app). */
|
||||
setTier: (tier: Tier) => void;
|
||||
/** True when the tier value is derived from the real wallet/link, not the dropdown. */
|
||||
/** True when the tier value is derived from the real plan, not pinned. */
|
||||
isDerived: boolean;
|
||||
}
|
||||
|
||||
@@ -36,38 +34,33 @@ const TierContext = createContext<TierContextValue | null>(null);
|
||||
|
||||
export function TierProvider({
|
||||
children,
|
||||
initialTier = "pro",
|
||||
initialTier,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Pins the tier to a fixed, locally settable value. Storybook and demo
|
||||
* surfaces pass this to stage a specific tier; the app omits it, so the
|
||||
* tier is always derived from the real plan (see usePlanTier — link state
|
||||
* self-hosted, wallet on SaaS).
|
||||
*/
|
||||
initialTier?: Tier;
|
||||
}) {
|
||||
// Mocks toggling reloads the page (see MocksToggle), so a single read at mount
|
||||
// is correct — the preference can't change without us remounting.
|
||||
const mocksOn = useMemo(() => readMocksPreference(), []);
|
||||
// Real derived tier. Its source is a per-flavor seam: self-hosted derives it
|
||||
// from the link/subscription state, SaaS from the wallet (see usePlanTier).
|
||||
const pinned = initialTier !== undefined;
|
||||
const [pinnedTier, setPinnedTier] = useState<Tier>(initialTier ?? "free");
|
||||
const derivedTier = usePlanTier();
|
||||
|
||||
const [mockTier, setMockTier] = useState<Tier>(initialTier);
|
||||
|
||||
// When mocks are off, mirror the derived tier so any component keyed on `tier`
|
||||
// (sidebar plan badge, gated panels) stays consistent. When mocks are on, the
|
||||
// dropdown wins.
|
||||
useEffect(() => {
|
||||
if (!mocksOn) {
|
||||
setMockTier(derivedTier);
|
||||
}
|
||||
}, [mocksOn, derivedTier]);
|
||||
|
||||
// Memo on the resolved tier (not its inputs) so plan transitions that map to
|
||||
// the same tier don't re-render every consumer.
|
||||
const tier = pinned ? pinnedTier : derivedTier;
|
||||
const value = useMemo<TierContextValue>(
|
||||
() => ({
|
||||
tier: mocksOn ? mockTier : derivedTier,
|
||||
// Setter is a no-op when mocks are off — UI controls can disable themselves
|
||||
tier,
|
||||
// Setter is a no-op when derived — UI controls can disable themselves
|
||||
// via `isDerived`, but even if one slips through, it has no effect.
|
||||
setTier: mocksOn ? setMockTier : () => {},
|
||||
isDerived: !mocksOn,
|
||||
setTier: pinned ? setPinnedTier : () => {},
|
||||
isDerived: !pinned,
|
||||
}),
|
||||
[mocksOn, mockTier, derivedTier],
|
||||
[pinned, tier],
|
||||
);
|
||||
|
||||
return <TierContext.Provider value={value}>{children}</TierContext.Provider>;
|
||||
|
||||
@@ -1,125 +1,14 @@
|
||||
/**
|
||||
* Agent Builder fixtures and the types api/agents.ts shares with them.
|
||||
* Agent Builder fixtures. Types live in api/agents.ts (the backend contract);
|
||||
* this module only builds fake data for Storybook and tests.
|
||||
*
|
||||
* An "agent" here is an AI agent that classifies, extracts from, and routes
|
||||
* documents. The builder is its lifecycle surface: scenarios (named test
|
||||
* cases), tool-access governance, an eval / golden set, and version history.
|
||||
*
|
||||
* api/agents.ts imports the types; the MSW handlers serve this fixture data
|
||||
* over the intercepted apiClient.local.json() calls. Components never reach into this
|
||||
* module directly. Once a real backend exists the handlers stop being
|
||||
* registered and these fixtures can be deleted (or kept as test seeds).
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Domain types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AgentStatus = "draft" | "published";
|
||||
|
||||
/**
|
||||
* Tool-access posture. `broad` lets the agent call any tool it can reach;
|
||||
* `restricted` is allow-by-default minus an explicit deny list (the governance
|
||||
* mode enterprise tenants use to fence agents away from sensitive tools).
|
||||
*/
|
||||
export type ToolMode = "broad" | "restricted";
|
||||
|
||||
/** A named test case describing expected agent behaviour for a kind of input. */
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
/** What the agent is expected to do for this input. */
|
||||
expectation: string;
|
||||
/** Whether this scenario is currently exercised by the eval run. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** A single golden-set check with its last-run outcome. */
|
||||
export interface EvalCase {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Last observed pass/fail; null when the case has never been run. */
|
||||
passing: boolean | null;
|
||||
/** Mean latency of the last run in milliseconds. */
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface AgentVersion {
|
||||
/** Display label, e.g. "v3" or "v2-draft". */
|
||||
version: string;
|
||||
status: AgentStatus;
|
||||
/** ISO timestamp the version was created. */
|
||||
createdAt: string;
|
||||
author: string;
|
||||
/** One-line change summary. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
/** One-line role description shown under the name in the selector. */
|
||||
role: string;
|
||||
status: AgentStatus;
|
||||
/** Current working version, e.g. "v3" or "v2-draft". */
|
||||
version: string;
|
||||
model: string;
|
||||
scenarios: Scenario[];
|
||||
toolMode: ToolMode;
|
||||
/** Tools the agent may not call when `toolMode` is "restricted". */
|
||||
deniedTools: string[];
|
||||
/** Count of golden-set cases currently passing. */
|
||||
evalsPassing: number;
|
||||
/** Total golden-set cases. */
|
||||
evalsTotal: number;
|
||||
evalCases: EvalCase[];
|
||||
versions: AgentVersion[];
|
||||
}
|
||||
|
||||
export interface AgentsSummary {
|
||||
/** Agents in the "published" state. */
|
||||
activeAgents: number;
|
||||
/** Total agents regardless of status. */
|
||||
totalAgents: number;
|
||||
/** Mean eval pass-rate across all agents, 0..1. */
|
||||
avgPassRate: number;
|
||||
/** Total scenarios across all agents. */
|
||||
totalScenarios: number;
|
||||
/** Latest published version label across the fleet, e.g. "v3". */
|
||||
latestPublished: string;
|
||||
}
|
||||
|
||||
export interface AgentsResponse {
|
||||
summary: AgentsSummary;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (chip tone per status). Product copy, client-side. */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
|
||||
published: "success",
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
|
||||
* palette in restricted mode so the deny list is picked from a known set
|
||||
* rather than free-typed.
|
||||
*/
|
||||
export const TOOL_CATALOGUE = [
|
||||
"extract.fields",
|
||||
"classify.document",
|
||||
"route.pipeline",
|
||||
"lookup.crm",
|
||||
"send.email",
|
||||
"write.audit",
|
||||
"read.pii",
|
||||
"invoke.webhook",
|
||||
] as const;
|
||||
import type { Agent, AgentsResponse, AgentsSummary } from "@portal/api/agents";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixture builders */
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { embeddedDataHandlers } from "@portal/mocks/handlers";
|
||||
|
||||
// Data handlers only (see embeddedDataHandlers) and no seeded auth token: the
|
||||
// portal shares an origin and auth session with the host editor, so mocking auth
|
||||
// or seeding a token would log the editor out.
|
||||
export const worker = setupWorker(...embeddedDataHandlers);
|
||||
|
||||
let workerStarted = false;
|
||||
|
||||
/**
|
||||
* Start the MSW worker. Idempotent — calling repeatedly is safe.
|
||||
*
|
||||
* The toggle flips MSW by writing the preference to localStorage and
|
||||
* reloading the page, so there's no need for a `stopMockWorker` counterpart:
|
||||
* the next boot just decides whether to call this or not.
|
||||
*/
|
||||
export async function startMockWorker(): Promise<void> {
|
||||
if (workerStarted) return;
|
||||
await worker.start({
|
||||
onUnhandledRequest: "bypass",
|
||||
serviceWorker: { url: "/mockServiceWorker.js" },
|
||||
quiet: true,
|
||||
});
|
||||
workerStarted = true;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Developer Docs fixtures and the types api/docs.ts shares with them.
|
||||
* api/docs.ts imports the types; the MSW handlers in mocks/handlers/ serve the
|
||||
* fixture data over the intercepted apiClient.local.json() calls. Components never reach
|
||||
* into this module directly.
|
||||
* Developer Docs fixtures. Types live in api/docs.ts (the backend contract);
|
||||
* this module only builds fake data for Storybook and tests.
|
||||
*
|
||||
* Two payloads back the surface:
|
||||
* - the left-hand nav tree (`buildDocsNav`), and
|
||||
@@ -12,37 +10,25 @@
|
||||
*
|
||||
* Rate limits scale with plan: free is throttled hard, pro lifts the ceiling,
|
||||
* enterprise is negotiated ("Custom"). The rest of the content is tier-neutral.
|
||||
*
|
||||
* Once a real backend exists the MSW handlers stop being registered and these
|
||||
* fixtures can be deleted (or kept as test seeds).
|
||||
*/
|
||||
|
||||
import type { CardAccent } from "@app/ui";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { CodeLang } from "@app/ui";
|
||||
import type {
|
||||
AgentSkill,
|
||||
ApiErrorRow,
|
||||
CodeSample,
|
||||
DocsContent,
|
||||
DocsNavSection,
|
||||
EmbedComponent,
|
||||
Playbook,
|
||||
RateLimit,
|
||||
Sdk,
|
||||
} from "@portal/api/docs";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Navigation */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A leaf entry in the docs nav — maps 1:1 to a content section. */
|
||||
export interface DocsNavItem {
|
||||
/** Stable id used as the in-page section anchor. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional badge shown to the right of the label (e.g. "New", "Beta"). */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/** A top-level grouping in the docs nav tree. */
|
||||
export interface DocsNavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Single-glyph icon shown beside the section header. */
|
||||
icon: string;
|
||||
items: DocsNavItem[];
|
||||
}
|
||||
|
||||
export function buildDocsNav(): DocsNavSection[] {
|
||||
return [
|
||||
{
|
||||
@@ -96,79 +82,6 @@ export function buildDocsNav(): DocsNavSection[] {
|
||||
/* Reference content */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** One tab in a multi-language code snippet. */
|
||||
export interface CodeSample {
|
||||
/** Stable key used as the snippet tab id. */
|
||||
key: string;
|
||||
label: string;
|
||||
lang: CodeLang;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Per-tier request ceilings rendered by the rate-limits section. */
|
||||
export interface RateLimit {
|
||||
rpm: string;
|
||||
burst: string;
|
||||
concurrency: string;
|
||||
}
|
||||
|
||||
/** A single HTTP status row in the error table. */
|
||||
export interface ApiErrorRow {
|
||||
code: string;
|
||||
/** Severity colour — amber for recoverable, red for hard failures. */
|
||||
tone: "amber" | "red";
|
||||
meaning: string;
|
||||
}
|
||||
|
||||
export type SdkStatus = "ga" | "beta" | "deprecated";
|
||||
|
||||
/** An official client library in the SDK matrix. */
|
||||
export interface Sdk {
|
||||
name: string;
|
||||
/** Single-glyph icon shown beside the name. */
|
||||
icon: string;
|
||||
install: string;
|
||||
lang: CodeLang;
|
||||
status: SdkStatus;
|
||||
}
|
||||
|
||||
/** An embeddable UI component in the drop-in viewer library. */
|
||||
export interface EmbedComponent {
|
||||
name: string;
|
||||
blurb: string;
|
||||
/** Stack tag, e.g. "React" or "Web". */
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** A copy-paste, end-to-end pipeline recipe. */
|
||||
export interface Playbook {
|
||||
title: string;
|
||||
blurb: string;
|
||||
/** Ordered stages rendered as a chip flow. */
|
||||
steps: string[];
|
||||
accent: CardAccent;
|
||||
}
|
||||
|
||||
/** A bundled, named agent capability — a deterministic op chain. */
|
||||
export interface AgentSkill {
|
||||
name: string;
|
||||
blurb: string;
|
||||
/** Op chain shown as a mono string, e.g. "extract · validate". */
|
||||
ops: string;
|
||||
}
|
||||
|
||||
/** The complete data-driven docs payload for one tier. */
|
||||
export interface DocsContent {
|
||||
quickstartSamples: CodeSample[];
|
||||
quickstartResponse: string;
|
||||
rateLimit: RateLimit;
|
||||
errors: ApiErrorRow[];
|
||||
sdks: Sdk[];
|
||||
components: EmbedComponent[];
|
||||
playbooks: Playbook[];
|
||||
skills: AgentSkill[];
|
||||
}
|
||||
|
||||
const QUICKSTART_SAMPLES: CodeSample[] = [
|
||||
{
|
||||
key: "curl",
|
||||
|
||||
@@ -11,136 +11,20 @@
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { ChipAccent, StatusTone } from "@app/ui";
|
||||
import type {
|
||||
DocAuditEvent,
|
||||
DocAuditKind,
|
||||
DocumentStatus,
|
||||
DocumentsResponse,
|
||||
DocumentsSummary,
|
||||
ProductType,
|
||||
ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Domain types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type DocumentStatus = "processed" | "flagged" | "in-review" | "error";
|
||||
|
||||
/** Which Stirling product ran the operation. */
|
||||
export type ProductType = "API" | "Editor";
|
||||
|
||||
/** A single field pulled out of the document by extraction. */
|
||||
export interface Extraction {
|
||||
field: string;
|
||||
value: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export type DocAuditKind =
|
||||
| "ingested"
|
||||
| "extracted"
|
||||
| "flagged"
|
||||
| "reviewed"
|
||||
| "approved"
|
||||
| "archived"
|
||||
| "elevation";
|
||||
|
||||
/** One event in a document's lifecycle, newest last. */
|
||||
export interface DocAuditEvent {
|
||||
id: string;
|
||||
kind: DocAuditKind;
|
||||
time: string;
|
||||
actor: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ReviewDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
/** File-type label, e.g. "PDF". */
|
||||
type: string;
|
||||
/** Auto-classification label (e.g. "Contract"), or null when not classified. */
|
||||
classification: string | null;
|
||||
/** True when the classification was assigned automatically. */
|
||||
auto: boolean;
|
||||
/** Short descriptive sub-line (editor action or flag reason), or null. */
|
||||
note: string | null;
|
||||
/** Where it was processed. */
|
||||
product: ProductType;
|
||||
/** Pipeline/action, e.g. "contract". Null (or Editor product) renders "Editor". */
|
||||
action: string | null;
|
||||
/** The user who ran it. */
|
||||
user: string;
|
||||
status: DocumentStatus;
|
||||
/** Reviewer name for in-review docs, e.g. "Sarah K.". */
|
||||
reviewer: string | null;
|
||||
/** Originating source name. */
|
||||
source: string;
|
||||
/** Overall confidence 0..1, or null (unsupported - never shown in the table). */
|
||||
confidence: number | null;
|
||||
fieldsExtracted: number;
|
||||
/** Relative-time string, e.g. "2 min ago". */
|
||||
time: string;
|
||||
sensitive: boolean;
|
||||
extractions: Extraction[];
|
||||
audit: DocAuditEvent[];
|
||||
}
|
||||
|
||||
export interface DocumentsSummary {
|
||||
totalInQueue: number;
|
||||
processed: number;
|
||||
errors: number;
|
||||
processedToday: number;
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
summary: DocumentsSummary;
|
||||
documents: ReviewDocument[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (label + chip tone) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const DOCUMENT_STATUS_LABEL: Record<DocumentStatus, string> = {
|
||||
processed: "Processed",
|
||||
flagged: "Needs Review",
|
||||
"in-review": "In Review",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_TONE: Record<DocumentStatus, StatusTone> = {
|
||||
processed: "success",
|
||||
flagged: "warning",
|
||||
"in-review": "purple",
|
||||
error: "danger",
|
||||
};
|
||||
|
||||
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
|
||||
API: "brand",
|
||||
Editor: "success",
|
||||
};
|
||||
|
||||
/** Classification chip accent: danger when unclassified, warning when it needs a look. */
|
||||
export function classificationTone(doc: ReviewDocument): ChipAccent {
|
||||
if (doc.classification === "Unclassified") return "danger";
|
||||
if (doc.status === "processed") return "success";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export const DOC_AUDIT_LABEL: Record<DocAuditKind, string> = {
|
||||
ingested: "Ingested",
|
||||
extracted: "Processed",
|
||||
flagged: "Needs Review",
|
||||
reviewed: "In Review",
|
||||
approved: "Approved",
|
||||
archived: "Archived",
|
||||
elevation: "Elevation",
|
||||
};
|
||||
|
||||
export const DOC_AUDIT_TONE: Record<DocAuditKind, StatusTone> = {
|
||||
ingested: "info",
|
||||
extracted: "success",
|
||||
flagged: "warning",
|
||||
reviewed: "purple",
|
||||
approved: "success",
|
||||
archived: "neutral",
|
||||
elevation: "purple",
|
||||
};
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixture builder */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -13,143 +13,13 @@
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deployment targets */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Where an Editor deployment can run. */
|
||||
export type TargetKind = "cloud" | "docker" | "kubernetes";
|
||||
|
||||
/**
|
||||
* Whether a target is usable on the current tier and, if so, whether the org
|
||||
* has actually stood it up. `locked` targets render an upgrade nudge instead of
|
||||
* a runnable snippet.
|
||||
*/
|
||||
export type TargetState = "running" | "available" | "locked";
|
||||
|
||||
export interface DeploymentTarget {
|
||||
kind: TargetKind;
|
||||
label: string;
|
||||
/** One-line positioning shown under the title. */
|
||||
tagline: string;
|
||||
state: TargetState;
|
||||
/** Minimum tier that unlocks this target — drives the upgrade nudge copy. */
|
||||
requiresTier: Tier;
|
||||
/** Install / run snippet for the target's CodeBlock. */
|
||||
snippet: string;
|
||||
/** Language hint for the CodeBlock chrome. */
|
||||
snippetLang: "bash" | "plain";
|
||||
/** Populated only when `state === "running"`. */
|
||||
runningVersion?: string;
|
||||
/** Count of instances currently reporting in for this target. */
|
||||
instanceCount?: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Pairing */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** How a self-hosted editor connects itself to the org. */
|
||||
export type PairingMethod = "token" | "shortcode" | "iac";
|
||||
|
||||
export interface PairingOption {
|
||||
method: PairingMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Minimum tier that unlocks this method. */
|
||||
requiresTier: Tier;
|
||||
/**
|
||||
* The current secret/handle to display. A long-lived pairing token, a
|
||||
* TV-style short code, or an IaC reference (e.g. a Terraform module input).
|
||||
* Pre-masked for token display — never carries the real secret.
|
||||
*/
|
||||
value: string;
|
||||
/** Short codes expire fast; tokens rotate on demand. Relative-time string. */
|
||||
expires?: string;
|
||||
/** Whether this option is currently usable on the active tier. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Running instances (deployment health) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type InstanceStatus = "healthy" | "degraded" | "offline" | "pairing";
|
||||
|
||||
export interface EditorInstance {
|
||||
id: string;
|
||||
/** Human host label, e.g. "edge-fra-01" or "Managed Cloud (us-east-1)". */
|
||||
host: string;
|
||||
target: TargetKind;
|
||||
version: string;
|
||||
region: string;
|
||||
status: InstanceStatus;
|
||||
/** Relative-time string, e.g. "12s ago". */
|
||||
lastSeen: string;
|
||||
activeUsers: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Summary metric strip */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DeploymentSummaryMetric {
|
||||
label: string;
|
||||
value: string | number;
|
||||
delta?: number;
|
||||
deltaDirection?: "up" | "down" | "flat";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DeploymentSummary {
|
||||
metrics: DeploymentSummaryMetric[];
|
||||
/** Masked service token + its rotation age, shown by the rotation card. */
|
||||
serviceToken: { masked: string; lastRotated: string };
|
||||
/** Air-gapped activation is enterprise-only; gate the card on this flag. */
|
||||
offlineActivationAvailable: boolean;
|
||||
/** Where users launch the Editor — the org workspace URL (Open in browser). */
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
export interface EditorDeploymentResponse {
|
||||
summary: DeploymentSummary;
|
||||
targets: DeploymentTarget[];
|
||||
pairings: PairingOption[];
|
||||
instances: EditorInstance[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (lives client-side — product copy, not data) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TargetMeta {
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple";
|
||||
}
|
||||
|
||||
export const TARGET_META: Record<TargetKind, TargetMeta> = {
|
||||
cloud: { icon: "☁", tone: "blue" },
|
||||
docker: { icon: "▣", tone: "neutral" },
|
||||
kubernetes: { icon: "⎈", tone: "purple" },
|
||||
};
|
||||
|
||||
export const INSTANCE_STATUS_TONE: Record<
|
||||
InstanceStatus,
|
||||
"success" | "warning" | "danger" | "info" | "neutral"
|
||||
> = {
|
||||
healthy: "success",
|
||||
degraded: "warning",
|
||||
offline: "danger",
|
||||
pairing: "info",
|
||||
};
|
||||
|
||||
export const INSTANCE_STATUS_LABEL: Record<InstanceStatus, string> = {
|
||||
healthy: "Healthy",
|
||||
degraded: "Degraded",
|
||||
offline: "Offline",
|
||||
pairing: "Pairing",
|
||||
};
|
||||
import type {
|
||||
DeploymentSummary,
|
||||
DeploymentTarget,
|
||||
EditorDeploymentResponse,
|
||||
EditorInstance,
|
||||
PairingOption,
|
||||
} from "@portal/api/editorDeploy";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Snippet builders */
|
||||
|
||||
@@ -8,7 +8,6 @@ import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
|
||||
import { procurementHandlers } from "@portal/mocks/handlers/procurement";
|
||||
import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas";
|
||||
import { docsHandlers } from "@portal/mocks/handlers/docs";
|
||||
import { settingsHandlers } from "@portal/mocks/handlers/settings";
|
||||
import { usersHandlers } from "@portal/mocks/handlers/users";
|
||||
import { agentsHandlers } from "@portal/mocks/handlers/agents";
|
||||
import { policiesHandlers } from "@portal/mocks/handlers/policies";
|
||||
@@ -28,7 +27,6 @@ export const handlers = [
|
||||
...docsHandlers,
|
||||
...procurementHandlers,
|
||||
...procurementSaasHandlers,
|
||||
...settingsHandlers,
|
||||
...usersHandlers,
|
||||
...agentsHandlers,
|
||||
...policiesHandlers,
|
||||
@@ -38,32 +36,5 @@ export const handlers = [
|
||||
...linkHandlers,
|
||||
];
|
||||
|
||||
/**
|
||||
* The handlers safe to run when the portal shares an origin with the editor.
|
||||
* Three groups are excluded because their routes overlap endpoints the editor
|
||||
* itself calls, so mocking them breaks the host app:
|
||||
* - authHandlers: /api/v1/auth/*, /api/v1/proprietary/ui-data/login (logs the
|
||||
* editor out; the portal uses the editor's real session instead)
|
||||
* - policiesHandlers + pipelinesHandlers: both /api/v1/policies* (the editor's
|
||||
* own policies feature)
|
||||
* Everything kept is portal-only. `handlers` above is still the full set.
|
||||
*/
|
||||
export const embeddedDataHandlers = [
|
||||
...notificationsHandlers,
|
||||
...assistantHandlers,
|
||||
...searchHandlers,
|
||||
...sourcesHandlers,
|
||||
...infrastructureHandlers,
|
||||
...docsHandlers,
|
||||
...procurementHandlers,
|
||||
...settingsHandlers,
|
||||
...usersHandlers,
|
||||
...agentsHandlers,
|
||||
...documentsHandlers,
|
||||
...sdkComponentsHandlers,
|
||||
...editorDeployHandlers,
|
||||
...linkHandlers,
|
||||
];
|
||||
|
||||
export { resetNotificationsStore } from "@portal/mocks/handlers/notifications";
|
||||
export { resetProcurementStore } from "@portal/mocks/handlers/procurement";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { LinkInstanceRequest } from "@portal/api/link";
|
||||
import {
|
||||
getLocalStatus,
|
||||
getLocalUsage,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
listInstances,
|
||||
revokeInstance,
|
||||
unlinkLocal,
|
||||
type LinkInstanceRequest,
|
||||
} from "@portal/mocks/link";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { NOTIFICATIONS, type Notification } from "@portal/mocks/notifications";
|
||||
import type { Notification } from "@portal/api/notifications";
|
||||
import { NOTIFICATIONS } from "@portal/mocks/notifications";
|
||||
|
||||
let store: Notification[] = [...NOTIFICATIONS];
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import {
|
||||
seedPolicies,
|
||||
seedPolicyRuns,
|
||||
type WirePolicy,
|
||||
} from "@portal/mocks/policies";
|
||||
import type { PolicyRunView } from "@app/policies/types";
|
||||
import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies";
|
||||
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
|
||||
|
||||
/**
|
||||
* The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the
|
||||
|
||||
@@ -2,10 +2,12 @@ import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import {
|
||||
JOURNEY,
|
||||
buildProcurement,
|
||||
seedEnterpriseDeal,
|
||||
type DealStage,
|
||||
type ProcurementResponse,
|
||||
} from "@portal/api/procurement";
|
||||
import {
|
||||
buildProcurement,
|
||||
seedEnterpriseDeal,
|
||||
} from "@portal/mocks/procurement";
|
||||
import {
|
||||
advanceDeal,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { buildSettingsSnapshot } from "@portal/mocks/settings";
|
||||
|
||||
export const settingsHandlers = [
|
||||
http.get("/v1/settings", async ({ request }) => {
|
||||
await delay(120);
|
||||
const url = new URL(request.url);
|
||||
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
|
||||
return HttpResponse.json(buildSettingsSnapshot(tier));
|
||||
}),
|
||||
];
|
||||
@@ -13,45 +13,29 @@
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditSummary,
|
||||
ComplianceAttestation,
|
||||
ComplianceCert,
|
||||
DeploymentRegion,
|
||||
IpAllowEntry,
|
||||
KeyManagement,
|
||||
ModelEntry,
|
||||
ModelsResponse,
|
||||
RecentDeployment,
|
||||
RoutingRule,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
StorageProvider,
|
||||
} from "@portal/api/infrastructure";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deployments */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RegionStatus = "healthy" | "degraded" | "down";
|
||||
|
||||
export interface DeploymentRegion {
|
||||
name: string;
|
||||
code: string;
|
||||
/** Median request latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Current load as a fraction of provisioned capacity (0–1). */
|
||||
load: number;
|
||||
status: RegionStatus;
|
||||
/** Deployed Stirling engine version. */
|
||||
version: string;
|
||||
/** 30-day uptime as a fraction (0–1). */
|
||||
uptime: number;
|
||||
/** Running instance count. */
|
||||
instances: number;
|
||||
/** Sustained throughput, docs/min. */
|
||||
throughput: number;
|
||||
/** P99 latency, ms. */
|
||||
p99Ms: number;
|
||||
}
|
||||
|
||||
export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued";
|
||||
|
||||
export interface RecentDeployment {
|
||||
id: string;
|
||||
version: string;
|
||||
environment: "production" | "staging" | "canary";
|
||||
product: string;
|
||||
status: DeploymentStatus;
|
||||
deployedBy: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const REGION_US_EAST: DeploymentRegion = {
|
||||
name: "US East (N. Virginia)",
|
||||
code: "us-east-1",
|
||||
@@ -178,25 +162,6 @@ export function recentDeploymentsFor(tier: Tier): RecentDeployment[] {
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
usageToday: number;
|
||||
usageMonth: number;
|
||||
}
|
||||
|
||||
const API_KEYS_ALL: ApiKey[] = [
|
||||
{
|
||||
id: "key-1",
|
||||
@@ -262,77 +227,6 @@ export function apiKeysFor(tier: Tier): ApiKey[] {
|
||||
/* Security */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AccessPolicy = "stirling" | "byok" | "hyok";
|
||||
export type DataResidency = "us" | "eu" | "apac";
|
||||
export type CertStatus = "certified" | "in-progress" | "not-started";
|
||||
|
||||
export interface ComplianceCert {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CertStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface IpAllowEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
cidr: string;
|
||||
addedBy: string;
|
||||
added: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is
|
||||
* surfaced separately because the key *custody model* (who can decrypt) is the
|
||||
* detail security teams scrutinise:
|
||||
* - `managed` — Stirling-owned KMS keys; zero key ops on the customer side.
|
||||
* - `byok` — customer key, but Stirling can use it to decrypt while processing.
|
||||
* - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext.
|
||||
*/
|
||||
export type KeyMode = "managed" | "byok" | "hyok";
|
||||
|
||||
export interface KeyManagement {
|
||||
mode: KeyMode;
|
||||
/** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */
|
||||
provider: string;
|
||||
/** ARN-style identifier for the active key. */
|
||||
keyId: string;
|
||||
/** Encryption algorithm in force. */
|
||||
algorithm: string;
|
||||
/** Relative last-rotation time, e.g. "32 days ago". */
|
||||
lastRotated: string;
|
||||
/** Rotation cadence summary, e.g. "Automatic · every 90 days". */
|
||||
rotationPolicy: string;
|
||||
/**
|
||||
* Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed
|
||||
* tiers see the posture but cannot change provider — only enterprise can.
|
||||
*/
|
||||
customerManaged: boolean;
|
||||
}
|
||||
|
||||
export type AttestationStatus = "attested" | "in-scope" | "not-applicable";
|
||||
|
||||
export interface ComplianceAttestation {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Framework family / short descriptor shown under the name. */
|
||||
framework: string;
|
||||
status: AttestationStatus;
|
||||
/** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */
|
||||
detail: string;
|
||||
/** Stub link to the downloadable report; null when none is available. */
|
||||
reportUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
accessPolicy: AccessPolicy;
|
||||
dataResidency: DataResidency;
|
||||
certs: ComplianceCert[];
|
||||
ipAllowlist: IpAllowEntry[];
|
||||
keyManagement: KeyManagement;
|
||||
attestations: ComplianceAttestation[];
|
||||
}
|
||||
|
||||
const CERTS_FULL: ComplianceCert[] = [
|
||||
{
|
||||
id: "soc2",
|
||||
@@ -559,26 +453,6 @@ export function securityFor(tier: Tier): SecurityConfig {
|
||||
/* Storage */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RetentionWindow = "30" | "60" | "90" | "180" | "never";
|
||||
|
||||
export interface StorageProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "stirling" | "s3" | "azure";
|
||||
connected: boolean;
|
||||
detail: string;
|
||||
usedGb: number;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
/** Total used storage, GB. */
|
||||
usedGb: number;
|
||||
/** Quota ceiling, GB. */
|
||||
quotaGb: number;
|
||||
retention: RetentionWindow;
|
||||
providers: StorageProvider[];
|
||||
}
|
||||
|
||||
const PROVIDERS_FULL: StorageProvider[] = [
|
||||
{
|
||||
id: "stirling",
|
||||
@@ -635,26 +509,6 @@ export function storageFor(tier: Tier): StorageConfig {
|
||||
/* Audit Logs */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AuditCategory =
|
||||
| "auth"
|
||||
| "config"
|
||||
| "elevation"
|
||||
| "processing"
|
||||
| "security";
|
||||
|
||||
export type AuditStatus = "success" | "warning" | "danger" | "info";
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
category: AuditCategory;
|
||||
action: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
status: AuditStatus;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
// Mirrors what the real backend (PortalInfraAuditService) returns: audit_events
|
||||
// mapped from real AuditEventType values to the tab's categories. Only real
|
||||
// types appear - there is no audited "elevation" event, so that category is
|
||||
@@ -842,77 +696,10 @@ const AUDIT_EVENTS_ALL: AuditEvent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export interface AuditSummary {
|
||||
totalEvents: number;
|
||||
processing: number;
|
||||
elevation: number;
|
||||
config: number;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
summary: AuditSummary;
|
||||
events: AuditEvent[];
|
||||
/** True for the whole-server (admin) view; gates the admin-only CSV export. */
|
||||
fullServer: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Models */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem";
|
||||
export type ModelType = "extraction" | "classification" | "ocr" | "llm";
|
||||
export type ModelStatus = "active" | "degraded" | "disabled";
|
||||
|
||||
/** Whether a model's cost is billed per 1k documents or per individual call. */
|
||||
export type ModelCostUnit = "per-1k-docs" | "per-call";
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
type: ModelType;
|
||||
status: ModelStatus;
|
||||
/** Median inference latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Cost in USD for the model's billing unit (see {@link costUnit}). */
|
||||
cost: number;
|
||||
costUnit: ModelCostUnit;
|
||||
version: string;
|
||||
/** Share of capacity this model is currently absorbing (0–1). */
|
||||
load: number;
|
||||
/** True for customer-registered bring-your-own / on-prem models. */
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
/** A binding from a processing operation (optionally a doc-type) to a model. */
|
||||
export interface RoutingRule {
|
||||
id: string;
|
||||
/** The operation or pipeline stage this rule governs. */
|
||||
operation: string;
|
||||
/** Doc-type scope, or "All document types" for a catch-all. */
|
||||
docType: string;
|
||||
/** id of the {@link ModelEntry} this operation routes to. */
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
/** Marks the fallback rule applied when no narrower rule matches. */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsSummary {
|
||||
activeModels: number;
|
||||
/** Capacity-weighted average latency across active models, ms. */
|
||||
avgLatencyMs: number;
|
||||
/** Projected monthly model spend, USD. */
|
||||
monthlySpend: number;
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
summary: ModelsSummary;
|
||||
models: ModelEntry[];
|
||||
routing: RoutingRule[];
|
||||
}
|
||||
|
||||
const MODELS_ALL: ModelEntry[] = [
|
||||
{
|
||||
id: "m-extract-v3",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Account-link fixtures and the types api/link.ts shares with them.
|
||||
* Account-link fixtures. Types live in api/link.ts (the backend contract);
|
||||
* this module only builds fake data for Storybook and tests.
|
||||
*
|
||||
* "Mode A" combined billing: a self-hosted instance links the org's SaaS account
|
||||
* so its unattended calls bill against the org wallet. Two surfaces:
|
||||
@@ -11,61 +12,17 @@
|
||||
* - TEAM-WIDE management: the SaaS backend (`GET /instances`,
|
||||
* `POST /instances/{id}/revoke`), called with the admin's JWT.
|
||||
*
|
||||
* api/link.ts imports the types; the MSW handlers in mocks/handlers/link.ts serve
|
||||
* this fixture data over the intercepted apiClient.local.json() calls. Components never reach
|
||||
* into this module directly. Once the real backend is wired the handlers stop
|
||||
* being registered and these fixtures can be deleted (or kept as test seeds).
|
||||
* The MSW handlers in mocks/handlers/link.ts serve this fixture data over the
|
||||
* intercepted apiClient.local.json() calls. Components never reach into this
|
||||
* module directly. Once the real backend is wired the handlers stop being
|
||||
* registered and these fixtures can be deleted (or kept as test seeds).
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Local backend — link / status / unlink (this instance) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */
|
||||
export interface LinkInstanceRequest {
|
||||
/** Admin's SaaS session JWT, obtained via the hosted-login popup. */
|
||||
supabaseJwt: string;
|
||||
/** Optional label for this instance. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Link status for this instance (GET /api/v1/account-link/status). */
|
||||
export interface LinkStatus {
|
||||
linked: boolean;
|
||||
/** Display name the local backend stored at link time; null when unset. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage).
|
||||
* The portal adds this on top of the SaaS-synced spend so "current usage"
|
||||
* includes work done since the last daily sync. Per-category unsynced units for
|
||||
* the current period; all zero when metering is off or nothing is pending.
|
||||
*/
|
||||
export interface LocalUsage {
|
||||
/** ISO timestamp of the current period start; null when unknown (not yet synced). */
|
||||
periodStart: string | null;
|
||||
apiUnsyncedUnits: number;
|
||||
aiUnsyncedUnits: number;
|
||||
automationUnsyncedUnits: number;
|
||||
totalUnsyncedUnits: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* SaaS backend — team-wide instance management */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A linked instance row (GET /api/v1/account-link/instances). */
|
||||
export interface LinkedInstanceRow {
|
||||
instanceId: number;
|
||||
deviceId: string;
|
||||
name: string | null;
|
||||
/** ISO timestamp the instance was registered. */
|
||||
createdAt: string | null;
|
||||
/** ISO timestamp the instance last presented its credential; null if never. */
|
||||
lastSeenAt: string | null;
|
||||
revoked: boolean;
|
||||
}
|
||||
import type {
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
LocalUsage,
|
||||
} from "@portal/api/link";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Mock store — link/unlink/revoke mutate this so the surface feels live */
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
/** Mock notifications for the header dropdown. */
|
||||
/**
|
||||
* Notification fixtures for the header dropdown. Types live in
|
||||
* api/notifications.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*/
|
||||
|
||||
export type NotificationCategory =
|
||||
| "pipeline"
|
||||
| "deploy"
|
||||
| "billing"
|
||||
| "audit"
|
||||
| "agent"
|
||||
| "doc";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
category: NotificationCategory;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Relative-time string. */
|
||||
time: string;
|
||||
}
|
||||
import type { Notification } from "@portal/api/notifications";
|
||||
|
||||
export const NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
|
||||
@@ -1,362 +1,11 @@
|
||||
/**
|
||||
* Policies fixtures and the canonical TS model the portal shares with them.
|
||||
*
|
||||
* Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec
|
||||
* layer and match the backend record exactly. Catalogue and UI types
|
||||
* (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only:
|
||||
* the backend has no "category" concept — `categoryId` rides in
|
||||
* `output.options`. The catalogue assembles client-side in `api/policies.ts`
|
||||
* from the decoded wire records + these static definitions.
|
||||
*
|
||||
* api/policies.ts re-exports everything; components never reach in here.
|
||||
* Policies fixtures. The canonical TS model and the static catalogue
|
||||
* definitions live in api/policies.ts (the backend contract); this module
|
||||
* only builds seed data for the MSW handlers and tests.
|
||||
*/
|
||||
|
||||
import type { WirePipelineStep, WirePolicy } from "@app/policies/types";
|
||||
import type { PolicyRunView } from "@app/policies/types";
|
||||
|
||||
export type {
|
||||
PolicyActivityItem,
|
||||
PolicyDecodedState,
|
||||
PolicyRunStatus,
|
||||
PolicyRunView,
|
||||
PolicyStats,
|
||||
WireOutputOptions,
|
||||
WireOutputSpec,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Catalogue model — portal-specific (lifted from editor types/policies.ts) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type PolicyStatus = "active" | "paused";
|
||||
|
||||
export type PolicyRowStatus = "active" | "paused" | "setup";
|
||||
|
||||
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
|
||||
|
||||
export interface PolicyField {
|
||||
label: string;
|
||||
key: string;
|
||||
type: PolicyFieldType;
|
||||
value: boolean | string | string[];
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
||||
desc: string;
|
||||
providesClassification?: boolean;
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyConfigDef {
|
||||
summary: string;
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
configured: boolean;
|
||||
status: PolicyStatus;
|
||||
sources: string[];
|
||||
scopeTypes: string[];
|
||||
reviewerEmail: string;
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
outputMode?: "new_file" | "new_version";
|
||||
outputName?: string;
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||
runOn?: "upload" | "export";
|
||||
maxRetries?: number;
|
||||
retryDelayMinutes?: number;
|
||||
backendId?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicySetupResult {
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
sources: string[];
|
||||
scopeTypes: string[];
|
||||
reviewerEmail: string;
|
||||
outputMode: "new_file" | "new_version";
|
||||
outputName: string;
|
||||
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
||||
runOn: "upload" | "export";
|
||||
maxRetries: number;
|
||||
retryDelayMinutes: number;
|
||||
steps: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface DecoratedPolicy {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
state: PolicyState;
|
||||
steps: WirePipelineStep[];
|
||||
stats: import("@app/policies/types").PolicyStats;
|
||||
activity: import("@app/policies/types").PolicyActivityItem[];
|
||||
}
|
||||
|
||||
export interface PoliciesSummary {
|
||||
active: number;
|
||||
paused: number;
|
||||
categories: number;
|
||||
docsEnforced: number;
|
||||
}
|
||||
|
||||
export interface PoliciesResponse {
|
||||
summary: PoliciesSummary;
|
||||
catalogue: CatalogueEntry[];
|
||||
}
|
||||
|
||||
export interface CatalogueEntry {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
policy: DecoratedPolicy | null;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tool → endpoint registry */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
redact: "/api/v1/security/auto-redact",
|
||||
sanitize: "/api/v1/security/sanitize-pdf",
|
||||
watermark: "/api/v1/security/add-watermark",
|
||||
ocr: "/api/v1/misc/ocr-pdf",
|
||||
flatten: "/api/v1/misc/flatten",
|
||||
compress: "/api/v1/misc/compress-pdf",
|
||||
};
|
||||
|
||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||
"/api/v1/security/auto-redact": "Redact PII",
|
||||
"/api/v1/security/sanitize-pdf": "Remove JavaScript",
|
||||
"/api/v1/security/add-watermark": "Watermark",
|
||||
"/api/v1/misc/ocr-pdf": "OCR",
|
||||
"/api/v1/misc/flatten": "Flatten",
|
||||
"/api/v1/misc/compress-pdf": "Compress",
|
||||
};
|
||||
|
||||
export function humanizeEndpoint(path: string): string {
|
||||
if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path];
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Catalogue definitions */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const DEFAULT_PII_PATTERNS: string[] = [
|
||||
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
|
||||
"\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b",
|
||||
];
|
||||
|
||||
export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "Ingestion",
|
||||
icon: "layers",
|
||||
tone: "blue",
|
||||
desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
|
||||
providesClassification: true,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: "shield",
|
||||
tone: "purple",
|
||||
desc: "Detect PII, redact, strip active content, and watermark documents.",
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
label: "Compliance",
|
||||
icon: "check",
|
||||
tone: "amber",
|
||||
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "routing",
|
||||
label: "Routing",
|
||||
icon: "route",
|
||||
tone: "green",
|
||||
desc: "Auto-route documents to the right team, folder, or system.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "retention",
|
||||
label: "Retention",
|
||||
icon: "clock",
|
||||
tone: "neutral",
|
||||
desc: "Set how long documents are kept, when to archive, and when to delete.",
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
ingestion: {
|
||||
summary:
|
||||
"Classifies documents, extracts structured data, enforces naming, and normalizes pages.",
|
||||
rules: ["Classify", "Extract", "Name", "Normalize"],
|
||||
scopeLabel: "All documents",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "Min confidence",
|
||||
key: "minConfidence",
|
||||
type: "select",
|
||||
value: "80%",
|
||||
options: ["60%", "70%", "80%", "90%", "95%"],
|
||||
},
|
||||
{
|
||||
label: "Below threshold",
|
||||
key: "belowThreshold",
|
||||
type: "select",
|
||||
value: "Flag for review",
|
||||
options: ["Flag for review", "Route to bucket", "Hold"],
|
||||
},
|
||||
],
|
||||
},
|
||||
security: {
|
||||
summary:
|
||||
"Detects and redacts PII, strips active content (JavaScript), and watermarks documents.",
|
||||
rules: ["Redact PII", "Remove JavaScript", "Watermark"],
|
||||
scopeLabel: "All documents",
|
||||
defaultOperations: [
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
parameters: {
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.sanitize,
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: false,
|
||||
removeMetadata: false,
|
||||
removeLinks: false,
|
||||
removeFonts: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.watermark,
|
||||
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||
parameters: {
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
compliance: {
|
||||
summary:
|
||||
"Validates documents against regulatory frameworks before they leave the system.",
|
||||
rules: ["Framework scan", "Enforce action", "Audit trail"],
|
||||
scopeLabel: "All documents",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "Frameworks",
|
||||
key: "frameworks",
|
||||
type: "chips",
|
||||
value: ["HIPAA"],
|
||||
options: ["HIPAA", "GDPR", "SOC 2", "FedRAMP", "PCI DSS", "ISO 27001"],
|
||||
},
|
||||
{
|
||||
label: "When non-compliant",
|
||||
key: "onViolation",
|
||||
type: "select",
|
||||
value: "Flag for review",
|
||||
options: [
|
||||
"Flag for review",
|
||||
"Block export",
|
||||
"Auto-redact PHI",
|
||||
"Quarantine document",
|
||||
],
|
||||
},
|
||||
{ label: "Audit trail", key: "auditTrail", type: "toggle", value: true },
|
||||
{ label: "Access log", key: "accessLog", type: "toggle", value: true },
|
||||
],
|
||||
},
|
||||
routing: {
|
||||
summary:
|
||||
"Routes documents to the right destination based on type and classification.",
|
||||
rules: ["Auto-classify", "Route to folder", "Webhook notify"],
|
||||
scopeLabel: "All documents",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "Destination",
|
||||
key: "destination",
|
||||
type: "select",
|
||||
value: "Documents",
|
||||
options: ["Documents", "S3 bucket", "SharePoint", "Webhook"],
|
||||
},
|
||||
{ label: "Webhook URL", key: "webhookUrl", type: "text", value: "" },
|
||||
{ label: "Notify on route", key: "notify", type: "toggle", value: false },
|
||||
],
|
||||
},
|
||||
retention: {
|
||||
summary:
|
||||
"Enforces how long documents are kept, when to archive, and when to delete.",
|
||||
rules: ["Retention hold", "Auto-archive", "Deletion block"],
|
||||
scopeLabel: "All documents",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "Keep for",
|
||||
key: "keepFor",
|
||||
type: "select",
|
||||
value: "7 years",
|
||||
options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"],
|
||||
},
|
||||
{
|
||||
label: "Archive after",
|
||||
key: "archiveAfter",
|
||||
type: "select",
|
||||
value: "Never",
|
||||
options: ["30 days", "90 days", "1 year", "Never"],
|
||||
},
|
||||
{
|
||||
label: "Immutable hold",
|
||||
key: "immutableHold",
|
||||
type: "toggle",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const POLICY_DOC_TYPES: string[] = [
|
||||
"Contracts",
|
||||
"Invoices",
|
||||
"Tax documents",
|
||||
"HR records",
|
||||
"Insurance",
|
||||
"Medical / PHI",
|
||||
"Legal filings",
|
||||
"Financial reports",
|
||||
];
|
||||
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
|
||||
import { POLICY_CONFIG } from "@portal/api/policies";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Seed data — real backend wire format */
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* Lightweight preference helpers — pulled out of mocks/browser.ts so they
|
||||
* don't drag MSW + every handler + every fixture into any chunk that just
|
||||
* needs to *read* the user's choice. Loading the actual worker stays a
|
||||
* dynamic import.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "stirling.portal.mocks-enabled";
|
||||
|
||||
export function readMocksPreference(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// An explicit user toggle (persisted) always wins.
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
// Build-time default: VITE_PORTAL_MOCKS forces mocks on/off. The single-origin
|
||||
// proxy sets it false so the portal hits the real backend (otherwise the dev
|
||||
// mock worker would seed a fake token over the shared real one). Falls back to
|
||||
// on-in-dev, off-in-production.
|
||||
const envDefault = import.meta.env.VITE_PORTAL_MOCKS;
|
||||
if (envDefault === "true") return true;
|
||||
if (envDefault === "false") return false;
|
||||
return import.meta.env.DEV;
|
||||
}
|
||||
|
||||
export function writeMocksPreference(enabled: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(STORAGE_KEY, String(enabled));
|
||||
}
|
||||
@@ -1,194 +1,21 @@
|
||||
/**
|
||||
* Procurement fixtures and the types api/procurement.ts shares with them.
|
||||
* api/procurement.ts imports the types; the MSW handlers in
|
||||
* mocks/handlers/procurement.ts serve this fixture data over the intercepted
|
||||
* httpJson() calls. Components never reach into this module directly.
|
||||
* Procurement fixtures. Types and the journey definition live in
|
||||
* api/procurement.ts (the backend contract); this module only builds the fake
|
||||
* deal data the MSW handlers in mocks/handlers/procurement.ts serve over the
|
||||
* intercepted httpJson() calls, for Storybook and tests.
|
||||
*
|
||||
* Procurement models the enterprise commercial journey, trial → quote →
|
||||
* agreement → payment → implementation, plus the paperwork ledger that rides
|
||||
* alongside it. The journey is enterprise-only; free/pro tiers receive a
|
||||
* minimal locked payload the view renders as an upgrade prompt.
|
||||
*
|
||||
* Once a real commercial backend exists the MSW handlers stop being registered
|
||||
* and these fixtures can be deleted (or kept as test seeds).
|
||||
* The handlers serve Storybook and tests, so these fixtures stay in sync with
|
||||
* the api contract for as long as those need them.
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Journey stages */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The five-stage enterprise journey. The id is the contract value the backend
|
||||
* advances; the labels below are the buyer-facing stage names (Agreement and
|
||||
* Payment read more plainly than the internal `security` / `procurement`).
|
||||
*/
|
||||
export type DealStage =
|
||||
| "trial"
|
||||
| "quote"
|
||||
| "security"
|
||||
| "procurement"
|
||||
| "active";
|
||||
|
||||
export interface JourneyStep {
|
||||
stage: DealStage;
|
||||
/** Buyer-facing stage name. */
|
||||
label: string;
|
||||
/** One-line description of what happens at this stage. */
|
||||
blurb: string;
|
||||
/**
|
||||
* Label for the single action that advances this stage. The current stage
|
||||
* surfaces its gating action; `active` is terminal (provisioning).
|
||||
*/
|
||||
gatingAction: string;
|
||||
}
|
||||
|
||||
/** Ordered journey definition, the stepper renders this verbatim. */
|
||||
export const JOURNEY: JourneyStep[] = [
|
||||
{
|
||||
stage: "trial",
|
||||
label: "Trial",
|
||||
blurb: "Evaluate Stirling against your documents and workflows.",
|
||||
gatingAction: "Build your quote",
|
||||
},
|
||||
{
|
||||
stage: "quote",
|
||||
label: "Quote",
|
||||
blurb: "Review committed-volume pricing and contract term.",
|
||||
gatingAction: "Accept your quote",
|
||||
},
|
||||
{
|
||||
stage: "security",
|
||||
label: "Agreement",
|
||||
blurb: "One signature covers MSA, order form, EULA and DPA.",
|
||||
gatingAction: "Review and sign your agreement",
|
||||
},
|
||||
{
|
||||
stage: "procurement",
|
||||
label: "Payment",
|
||||
blurb: "Pay by card, bank transfer, or against a purchase order.",
|
||||
gatingAction: "Confirm payment",
|
||||
},
|
||||
{
|
||||
stage: "active",
|
||||
label: "Implementation",
|
||||
blurb: "Provision your workspace and run the go-live playbook.",
|
||||
gatingAction: "Provisioning your workspace",
|
||||
},
|
||||
];
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deal header */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SolutionsEngineer {
|
||||
name: string;
|
||||
title: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface TrialInfo {
|
||||
/** License key seeded for the evaluation. */
|
||||
key: string;
|
||||
/** ISO date the trial began. */
|
||||
startedOn: string;
|
||||
/** ISO date the trial expires. */
|
||||
endsOn: string;
|
||||
/** Whole days remaining (derived in the fixture for a stable demo number). */
|
||||
daysLeft: number;
|
||||
extensionsUsed: number;
|
||||
maxExtensions: number;
|
||||
}
|
||||
|
||||
export interface QuoteInfo {
|
||||
number: string;
|
||||
/** Annual contract value, in USD. */
|
||||
amount: number;
|
||||
/** Contract term, e.g. "12 months". */
|
||||
term: string;
|
||||
/** ISO date the quote expires. */
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
export interface Deal {
|
||||
company: string;
|
||||
currentStage: DealStage;
|
||||
engineer: SolutionsEngineer;
|
||||
trial: TrialInfo;
|
||||
quote: QuoteInfo;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Document ledger + supporting pool */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Lifecycle of a single document.
|
||||
* available: ready to grab now (download/sign/pay/upload as the action says)
|
||||
* action: waiting on the buyer to act (the gating paperwork of a stage)
|
||||
* pending: issued, awaiting the other side / a system step
|
||||
* request: not generated yet; the buyer asks for it (some carry a fee)
|
||||
* complete: done, kept for the record
|
||||
*/
|
||||
export type DocStatus =
|
||||
| "available"
|
||||
| "action"
|
||||
| "pending"
|
||||
| "request"
|
||||
| "complete";
|
||||
|
||||
/** What pressing the document's button does. */
|
||||
export type DocAction = "download" | "sign" | "pay" | "upload" | "request";
|
||||
|
||||
export interface LedgerDoc {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Sub-line describing what the document is / what it covers. */
|
||||
sub: string;
|
||||
status: DocStatus;
|
||||
action: DocAction;
|
||||
/** Buyer-skippable paperwork (e.g. paid onboarding). */
|
||||
optional?: boolean;
|
||||
/** One-off fee in USD when the document/service is a paid add-on. */
|
||||
fee?: number;
|
||||
}
|
||||
|
||||
/** Document ledger grouped by the journey stage the paperwork belongs to. */
|
||||
export interface LedgerGroup {
|
||||
stage: DealStage;
|
||||
/** Buyer-facing stage name (matches JourneyStep.label). */
|
||||
label: string;
|
||||
docs: LedgerDoc[];
|
||||
}
|
||||
|
||||
/** Categories the stage-agnostic supporting pool is grouped under. */
|
||||
export type SupportingCategory =
|
||||
| "security"
|
||||
| "legal"
|
||||
| "corporate"
|
||||
| "procurement";
|
||||
|
||||
export interface SupportingGroup {
|
||||
category: SupportingCategory;
|
||||
label: string;
|
||||
docs: LedgerDoc[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Full procurement payload */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface ProcurementResponse {
|
||||
tier: Tier;
|
||||
/** True only for enterprise, gates the whole journey + ledger. */
|
||||
unlocked: boolean;
|
||||
/** Present only when unlocked. */
|
||||
deal: Deal | null;
|
||||
journey: JourneyStep[];
|
||||
ledger: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
}
|
||||
import type {
|
||||
Deal,
|
||||
LedgerGroup,
|
||||
ProcurementResponse,
|
||||
SupportingGroup,
|
||||
} from "@portal/api/procurement";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixtures */
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type LedgerDoc,
|
||||
type LedgerGroup,
|
||||
type SupportingGroup,
|
||||
} from "@portal/mocks/procurement";
|
||||
} from "@portal/api/procurement";
|
||||
|
||||
export interface ProcurementStore {
|
||||
deal: Deal;
|
||||
|
||||
@@ -1,126 +1,24 @@
|
||||
/**
|
||||
* Components surface fixtures and the types api/sdkComponents.ts shares with them.
|
||||
* Components surface fixtures. Types and presentation metadata live in
|
||||
* api/sdkComponents.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*
|
||||
* "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops
|
||||
* into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel —
|
||||
* each metered per action (per render, per review, per signature). Every
|
||||
* component carries its npm package, maturity, supported frameworks, per-action
|
||||
* price, an install/usage snippet, and its key props.
|
||||
*
|
||||
* api/sdkComponents.ts imports the types; the MSW handlers serve the fixture
|
||||
* data over the intercepted apiClient.local.json() calls. Components never reach into this
|
||||
* module directly. Once a real backend exists the handlers stop being
|
||||
* registered and these fixtures can be deleted (or kept as test seeds).
|
||||
*/
|
||||
|
||||
import { isUnlocked } from "@portal/api/sdkComponents";
|
||||
import type {
|
||||
ComponentPricing,
|
||||
ComponentsResponse,
|
||||
ComponentsSummary,
|
||||
SdkComponent,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ComponentMaturity = "ga" | "beta";
|
||||
|
||||
export type Framework = "React" | "Vue" | "Vanilla";
|
||||
|
||||
/** The action a component bills against — surfaces in the price unit label. */
|
||||
export type BillingUnit =
|
||||
| "render"
|
||||
| "review"
|
||||
| "approval"
|
||||
| "signature"
|
||||
| "check"
|
||||
| "event"
|
||||
| "session";
|
||||
|
||||
export interface ComponentProp {
|
||||
name: string;
|
||||
/** TypeScript-ish type expression, shown verbatim in the API table. */
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ComponentPricing {
|
||||
/** Price per billed action in USD. */
|
||||
pricePerAction: number;
|
||||
unit: BillingUnit;
|
||||
/** Free-tier monthly allowance before metering kicks in; 0 = none. */
|
||||
freeQuota: number;
|
||||
}
|
||||
|
||||
export interface SdkComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Package suffix — full name is `@stirling/<package>`. */
|
||||
package: string;
|
||||
description: string;
|
||||
maturity: ComponentMaturity;
|
||||
frameworks: Framework[];
|
||||
pricing: ComponentPricing;
|
||||
/** Install command (npm). */
|
||||
install: string;
|
||||
/** Minimal usage snippet shown under the Code tab. */
|
||||
usage: string;
|
||||
props: ComponentProp[];
|
||||
/**
|
||||
* Embeds attributed to this component over the trailing 30 days — drives the
|
||||
* per-card usage line. Zero for never-embedded components.
|
||||
*/
|
||||
embeds30d: number;
|
||||
/**
|
||||
* Tier at which the component becomes available. Components above the active
|
||||
* tier render locked with an upgrade nudge. `pro` is the default floor.
|
||||
*/
|
||||
minTier: Tier;
|
||||
}
|
||||
|
||||
export interface ComponentsSummary {
|
||||
/** Count of GA (production-ready) components available to the tier. */
|
||||
gaCount: number;
|
||||
/** Count of Beta components available to the tier. */
|
||||
betaCount: number;
|
||||
/** Total embeds across all components this month. */
|
||||
embedsThisMonth: number;
|
||||
/** Month-to-date spend attributed to component actions, in USD. */
|
||||
spendThisMonth: number;
|
||||
}
|
||||
|
||||
export interface ComponentsResponse {
|
||||
summary: ComponentsSummary;
|
||||
components: SdkComponent[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (client-side product copy, not data) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface MaturityMeta {
|
||||
label: string;
|
||||
tone: "success" | "info";
|
||||
}
|
||||
|
||||
export const MATURITY_META: Record<ComponentMaturity, MaturityMeta> = {
|
||||
ga: { label: "GA", tone: "success" },
|
||||
beta: { label: "Beta", tone: "info" },
|
||||
};
|
||||
|
||||
/** Human label for a billing unit, e.g. "render" → "/render". */
|
||||
export const BILLING_UNIT_LABEL: Record<BillingUnit, string> = {
|
||||
render: "render",
|
||||
review: "review",
|
||||
approval: "approval",
|
||||
signature: "signature",
|
||||
check: "check",
|
||||
event: "event",
|
||||
session: "session",
|
||||
};
|
||||
|
||||
/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */
|
||||
export function formatPrice(pricing: ComponentPricing): string {
|
||||
return `$${pricing.pricePerAction.toFixed(2)} / ${BILLING_UNIT_LABEL[pricing.unit]}`;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixtures */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -463,8 +361,6 @@ export function MergeButton({ files }: { files: File[] }) {
|
||||
/* Tier shaping */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TIER_RANK: Record<Tier, number> = { free: 0, pro: 1, enterprise: 2 };
|
||||
|
||||
/**
|
||||
* Enterprise negotiates volume pricing — renders and reviews come in cheaper.
|
||||
* Applied as a flat per-tier multiplier so the catalogue stays single-sourced.
|
||||
@@ -486,11 +382,6 @@ export function componentsFor(tier: Tier): SdkComponent[] {
|
||||
return CATALOGUE.map((c) => ({ ...c, pricing: priceFor(c, tier) }));
|
||||
}
|
||||
|
||||
/** Whether a component is usable at the given tier (vs locked/upgrade). */
|
||||
export function isUnlocked(component: SdkComponent, tier: Tier): boolean {
|
||||
return TIER_RANK[tier] >= TIER_RANK[component.minTier];
|
||||
}
|
||||
|
||||
export function summaryFor(tier: Tier): ComponentsSummary {
|
||||
const components = componentsFor(tier);
|
||||
const unlocked = components.filter((c) => isUnlocked(c, tier));
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/** Mock quick-action catalogue for the ⌘K search palette. */
|
||||
/**
|
||||
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
|
||||
* lives in api/search.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*/
|
||||
|
||||
export interface QuickAction {
|
||||
group: "Jump to" | "Create" | "Theme";
|
||||
label: string;
|
||||
/** Keyboard hint shown to the right. */
|
||||
hint: string;
|
||||
}
|
||||
import type { QuickAction } from "@portal/api/search";
|
||||
|
||||
export const QUICK_ACTIONS: QuickAction[] = [
|
||||
{ group: "Jump to", label: "Home", hint: "G H" },
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Account-settings fixtures and the types api/settings.ts shares with them.
|
||||
* api/settings.ts imports the types; the MSW handlers in mocks/handlers/ serve
|
||||
* the fixture data over the intercepted apiClient.local.json() call. Components never reach
|
||||
* into this module directly.
|
||||
*
|
||||
* The shape is tier-aware: the workspace plan label, available regions, and
|
||||
* data-residency posture differ by tier, so the modal reflects what each plan
|
||||
* can actually configure.
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export interface RegionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Enterprise-only residency regions are gated below higher tiers. */
|
||||
enterpriseOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationDefault {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** A device/browser with an active session, shown under Admin → Security. */
|
||||
export interface ActiveSession {
|
||||
id: string;
|
||||
device: string;
|
||||
location: string;
|
||||
lastActive: string;
|
||||
/** The session viewing this modal — can't be revoked from here. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-wide authentication posture. SSO/SCIM are enterprise capabilities; lower
|
||||
* tiers see them as locked rows with an upgrade nudge.
|
||||
*/
|
||||
export interface SecuritySettings {
|
||||
mfaEnforced: boolean;
|
||||
ssoEnabled: boolean;
|
||||
scimEnabled: boolean;
|
||||
/** Idle timeout before re-auth, in minutes. */
|
||||
sessionTimeoutMins: number;
|
||||
activeSessions: ActiveSession[];
|
||||
}
|
||||
|
||||
/** An opt-in early-access feature flag. */
|
||||
export interface BetaFeature {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
/** Gated to enterprise — rendered locked below it. */
|
||||
enterpriseOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server snapshot of the account + workspace the modal opens onto. Editable
|
||||
* fields seed local form state; `planLabel` / `seats` are read-only context.
|
||||
*/
|
||||
export interface SettingsSnapshot {
|
||||
profile: {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
/** Avatar image URL, or null to fall back to initials. */
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
workspace: {
|
||||
name: string;
|
||||
region: string;
|
||||
planLabel: string;
|
||||
seats: { used: number; total: number };
|
||||
};
|
||||
/** Per-category notification toggles, server-default on/off. */
|
||||
notifications: NotificationDefault[];
|
||||
regions: RegionOption[];
|
||||
/** Org-wide authentication + session posture (Admin scope). */
|
||||
security: SecuritySettings;
|
||||
/** Opt-in early-access features (Admin scope). */
|
||||
betaFeatures: BetaFeature[];
|
||||
}
|
||||
|
||||
const REGIONS: RegionOption[] = [
|
||||
{ value: "us-east-1", label: "US East (N. Virginia)" },
|
||||
{ value: "us-west-2", label: "US West (Oregon)" },
|
||||
{ value: "eu-west-1", label: "EU West (Ireland)" },
|
||||
{ value: "eu-central-1", label: "EU Central (Frankfurt)" },
|
||||
{
|
||||
value: "ap-southeast-2",
|
||||
label: "Asia Pacific (Sydney)",
|
||||
enterpriseOnly: true,
|
||||
},
|
||||
{ value: "ca-central-1", label: "Canada (Central)", enterpriseOnly: true },
|
||||
];
|
||||
|
||||
const PLAN_LABEL: Record<Tier, string> = {
|
||||
free: "Editor plan",
|
||||
pro: "Processor plan",
|
||||
enterprise: "Enterprise plan",
|
||||
};
|
||||
|
||||
const SEATS: Record<Tier, { used: number; total: number }> = {
|
||||
free: { used: 1, total: 1 },
|
||||
pro: { used: 4, total: 5 },
|
||||
enterprise: { used: 38, total: 50 },
|
||||
};
|
||||
|
||||
const WORKSPACE_NAME: Record<Tier, string> = {
|
||||
free: "My Workspace",
|
||||
pro: "Acme Document Ops",
|
||||
enterprise: "Acme Corp — Global",
|
||||
};
|
||||
|
||||
/** Notification categories shown in Preferences, with sensible per-tier defaults. */
|
||||
function notificationsFor(tier: Tier): NotificationDefault[] {
|
||||
return [
|
||||
{ id: "pipeline-failures", enabled: true },
|
||||
{ id: "pipeline-success", enabled: tier !== "free" },
|
||||
{ id: "usage-alerts", enabled: true },
|
||||
{ id: "weekly-digest", enabled: tier === "free" },
|
||||
{ id: "security-alerts", enabled: true },
|
||||
{ id: "product-updates", enabled: false },
|
||||
];
|
||||
}
|
||||
|
||||
/** Session timeout shortens as the plan's security posture tightens. */
|
||||
const SESSION_TIMEOUT_MINS: Record<Tier, number> = {
|
||||
free: 1440,
|
||||
pro: 720,
|
||||
enterprise: 480,
|
||||
};
|
||||
|
||||
function securityFor(tier: Tier): SecuritySettings {
|
||||
const base: ActiveSession[] = [
|
||||
{
|
||||
id: "sess-current",
|
||||
device: "Chrome · macOS",
|
||||
location: "London, UK",
|
||||
lastActive: "Active now",
|
||||
current: true,
|
||||
},
|
||||
];
|
||||
if (tier !== "free") {
|
||||
base.push({
|
||||
id: "sess-cli",
|
||||
device: "Stirling CLI · CI runner",
|
||||
location: "eu-west-1",
|
||||
lastActive: "12 min ago",
|
||||
current: false,
|
||||
});
|
||||
}
|
||||
if (tier === "enterprise") {
|
||||
base.push({
|
||||
id: "sess-mobile",
|
||||
device: "Safari · iPhone",
|
||||
location: "London, UK",
|
||||
lastActive: "3 h ago",
|
||||
current: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
// Enterprise tenants enforce MFA + SSO/SCIM org-wide by default.
|
||||
mfaEnforced: tier === "enterprise",
|
||||
ssoEnabled: tier === "enterprise",
|
||||
scimEnabled: tier === "enterprise",
|
||||
sessionTimeoutMins: SESSION_TIMEOUT_MINS[tier],
|
||||
activeSessions: base,
|
||||
};
|
||||
}
|
||||
|
||||
function betaFeaturesFor(tier: Tier): BetaFeature[] {
|
||||
return [
|
||||
{
|
||||
id: "pipeline-canary",
|
||||
label: "Pipeline canary rollouts",
|
||||
description: "Shadow-run a new pipeline version before promoting it.",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "component-sandboxes",
|
||||
label: "Live component sandboxes",
|
||||
description: "Interactive previews for embeddable components.",
|
||||
enabled: tier !== "free",
|
||||
},
|
||||
{
|
||||
id: "agent-evals-v2",
|
||||
label: "Agent evals v2",
|
||||
description: "Richer golden-set scoring with regression diffs.",
|
||||
enabled: tier === "enterprise",
|
||||
enterpriseOnly: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSettingsSnapshot(tier: Tier): SettingsSnapshot {
|
||||
return {
|
||||
profile: {
|
||||
name: "Reece Browne",
|
||||
email: "reece@stirlingpdf.com",
|
||||
role: tier === "enterprise" ? "Org Admin" : "Owner",
|
||||
avatarUrl: null,
|
||||
},
|
||||
workspace: {
|
||||
name: WORKSPACE_NAME[tier],
|
||||
region: tier === "free" ? "us-east-1" : "eu-west-1",
|
||||
planLabel: PLAN_LABEL[tier],
|
||||
seats: SEATS[tier],
|
||||
},
|
||||
notifications: notificationsFor(tier),
|
||||
regions: REGIONS,
|
||||
security: securityFor(tier),
|
||||
betaFeatures: betaFeaturesFor(tier),
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { readMocksPreference } from "@portal/mocks/preference";
|
||||
|
||||
/**
|
||||
* Start the portal's MSW worker if the mocks preference is on. Await this before
|
||||
* rendering PortalApp so the worker is registered before the first data fetch.
|
||||
* The dynamic import keeps MSW and its fixtures out of chunks that don't run it.
|
||||
*/
|
||||
export async function startPortalMocksIfEnabled(): Promise<void> {
|
||||
if (!readMocksPreference()) return;
|
||||
const { startMockWorker } = await import("@portal/mocks/browser");
|
||||
await startMockWorker();
|
||||
}
|
||||
@@ -13,221 +13,18 @@
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
AccessControls,
|
||||
Member,
|
||||
UsersResponse,
|
||||
UsersSummary,
|
||||
} from "@portal/api/users";
|
||||
import { ROLES } from "@portal/api/users";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Roles & members */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The four org roles, most → least privileged, mapped onto the backend's
|
||||
* authorities + team leadership. Order drives the role select and grid. */
|
||||
export type RoleId = "admin" | "team_owner" | "member" | "guest";
|
||||
|
||||
export type MemberStatus = "active" | "invited" | "suspended";
|
||||
|
||||
/**
|
||||
* Effective portal (processor) access for a member:
|
||||
* admin — implicit, admins always have it
|
||||
* role — implicit via team-owner leadership (default policy)
|
||||
* team — inherited from a PORTAL grant on the member's whole team
|
||||
* granted — explicit per-user PORTAL grant
|
||||
* none — no access
|
||||
*/
|
||||
export type PortalAccessState = "admin" | "role" | "team" | "granted" | "none";
|
||||
|
||||
export const PORTAL_ACCESS_TONE: Record<
|
||||
PortalAccessState,
|
||||
"success" | "info" | "neutral" | "warning"
|
||||
> = {
|
||||
admin: "info",
|
||||
role: "info",
|
||||
team: "info",
|
||||
granted: "success",
|
||||
none: "neutral",
|
||||
};
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: RoleId;
|
||||
status: MemberStatus;
|
||||
/** Effective portal access; set by the view from the grant list. */
|
||||
portalAccess?: PortalAccessState;
|
||||
/** Authoritative server-side portal access (roster DTO); drives whether a chip shows at all. */
|
||||
canAccessPortal?: boolean;
|
||||
/** The explicit PORTAL grant's id, for revoke (present when access = granted). */
|
||||
portalGrantId?: number;
|
||||
/** Relative-time string, e.g. "4m ago". Invited members read "—". */
|
||||
lastActive: string;
|
||||
/** Optional avatar image; falls back to initials when absent. */
|
||||
avatarUrl?: string;
|
||||
/** Backend linkage for row actions (absent on pure fixtures). */
|
||||
username?: string;
|
||||
teamId?: number;
|
||||
teamName?: string;
|
||||
/** Holds a LEADER membership on their team (independent of displayed role). */
|
||||
teamLead?: boolean;
|
||||
/** The signed-in admin's own row; self-directed actions are disabled. */
|
||||
isSelf?: boolean;
|
||||
/** Account locked after failed logins (admin can unlock). */
|
||||
locked?: boolean;
|
||||
/** MFA enrolled (admin can reset it). */
|
||||
mfaEnabled?: boolean;
|
||||
/** Auth provider: "web" (password), "oauth2", "saml2", etc. */
|
||||
authType?: string;
|
||||
/** Raw stored authority (e.g. ROLE_USER, ROLE_WEB_ONLY_USER); preserved on team moves. */
|
||||
authority?: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: RoleId;
|
||||
label: string;
|
||||
/** One-line summary of what the role can do. */
|
||||
summary: string;
|
||||
/** Concrete permission bullets shown in the reference grid. */
|
||||
permissions: string[];
|
||||
tone: "purple" | "blue" | "green" | "amber" | "neutral";
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Access controls (tier-scoped) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Access posture for the org, shaped by tier. Free exposes only the seat limit
|
||||
* and an upgrade nudge; pro adds session/MFA self-service; enterprise adds
|
||||
* SSO/SAML, SCIM provisioning, enforced MFA and a session policy. Fields are
|
||||
* optional so the panel renders whatever the tier returns.
|
||||
*/
|
||||
export interface AccessControls {
|
||||
tier: Tier;
|
||||
/** Seats consumed by active + invited members. */
|
||||
seatsUsed: number;
|
||||
/** Total seats on the plan; null = unlimited (enterprise). */
|
||||
seatLimit: number | null;
|
||||
/** Free only: copy for the upgrade nudge. */
|
||||
upgradeHint?: string;
|
||||
/** Pro+: end-user MFA available (self-service, not enforced). */
|
||||
mfaAvailable?: boolean;
|
||||
/** Enterprise: MFA enforced org-wide. */
|
||||
mfaEnforced?: boolean;
|
||||
/** Pro+: idle session timeout, e.g. "30 days" / "12 hours". */
|
||||
sessionTimeout?: string;
|
||||
/** Enterprise: SSO connection summary. */
|
||||
sso?: {
|
||||
provider: string;
|
||||
status: "connected" | "not_configured";
|
||||
/** Email domains that auto-route to SSO. */
|
||||
domains: string[];
|
||||
};
|
||||
/** Enterprise: SCIM directory provisioning. */
|
||||
scim?: {
|
||||
enabled: boolean;
|
||||
/** Where the directory syncs from, e.g. "Okta". */
|
||||
directory: string;
|
||||
lastSync: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsersSummary {
|
||||
totalMembers: number;
|
||||
pendingInvites: number;
|
||||
seatsUsed: number;
|
||||
/** null = unlimited. */
|
||||
seatLimit: number | null;
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
summary: UsersSummary;
|
||||
members: Member[];
|
||||
roles: Role[];
|
||||
access: AccessControls;
|
||||
/** Whether SMTP is configured (gates emailing passwords/invites). */
|
||||
mailEnabled: boolean;
|
||||
/** Whether email invites will work: SMTP on AND mail.enableInvites=true. Gates the
|
||||
* "Invite by email" option on self-hosted. */
|
||||
emailInvitesEnabled: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata — product copy, lives client-side */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const MEMBER_STATUS_TONE: Record<
|
||||
MemberStatus,
|
||||
"success" | "warning" | "danger" | "neutral" | "info"
|
||||
> = {
|
||||
active: "success",
|
||||
invited: "info",
|
||||
suspended: "danger",
|
||||
};
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Role catalogue */
|
||||
/* The same five roles exist on every tier — what varies is who can fill */
|
||||
/* them and how access is enforced, not the role definitions themselves. */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ROLES: Role[] = [
|
||||
{
|
||||
id: "admin",
|
||||
label: "Admin (Org owner)",
|
||||
summary: "Full governance over the workspace, settings and members.",
|
||||
permissions: [
|
||||
"Manage users, teams and roles",
|
||||
"Manage all integrations incl. S3 connections",
|
||||
"Grant or revoke portal access",
|
||||
"Everything Team Owner can do",
|
||||
],
|
||||
tone: "purple",
|
||||
},
|
||||
{
|
||||
id: "team_owner",
|
||||
label: "Team owner",
|
||||
summary: "Owns a team — manages its members' resources and shared configs.",
|
||||
permissions: [
|
||||
"Create & manage the team's S3 connections",
|
||||
"Manage team-owned integration configs",
|
||||
"Portal access via the default policy",
|
||||
"Everything Member can do",
|
||||
],
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
id: "member",
|
||||
label: "Member",
|
||||
summary:
|
||||
"Regular user — works with shared resources and their own configs.",
|
||||
permissions: [
|
||||
"Use the editor and shared integrations",
|
||||
"Create personal API & MCP configs",
|
||||
"See team configs shared with them",
|
||||
"No S3 or workspace management",
|
||||
],
|
||||
tone: "green",
|
||||
},
|
||||
{
|
||||
id: "guest",
|
||||
label: "Guest",
|
||||
summary: "Limited or web-only access; cannot hold personal configs.",
|
||||
permissions: [
|
||||
"Web-only / demo usage",
|
||||
"No API keys or integrations",
|
||||
"No portal access",
|
||||
"Read-only where shared",
|
||||
],
|
||||
tone: "neutral",
|
||||
},
|
||||
];
|
||||
|
||||
export const ROLE_LABEL: Record<RoleId, string> = Object.fromEntries(
|
||||
ROLES.map((r) => [r.id, r.label]),
|
||||
) as Record<RoleId, string>;
|
||||
|
||||
export const ROLE_TONE: Record<RoleId, Role["tone"]> = Object.fromEntries(
|
||||
ROLES.map((r) => [r.id, r.tone]),
|
||||
) as Record<RoleId, Role["tone"]>;
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Member fixtures */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -225,13 +225,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-policies__wizard-subheading {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policies__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -248,17 +241,28 @@
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Workflow step — tool rows */
|
||||
.portal-policies__tool-head {
|
||||
/* Workflow step — policy capability settings list.
|
||||
* Rows read as the policy's own settings (label + plain description + toggle),
|
||||
* not a chain of distinct tools. Config reveals inline beneath an enabled row. */
|
||||
.portal-policies__capabilities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-policies__tool-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
.portal-policies__capability {
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.portal-policies__capability + .portal-policies__capability {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* An enabled row with config gets a faint tint so the revealed settings read as
|
||||
* belonging to it. */
|
||||
.portal-policies__capability[data-on] .portal-policies__capability-config {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Sources picker */
|
||||
@@ -284,11 +288,32 @@
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
/* Shared Button pins a fixed control height and natural width; this tile is a
|
||||
full-width, two-line card, so fill the grid cell and grow to fit content. */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
font-weight: inherit;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
/* The shared Button wraps children in an inner/label node. Let the inner grow
|
||||
(so the ::after check sits at the far right) and the label carry the
|
||||
icon + two-line-text row. */
|
||||
.portal-policies__source .mantine-Button-inner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__source .mantine-Button-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.portal-policies__source::after {
|
||||
content: "";
|
||||
display: flex;
|
||||
@@ -353,22 +378,6 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Doc-type scope */
|
||||
.portal-policies__doctypes-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-policies__doctypes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-policies__link {
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
-2
@@ -11,8 +11,6 @@ interface ImportMetaEnv {
|
||||
readonly VITE_STRIPE_PUBLISHABLE_KEY: string;
|
||||
/** URL of the editor app (app switcher + non-admin redirect). See editor/.env.proprietary. */
|
||||
readonly VITE_EDITOR_URL: string;
|
||||
/** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */
|
||||
readonly VITE_PORTAL_MOCKS: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -90,24 +90,8 @@
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* Policy icons are colourless at rest; hovering or focusing the row reveals the
|
||||
category colour (blue/purple/green/amber/red — see ROW_ACCENT). The accent
|
||||
class sets --ib-base; we neutralise --ib-accent here and restore it on hover. */
|
||||
.pol-row .sui-iconbadge {
|
||||
--ib-accent: var(--color-text-3);
|
||||
transition:
|
||||
color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
.pol-row:hover .sui-iconbadge,
|
||||
.pol-row:focus-visible .sui-iconbadge {
|
||||
--ib-accent: var(--ib-base);
|
||||
}
|
||||
|
||||
/* Processing indicator: a spinning ring around the category icon while the
|
||||
policy has runs in flight. The ring carries the category accent; the badge
|
||||
also shows its colour (not the neutral rest tint) so an active policy reads
|
||||
clearly even before hover. */
|
||||
policy has runs in flight. */
|
||||
.pol-row-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MultiSelect } from "@mantine/core";
|
||||
import { MultiSelect } from "@app/ui/MultiSelect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
|
||||
@@ -49,8 +49,8 @@ export function PolicyPiiField({
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
size="sm"
|
||||
label={t("policies.pii.fieldLabel", "PII to redact")}
|
||||
inputSize="sm"
|
||||
aria-label={t("policies.pii.fieldLabel", "PII to redact")}
|
||||
placeholder={t("policies.pii.placeholder", "Select PII types")}
|
||||
data={PII_PRESETS.map((p) => ({
|
||||
value: p.value,
|
||||
@@ -62,7 +62,6 @@ export function PolicyPiiField({
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
clearable
|
||||
checkIconPosition="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -696,7 +696,7 @@ export function PolicySetupWizard({
|
||||
: [...prev, dt],
|
||||
)
|
||||
}
|
||||
label={dt}
|
||||
label={t(`policies.docType.${dt}`, dt)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,10 @@ interface PolicyWatermarkConfigProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watermark configuration for a policy: the full watermark settings minus the
|
||||
* "Flatten PDF pages to images" toggle (hidden), with flatten forced on so the
|
||||
* watermark is baked into the page and can't be stripped out. Normalised once
|
||||
* on mount.
|
||||
* Watermark configuration for a policy: text watermarks only (the type selector
|
||||
* and image option are hidden), minus the "Flatten PDF pages to images" toggle
|
||||
* (hidden), with flatten forced on so the watermark is baked into the page and
|
||||
* can't be stripped out. Normalised once on mount.
|
||||
*/
|
||||
export function PolicyWatermarkConfig({
|
||||
parameters,
|
||||
@@ -20,9 +20,11 @@ export function PolicyWatermarkConfig({
|
||||
disabled,
|
||||
}: PolicyWatermarkConfigProps) {
|
||||
useEffect(() => {
|
||||
if (parameters.convertPDFToImage !== true) {
|
||||
onChange({ ...parameters, convertPDFToImage: true });
|
||||
}
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
|
||||
// Policies only support text watermarks.
|
||||
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
|
||||
if (Object.keys(patch).length > 0) onChange({ ...parameters, ...patch });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -33,6 +35,7 @@ export function PolicyWatermarkConfig({
|
||||
}
|
||||
disabled={disabled}
|
||||
showFlatten={false}
|
||||
textOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,19 @@ export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
|
||||
setup: "Set up",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-category accent colour
|
||||
*/
|
||||
/** Per-category icon accent — neutral (no tint background) across all categories. */
|
||||
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
ingestion: "neutral",
|
||||
security: "neutral",
|
||||
compliance: "neutral",
|
||||
routing: "neutral",
|
||||
retention: "neutral",
|
||||
};
|
||||
|
||||
/** Per-category colour for the file badges + enforcement overlay. Separate from
|
||||
* ROW_ACCENT: the sidebar rows render neutral by design, but the badges keep
|
||||
* their identity colours so files remain distinguishable at a glance. */
|
||||
const BADGE_ACCENT: Record<string, string> = {
|
||||
ingestion: "blue",
|
||||
classification: "orange",
|
||||
security: "purple",
|
||||
@@ -30,15 +39,9 @@ export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* CSS colour var for a policy category's accent (blue for unknown categories) —
|
||||
* the tint used by the file badges and the enforcement overlay.
|
||||
*
|
||||
* Derived straight from the accent name (`--color-<accent>`), which is exactly
|
||||
* the token {@link IconBadge} uses for the same accent. Deriving it (rather than
|
||||
* keeping a second name→var map) means the badge tint can never drift from the
|
||||
* sidebar's colour — previously `orange` was missing from that map, so the
|
||||
* Classification badge/overlay rendered untinted while its sidebar row was orange.
|
||||
* CSS colour var for a policy category's badge accent (blue for unknown
|
||||
* categories) — the tint used by the file badges and the enforcement overlay.
|
||||
*/
|
||||
export function policyAccentVar(categoryId: string): string {
|
||||
return `var(--color-${ROW_ACCENT[categoryId] ?? "blue"})`;
|
||||
return `var(--color-${BADGE_ACCENT[categoryId] ?? "blue"})`;
|
||||
}
|
||||
|
||||
@@ -9,16 +9,12 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
// GHA when the portal or AI layers change). Vite replaces the env with a literal at
|
||||
// build time, so when it's off the dynamic import below is tree-shaken out and the
|
||||
// portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on
|
||||
// each render. Mocks start first so the worker is ready before the portal's first
|
||||
// fetch.
|
||||
// each render.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
const PortalApp = includePortal
|
||||
? lazy(async () => {
|
||||
const { startPortalMocksIfEnabled } =
|
||||
await import("@portal/mocks/startIfEnabled");
|
||||
await startPortalMocksIfEnabled();
|
||||
const m = await import("@portal/PortalApp");
|
||||
return { default: m.PortalApp };
|
||||
})
|
||||
|
||||
@@ -9,7 +9,10 @@ import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import Overview from "@app/components/shared/config/configSections/Overview";
|
||||
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import {
|
||||
NavKey,
|
||||
type ConfigNavSection,
|
||||
} from "@app/components/shared/config/types";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
@@ -21,9 +24,21 @@ import {
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Accepted for interface parity with the core shell; this shell never
|
||||
* URL-syncs, so it has no effect. */
|
||||
urlSync?: boolean;
|
||||
/** Section to land on when opening (used by non-URL hosts like the portal). */
|
||||
initialSection?: NavKey | null;
|
||||
/** Host-specific sections appended after the saas registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
initialSection,
|
||||
extraSections,
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const { signOut, user } = useAuth();
|
||||
@@ -53,16 +68,21 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
// usage-limit modal CTAs, which need to land on the Plan section), select that section. The
|
||||
// opener (QuickAccessBar) opens the modal whenever the path is /settings/*, but doesn't carry
|
||||
// the section, and `active` defaults to "overview" — so without this a deep link would open on
|
||||
// Overview rather than the linked section.
|
||||
// Overview rather than the linked section. Non-URL hosts (the portal) pass the
|
||||
// section directly instead.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (initialSection) {
|
||||
setActive(initialSection);
|
||||
return;
|
||||
}
|
||||
const match = stripBasePath(window.location.pathname).match(
|
||||
/^\/settings\/([^/?#]+)/,
|
||||
);
|
||||
if (match) {
|
||||
setActive(match[1] as NavKey);
|
||||
}
|
||||
}, [opened]);
|
||||
}, [opened, initialSection]);
|
||||
|
||||
// Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
|
||||
useEffect(() => {
|
||||
@@ -116,15 +136,14 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
// Left navigation structure and icons. The Plan tab now internally branches
|
||||
// free vs subscribed × leader vs member via useWallet(), so the modal no
|
||||
// longer plumbs paygEnabled / isLeader through to the nav builder.
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
isDev,
|
||||
isAnonymous,
|
||||
t,
|
||||
}),
|
||||
[openLogoutConfirm, isDev, isAnonymous, t],
|
||||
);
|
||||
const configNavSections = useMemo(() => {
|
||||
const sections = createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
isDev,
|
||||
isAnonymous,
|
||||
t,
|
||||
});
|
||||
return extraSections?.length ? [...sections, ...extraSections] : sections;
|
||||
}, [openLogoutConfirm, isDev, isAnonymous, t, extraSections]);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from "react";
|
||||
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
|
||||
|
||||
// SaaS adds an "overview" account section and an "mcp" integrations tab. All
|
||||
@@ -7,3 +8,21 @@ import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/
|
||||
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview", "mcp"] as const;
|
||||
|
||||
export type NavKey = (typeof VALID_NAV_KEYS)[number];
|
||||
|
||||
// Mirrors the core shape over the widened saas NavKey union — see the core
|
||||
// module for why these live in types rather than configNavSections.
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
badge?: string;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user