Classifier setup as a processor policy (#7012)

## Overview

Adds a **Classification policy** to the processor's policy catalogue,
set up the same way as the Security policy. This moves classifier
configuration out of the editor (where the labels UI landed in #6898 and
was then removed with the rest of the editor's policy-management surface
in #6932) and into the processor, which is now the single place policies
are configured.

## What it does

- **Classification card** in the processor policy catalogue. Always
shown, but **setup is locked until the backend reports the AI engine is
on** — so admins can see the capability they're missing rather than it
being hidden entirely.
- **Setup wizard** mirrors Security: the workflow step shows the team's
**classification label editor** (reused
`LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon
picker, import/export, reset) instead of tool toggles, since classify is
a single non-configurable step.
- On enable, the team's label vocabulary is **seeded with the 268
built-in defaults** (clobber-safe: only when the team has none). On
upload the document is classified against the team's labels and tagged;
on SaaS with the engine on, files group by category in the editor
sidebar.

## Reuse & consolidation

- Reuses the existing labels table, `labelsFile` helpers, and default
vocabulary. Labels read/write through the processor's own
`apiClient.local` (not the editor's axios client) so auth/base routing
stays explicit; the wire shape is shared.
- Consolidates policy-category icons into a shared, **id-keyed**
`policyCategoryIcon` util (outline glyphs) used by both the editor and
the processor, replacing the processor's emoji-glyph map (and the stray
`schedule` key that rendered a bare dot).

## Testing

- `task frontend:typecheck:{core,proprietary,portal}`,
`frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all
green.
- Verified in Storybook: the Classification card renders, the setup
wizard shows the label editor (268 defaults), and the full labels editor
opens with icons/import/export/reset. Added an MSW handler for the
app-config + labels endpoints and a `Classification` wizard story.

## Notes for reviewers

- The AI-engine gate reads the public `/api/v1/config/app-config`;
classification labels use `/api/v1/classification/labels` (team-scoped,
team-lead/admin-gated, `policies.enabled`); the classify step hits
`/api/v1/ai/tools/classify-and-label` — all pre-existing backend from
#6898.
- Known parity behavior (matches the editor hook): a transient failure
loading team labels falls back to showing the defaults; not changed here
to avoid diverging the two hooks.
This commit is contained in:
EthanHealy01
2026-07-15 11:04:30 +00:00
committed by GitHub
parent 0570c4c4d9
commit 7f01bcdc44
61 changed files with 2269 additions and 3156 deletions
@@ -0,0 +1,61 @@
package stirling.software.proprietary.classification;
import java.io.InputStream;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.databind.ObjectMapper;
/**
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
* fixed, built-in list bundled with the application ({@code
* classification/classification-labels.json}) and shared by everyone — there is no per-team
* customization or database. Loaded once at startup.
*/
@Slf4j
@Component
public class ClassificationLabelProvider {
private static final String RESOURCE = "classification/classification-labels.json";
private final List<ClassificationLabel> labels;
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
// can't infer which to use without it.
@Autowired
public ClassificationLabelProvider(ObjectMapper objectMapper) {
this(load(objectMapper));
}
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
this.labels = List.copyOf(labels);
}
/** Build a provider with an explicit label set (tests). */
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
return new ClassificationLabelProvider(labels);
}
/** The built-in vocabulary, in file order. */
public List<ClassificationLabel> labels() {
return labels;
}
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
return parsed.labels();
} catch (Exception e) {
log.error("Failed to load classification labels from {}", RESOURCE, e);
return List.of();
}
}
}
@@ -1,136 +0,0 @@
package stirling.software.proprietary.classification;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.model.LabelsValidator;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
* default on the backend or the engine — the label data lives only in the frontend).
*/
@RestController
@RequestMapping("/api/v1/classification/labels")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ClassificationLabelsController {
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
@GetMapping
@Operation(
summary = "Get the team's classification labels",
description =
"Returns the caller's team label set, or 204 when the team has none (its"
+ " documents are then not classified).")
public ResponseEntity<ClassificationLabels> getTeamLabels() {
return labelStore
.findByTeam(currentTeamId())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Save the team's classification labels",
description =
"Validates and stores the label set for the caller's team, shared by everyone"
+ " on the team. Requires the policy-editor role for the team.")
public ResponseEntity<ClassificationLabels> saveTeamLabels(
@RequestBody ClassificationLabels labels) {
requireEditingAllowed();
validate(labels);
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
return ResponseEntity.ok(saved);
}
@DeleteMapping
@Operation(
summary = "Reset the team's classification labels",
description =
"Removes the team's stored label set; its documents are then not classified"
+ " until labels are saved again. Requires the policy-editor role for the"
+ " team.")
public ResponseEntity<Void> resetTeamLabels() {
requireEditingAllowed();
labelStore.deleteByTeam(currentTeamId());
return ResponseEntity.noContent().build();
}
private static void validate(ClassificationLabels labels) {
try {
LabelsValidator.validate(labels);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Editing the team labels requires the editor role for the caller's team — the same gate
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
* disabled) have no such role, so they trust the local operator.
*/
private void requireEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"The team classification labels may only be changed by a team leader");
}
}
/**
* The caller's team key. With login disabled the single operator owns the {@link
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
* is an error rather than being dropped into the shared sentinel bucket (which would let
* unteamed users read and overwrite each other's "team" labels).
*/
private Long currentTeamId() {
Long teamId = policyManagementAuthority.currentUserTeamId();
if (teamId != null) {
return teamId;
}
if (!applicationProperties.getSecurity().isEnableLogin()) {
return TeamLabelsEntity.NO_TEAM;
}
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
}
private String currentUsername() {
return userService == null ? null : userService.getCurrentUsername();
}
}
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
* neither the backend nor the engine holds a default of its own.
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
* this record is the JSON parse target for that bundled resource.
*/
public record ClassificationLabels(List<ClassificationLabel> labels) {
@@ -1,71 +0,0 @@
package stirling.software.proprietary.classification.model;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
*/
public final class LabelsValidator {
private LabelsValidator() {}
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
// exhaust memory on deserialize.
static final int MAX_LABELS = 500;
static final int MAX_TEXT_LENGTH = 128;
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
// arbitrary
// text that would render as garbage (or worse) in every teammate's sidebar.
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
/**
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
*/
public static void validate(ClassificationLabels labels) {
if (labels == null || labels.labels() == null) {
throw new IllegalArgumentException("Labels are required");
}
if (labels.labels().size() > MAX_LABELS) {
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
}
Set<String> ids = new HashSet<>();
Set<String> names = new HashSet<>();
for (ClassificationLabel label : labels.labels()) {
requireText(label.id(), "Label id");
requireText(label.name(), "Label name");
if (label.icon() != null && !label.icon().isEmpty()) {
if (label.icon().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
if (!ICON_KEY.matcher(label.icon()).matches()) {
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
}
}
if (!ids.add(label.id().trim())) {
throw new IllegalArgumentException("Duplicate label id: " + label.id());
}
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Duplicate label name: " + label.name());
}
}
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
if (value.trim().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
}
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.util.Optional;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
* team.
*/
public interface ClassificationLabelStore {
/** The team's stored labels, or empty when it has none (callers then skip classification). */
Optional<ClassificationLabels> findByTeam(Long teamId);
/** Create or replace the team's labels. Returns the stored value. */
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
/** Remove the team's labels (reset to default). Returns whether a set existed. */
boolean deleteByTeam(Long teamId);
}
@@ -1,36 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
* JpaClassificationLabelStore} is the runtime bean.
*/
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return Optional.ofNullable(byTeam.get(key(teamId)));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
byTeam.put(key(teamId), labels);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
return byTeam.remove(key(teamId)) != null;
}
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -1,76 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.time.Instant;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
* TeamLabelsEntity}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaClassificationLabelStore implements ClassificationLabelStore {
private final TeamLabelsRepository teamRepository;
private final ObjectMapper objectMapper;
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return teamRepository
.findById(key(teamId))
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
TeamLabelsEntity entity = new TeamLabelsEntity();
entity.setTeamId(key(teamId));
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
entity.setUpdatedAt(Instant.now());
entity.setUpdatedBy(updatedBy);
teamRepository.save(entity);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
long id = key(teamId);
if (!teamRepository.existsById(id)) {
return false;
}
teamRepository.deleteById(id);
return true;
}
private Optional<ClassificationLabels> parse(String json, String owner) {
try {
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
} catch (JacksonException e) {
// A stored label set that no longer parses (corruption / manual DB edit) must not break
// classification: drop it so the caller treats the team as having no labels (and skips
// classification) rather than surfacing a 500 on every upload.
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
return Optional.empty();
}
}
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -1,47 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
* not a foreign key — so classification can be enabled or disabled without touching them.
*/
@Entity
@Table(name = "classification_labels")
@NoArgsConstructor
@Getter
@Setter
public class TeamLabelsEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
public static final long NO_TEAM = 0L;
@Id
@Column(name = "team_id")
private long teamId;
@Column(name = "labels_json", columnDefinition = "text")
private String labelsJson;
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "updated_by")
private String updatedBy;
}
@@ -1,7 +0,0 @@
package stirling.software.proprietary.classification.store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
@@ -31,10 +31,9 @@ import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
@@ -46,7 +45,7 @@ import tools.jackson.databind.node.ObjectNode;
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
*
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
* engine to classify the document against the caller's team label set, and stores the engine's JSON
* engine to classify the document against the built-in label set, and stores the engine's JSON
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
* client use.
@@ -72,13 +71,9 @@ public class ClassifyLabelController {
private final UserServiceInterface userService;
/**
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
* team authority are gated on it. Null otherwise, in which case there are no team labels to
* classify against and the document is passed through unlabelled.
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
*/
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ClassificationLabelProvider labelProvider;
public ClassifyLabelController(
CustomPDFDocumentFactory pdfDocumentFactory,
@@ -87,18 +82,16 @@ public class ClassifyLabelController {
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
ClassificationLabelProvider labelProvider,
@Autowired(required = false) UserServiceInterface userService) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.objectMapper = objectMapper;
this.labelProvider = labelProvider;
this.userService = userService;
this.labelStore = labelStore;
this.policyManagementAuthority = policyManagementAuthority;
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -116,9 +109,9 @@ public class ClassifyLabelController {
List<EngineLabel> allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// No vocabulary to classify against (the team stored no labels): pass the file
// through unlabelled rather than ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
// No vocabulary to classify against: pass the file through unlabelled rather than
// ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
@@ -175,24 +168,13 @@ public class ClassifyLabelController {
}
/**
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
* The engine shows the model the names and returns the ids (icons are presentational and never
* sent). Returns an empty list — the caller then skips classification — when the policy
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
* default vocabulary of its own, so a team's stored labels are the only source.
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
* the model the names and returns the ids (icons are presentational and never sent). The engine
* holds no default vocabulary of its own, so this bundled set is the only source.
*/
private List<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
collectLabels(labelProvider.labels(), byId);
return List.copyOf(byId.values());
}
@@ -37,8 +37,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.classification.store"
"stirling.software.proprietary.integration.repository"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -50,8 +49,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.classification.store"
"stirling.software.proprietary.integration.model"
})
public class DatabaseConfig {
File diff suppressed because it is too large Load Diff
@@ -1,133 +0,0 @@
package stirling.software.proprietary.classification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
@ExtendWith(MockitoExtension.class)
@DisplayName("ClassificationLabelsController")
class ClassificationLabelsControllerTest {
private static final Long TEAM = 7L;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private UserServiceInterface userService;
private ClassificationLabelStore store;
private ApplicationProperties applicationProperties;
private ClassificationLabelsController controller;
@BeforeEach
void setUp() {
store = new InProcessClassificationLabelStore();
applicationProperties = new ApplicationProperties();
controller =
new ClassificationLabelsController(
store, policyManagementAuthority, applicationProperties, userService);
}
private static ClassificationLabels sample() {
return new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null)));
}
private void loginEnabled(boolean enabled) {
applicationProperties.getSecurity().setEnableLogin(enabled);
}
@Test
@DisplayName("GET returns 204 when the team has no labels")
void getEmpty() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
ResponseEntity<ClassificationLabels> response = controller.getTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT then GET round-trips the team's labels (login disabled)")
void saveThenGet() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
ResponseEntity<ClassificationLabels> got = controller.getTeamLabels();
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(got.getBody()).isNotNull();
assertThat(got.getBody().labels()).hasSize(2);
assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice");
assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long");
}
@Test
@DisplayName("PUT is scoped per team")
void perTeam() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT is rejected for a non-editor when login is enabled")
void putForbiddenForNonEditor() {
loginEnabled(true);
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.saveTeamLabels(sample()))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("PUT rejects an invalid label set with 400")
void putInvalid() {
loginEnabled(false);
ClassificationLabels duplicate =
new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", null),
new ClassificationLabel("invoice", "Invoice", null)));
assertThatThrownBy(() -> controller.saveTeamLabels(duplicate))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("DELETE resets the team back to no stored labels")
void deleteResets() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTeamLabels(sample());
ResponseEntity<Void> response = controller.resetTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@@ -1,146 +0,0 @@
package stirling.software.proprietary.classification.model;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import java.util.Locale;
import java.util.stream.IntStream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("LabelsValidator")
class LabelsValidatorTest {
private static ClassificationLabels labels(ClassificationLabel... labels) {
return new ClassificationLabels(List.of(labels));
}
private static ClassificationLabel label(String name) {
return new ClassificationLabel(slug(name), name, null);
}
private static String slug(String name) {
return name.trim()
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
}
@Test
@DisplayName("accepts a well-formed label set")
void acceptsValid() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
label("Contract"));
assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException();
}
@Test
@DisplayName("accepts an empty label set (reads as: use the default)")
void acceptsEmpty() {
assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException();
}
@Test
@DisplayName("rejects a null label set")
void rejectsNull() {
assertThatThrownBy(() -> LabelsValidator.validate(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Labels are required");
}
@Test
@DisplayName("rejects duplicate names (distinct ids)")
void rejectsDuplicateNames() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice-a", "Invoice", null),
new ClassificationLabel("invoice-b", "Invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label name");
}
@Test
@DisplayName("rejects duplicate names differing only by case")
void rejectsDuplicateNamesCaseInsensitive() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice-a", "Invoice", null),
new ClassificationLabel("invoice-b", "INVOICE", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label name");
}
@Test
@DisplayName("rejects duplicate ids")
void rejectsDuplicateIds() {
ClassificationLabels set =
labels(
new ClassificationLabel("invoice", "Invoice", null),
new ClassificationLabel("invoice", "Sales invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate label id");
}
@Test
@DisplayName("rejects a blank name")
void rejectsBlankName() {
ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Label name must not be blank");
}
@Test
@DisplayName("rejects a blank id")
void rejectsBlankId() {
ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Label id must not be blank");
}
@Test
@DisplayName("rejects an over-long name")
void rejectsOverLongName() {
ClassificationLabels set =
labels(
new ClassificationLabel(
"x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("too long");
}
@Test
@DisplayName("rejects an over-long icon (a null icon is fine)")
void rejectsOverLongIcon() {
ClassificationLabels set =
labels(
new ClassificationLabel(
"invoice",
"Invoice",
"x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1)));
assertThatThrownBy(() -> LabelsValidator.validate(set))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("icon is too long");
}
@Test
@DisplayName("rejects more labels than the cap")
void rejectsTooManyLabels() {
List<ClassificationLabel> tooMany =
IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS)
.mapToObj(i -> label("label" + i))
.toList();
assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Too many labels");
}
}
@@ -14,7 +14,6 @@ import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -27,10 +26,8 @@ import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
@@ -42,22 +39,16 @@ import tools.jackson.databind.json.JsonMapper;
@MockitoSettings(strictness = Strictness.LENIENT)
class ClassifyLabelControllerTest {
private static final Long TEAM = 7L;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private AiEngineClient aiEngineClient;
@Mock private PolicyManagementAuthority policyManagementAuthority;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private InProcessClassificationLabelStore labelStore;
private ClassifyLabelController controller;
@BeforeEach
void setUp() {
labelStore = new InProcessClassificationLabelStore();
private void withLabels(List<ClassificationLabel> labels) {
controller =
new ClassifyLabelController(
pdfDocumentFactory,
@@ -66,9 +57,8 @@ class ClassifyLabelControllerTest {
pdfMetadataService,
aiEngineClient,
objectMapper,
null,
labelStore,
policyManagementAuthority);
ClassificationLabelProvider.withLabels(labels),
null);
}
private void stubSinglePageDocument() throws Exception {
@@ -98,12 +88,7 @@ class ClassifyLabelControllerTest {
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
labelStore.save(
TEAM,
new ClassificationLabels(
List.of(new ClassificationLabel("invoice", "Invoice", null))),
"admin");
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
stubSinglePageDocument();
@@ -118,16 +103,12 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
labelStore.save(
TEAM,
new ClassificationLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null),
new ClassificationLabel("timesheet", "Timesheet", null))),
"admin");
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
withLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null),
new ClassificationLabel("timesheet", "Timesheet", null)));
stubSinglePageDocument();
@@ -152,13 +133,13 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
withLabels(List.of());
stubSinglePageDocument();
// No team labels stored, and the engine holds no default of its own, so the file is passed
// through unlabelled: neither the engine nor the metadata write is invoked.
// No vocabulary, and the engine holds no default of its own, so the file is passed through
// unlabelled: neither the engine nor the metadata write is invoked.
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
verify(pdfMetadataService, never())
.setClassificationMetadata(any(PDDocument.class), anyString());
@@ -0,0 +1,10 @@
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
-- in V30) is no longer read or written. Drop it.
--
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
-- absent, keeping the migration safe to apply regardless of prior state.
DROP TABLE IF EXISTS classification_labels;
@@ -116,8 +116,8 @@ class DocumentClassifierAgent:
)
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
# The caller (the backend) always supplies the allowed vocabulary — the
# team's stored labels — so the engine holds no vocabulary of its own.
# The caller (the backend) always supplies the allowed vocabulary — its
# fixed built-in label set — so the engine holds no vocabulary of its own.
allowed = request.labels
window = select_window(request.pages)
prompt = self._build_prompt(request.file_name, allowed, window)
@@ -3734,20 +3734,9 @@ uploadToServer = "Upload to server"
versionHistory = "Version history"
[fileSidebar.groupsModal]
add = "Add"
addLabel = "Add a label…"
categoryIconAria = "Choose an icon for {{name}}"
createCategory = "Create category"
delete = "Delete category"
done = "Done"
hide = "Hide category"
newCategory = "New category name…"
removeLabel = "Remove {{name}}"
rename = "Rename"
reset = "Reset to defaults"
search = "Search labels…"
show = "Show category"
subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”."
reset = "Show all"
subtitle = "Show or hide categories in the files sidebar."
title = "Sidebar categories"
[filesPage]
@@ -6014,31 +6003,11 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[policies.labels]
add = "Add"
addPlaceholder = "Add a label…"
customNote = "Customized for your team."
defaultNote = "Using the built-in default, shared with your team."
duplicate = "\"{{name}}\" already exists."
edit = "Edit labels"
empty = "No labels yet."
export = "Export JSON"
iconAria = "Choose an icon for {{name}}"
import = "Import JSON"
importError = "Couldn't import that file."
managedNote = "Team labels are managed by your team leader."
modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document."
modalTitle = "Classification labels"
pickIcon = "Use {{label}} icon"
removeAria = "Remove {{name}}"
resetToDefault = "Reset to default"
saveForTeam = "Save for team"
saving = "Saving…"
sectionLabel = "Classification labels"
startFromScratch = "Start from scratch"
teamCount = "team labels"
tooLong = "Labels can be at most {{max}} characters."
ungrouped = "Ungrouped"
view = "View labels"
categoryCount = "categories"
hideCategory = "Hide category"
labelCount = "labels"
sharedNote = "These labels are built in and shared across your whole team."
showCategory = "Show category"
[policies.pii]
account = "Account numbers (labelled)"
@@ -7451,6 +7420,11 @@ title = "Policies"
[portal.policies.card]
comingSoon = "Upgrade to Enterprise"
notSetUp = "Not set up"
requiresAiEngine = "Requires AI engine"
[portal.policies.categories.classification]
desc = "Identify each document's type on upload and tag its metadata for filing and search."
label = "Classification"
[portal.policies.categories.compliance]
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
@@ -7475,6 +7449,13 @@ label = "Security"
[portal.policies.config]
scopeAll = "All documents"
[portal.policies.config.classification]
summary = "Classifies every uploaded document against your team's labels and tags its metadata."
[portal.policies.config.classification.rules]
0 = "Classify"
1 = "Tag metadata"
[portal.policies.config.compliance]
summary = "Validates documents against regulatory frameworks before they leave the system."
@@ -7569,6 +7550,7 @@ title = "No activity yet"
[portal.policies.endpoints]
addWatermark = "Watermark"
autoRedact = "Redact PII"
classifyAndLabel = "Classify"
compressPdf = "Compress"
flatten = "Flatten"
ocrPdf = "OCR"
@@ -7611,6 +7593,10 @@ continue = "Continue"
enablePolicy = "Enable policy"
saveChanges = "Save changes"
[portal.policies.wizard.capability.classify]
desc = "Identifies the document's type from your team's labels and tags it, so it files and searches by category."
label = "Classify the document"
[portal.policies.wizard.capability.compress]
desc = "Compresses the document to a smaller file size."
label = "Reduce file size"
@@ -7635,6 +7621,10 @@ label = "Strip active content"
desc = "Stamps a visible mark (e.g. “Confidential”) across every page."
label = "Apply a watermark"
[portal.policies.wizard.classification]
description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team."
labelsHeading = "Classification labels"
[portal.policies.wizard.errors]
noTools = "Enable at least one tool in the workflow first."
saveFailed = "Couldn't save the policy. Please try again."
@@ -0,0 +1,36 @@
// Shared source of truth for a policy category's outline icon, keyed by category
// id (not a parallel icon-name vocabulary). Used by the editor's policy
// definitions and the portal's catalogue cards, summaries, and setup wizard.
import type { ReactNode } from "react";
import type { SxProps, Theme } from "@mui/material";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
type MuiIcon = React.ComponentType<{ sx?: SxProps<Theme>; className?: string }>;
/** Policy category id → outline glyph. */
const POLICY_CATEGORY_ICONS: Record<string, MuiIcon> = {
ingestion: LayersOutlinedIcon,
security: ShieldOutlinedIcon,
classification: LabelOutlinedIcon,
compliance: CheckCircleOutlinedIcon,
routing: AltRouteOutlinedIcon,
retention: ScheduleOutlinedIcon,
};
const FALLBACK_ICON = LabelOutlinedIcon;
// Defaults to inheriting the surrounding font-size so a wrapping box controls size.
export function policyCategoryIcon(
categoryId: string,
sx: SxProps<Theme> = { fontSize: "inherit" },
className?: string,
): ReactNode {
const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON;
return <Icon sx={sx} className={className} />;
}
-57
View File
@@ -1,57 +0,0 @@
/* Shared classification-label pill (team labels editor + sidebar category manager). */
.sui-labelchip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.2rem 0.3rem 0.2rem 0.25rem;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--color-surface);
max-width: 16rem;
}
.sui-labelchip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--color-text-2);
}
.sui-labelchip-name {
font-size: 0.8125rem;
color: var(--color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sui-labelchip-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-3);
padding-left: 0.1rem;
}
.sui-labelchip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-labelchip-remove:hover {
background: var(--color-bg-hover);
color: var(--color-red);
}
@@ -1,52 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LabelChip } from "@app/ui/LabelChip";
const meta: Meta<typeof LabelChip> = {
title: "Primitives/LabelChip",
component: LabelChip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { label: "Invoice", icon: "receipt-long" },
argTypes: {
label: { control: "text" },
icon: { control: "text" },
count: { control: "number" },
onRemove: { action: "removed" },
},
};
export default meta;
type Story = StoryObj<typeof LabelChip>;
/** The classification-label pill shared by the labels editor and the sidebar category manager. */
export const Playground: Story = {};
/** With a file count, as shown in the sidebar category manager. */
export const WithCount: Story = {
args: { label: "Contract", icon: "handshake", count: 12 },
};
/** Removable — the trailing × appears when `onRemove` is set. */
export const Removable: Story = {
args: { label: "NDA", icon: "lock", onRemove: () => {} },
};
/** Falls back to the default "sell" icon when none is given. */
export const DefaultIcon: Story = {
args: { label: "Uncategorised label", icon: undefined },
};
/** Long names truncate rather than overflow the pill. */
export const LongName: Story = {
args: { label: "Memorandum of understanding and mutual agreement", count: 3 },
};
export const Row: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
<LabelChip label="Invoice" icon="receipt-long" count={12} />
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
<LabelChip label="Lab report" icon="science" count={2} />
<LabelChip label="Payslip" icon="payments" />
</div>
),
};
-60
View File
@@ -1,60 +0,0 @@
import type { ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import "@app/ui/LabelChip.css";
export interface LabelChipProps {
/** The label text. */
label: string;
/** Material Symbols key for a leading icon; ignored when `leading` is given. */
icon?: string;
/** Custom leading node (e.g. an icon picker), overriding `icon`. */
leading?: ReactNode;
/** Optional trailing count (e.g. how many files carry this label). */
count?: number;
/** Show a trailing `×`; called on click. */
onRemove?: () => void;
/** Accessible name for the remove button. */
removeAriaLabel?: string;
}
/**
* A classification-label pill: leading icon (or a custom control like an icon
* picker) + name, with an optional count and remove button. The shared look for
* every place labels are shown as chips — the team labels editor and the
* sidebar category manager both render this so they stay visually identical.
*/
export function LabelChip({
label,
icon,
leading,
count,
onRemove,
removeAriaLabel,
}: LabelChipProps) {
return (
<span className="sui-labelchip" role="listitem">
{leading ?? (
<span className="sui-labelchip-icon">
<LocalIcon icon={icon || "sell"} width="1rem" />
</span>
)}
<span className="sui-labelchip-name" title={label}>
{label}
</span>
{count != null && count > 0 && (
<span className="sui-labelchip-count">{count}</span>
)}
{onRemove && (
<button
type="button"
className="sui-labelchip-remove"
onClick={onRemove}
aria-label={removeAriaLabel ?? `Remove ${label}`}
>
<CloseIcon sx={{ fontSize: "0.85rem" }} />
</button>
)}
</span>
);
}
+28 -8
View File
@@ -56,11 +56,11 @@ export interface PolicyField {
export interface PolicyCategory {
id: string;
label: string;
icon: string;
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
desc: string;
providesClassification?: boolean;
comingSoon?: boolean;
requiresAiEngine?: boolean;
}
export interface PolicyConfigDef {
@@ -133,14 +133,21 @@ export interface CatalogueEntry {
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
/**
* i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
*/
export const ENDPOINT_LABELS: Partial<
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", 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",
"/api/v1/ai/tools/classify-and-label":
"portal.policies.endpoints.classifyAndLabel",
};
export function humanizeEndpoint(
@@ -170,7 +177,6 @@ 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,
@@ -179,14 +185,20 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "security",
label: "portal.policies.categories.security.label",
icon: "shield",
tone: "purple",
desc: "portal.policies.categories.security.desc",
},
{
id: "classification",
label: "portal.policies.categories.classification.label",
tone: "blue",
desc: "portal.policies.categories.classification.desc",
providesClassification: true,
requiresAiEngine: true,
},
{
id: "compliance",
label: "portal.policies.categories.compliance.label",
icon: "check",
tone: "amber",
desc: "portal.policies.categories.compliance.desc",
comingSoon: true,
@@ -194,7 +206,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "routing",
label: "portal.policies.categories.routing.label",
icon: "route",
tone: "green",
desc: "portal.policies.categories.routing.desc",
comingSoon: true,
@@ -202,7 +213,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "retention",
label: "portal.policies.categories.retention.label",
icon: "schedule",
tone: "neutral",
desc: "portal.policies.categories.retention.desc",
comingSoon: true,
@@ -264,6 +274,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
fields: [],
},
classification: {
summary: "portal.policies.config.classification.summary",
rules: [
"portal.policies.config.classification.rules.0",
"portal.policies.config.classification.rules.1",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("classify")],
fields: [],
},
compliance: {
summary: "portal.policies.config.compliance.summary",
rules: [
@@ -15,7 +15,7 @@ import {
type CatalogueEntry,
type PoliciesResponse,
} from "@portal/api/policies";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import "@portal/components/PolicySummary.css";
/**
@@ -64,7 +64,7 @@ export function PolicySummary() {
render: ({ entry }) => (
<div className="portal-policysum__cat">
<span className="portal-policysum__icon" aria-hidden>
{policyIcon(entry.category.icon)}
{policyCategoryIcon(entry.category.id)}
</span>
<div className="portal-policysum__cat-text">
<strong>{t(entry.category.label)}</strong>
@@ -0,0 +1,53 @@
/* Read-only classification-vocabulary viewer in the policy wizard. */
.classification-summary {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.classification-summary-stats {
display: flex;
gap: var(--space-3);
font-size: 0.8125rem;
color: var(--color-text-2);
}
.classification-summary-note {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* Expandable category → labels list. */
.classification-categories {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.classification-category {
border-top: 1px solid var(--border-default);
}
.classification-category:last-child {
border-bottom: 1px solid var(--border-default);
}
.classification-category-header {
min-height: 2.5rem;
}
.classification-category-lead {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.classification-category-name {
font-weight: 600;
font-size: 0.875rem;
}
.classification-category-count {
font-size: 0.75rem;
color: var(--color-text-3);
}
.classification-category-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
padding: 0 var(--space-2) var(--space-2) 2rem;
}
@@ -0,0 +1,97 @@
// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their
// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but
// the full vocabulary is browsable: expand a category to see the labels it groups.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import { Button, Card, Chip } from "@app/ui";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import {
DEFAULT_CLASSIFICATION_LABELS,
LABEL_FAMILIES,
} from "@app/data/classificationLabels";
import "@portal/components/policies/ClassificationLabelsSection.css";
export function ClassificationLabelsSection() {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<Card>
<div className="classification-summary">
<div className="classification-summary-stats">
<span>
<strong>{DEFAULT_CLASSIFICATION_LABELS.length}</strong>{" "}
{t("policies.labels.labelCount", "labels")}
</span>
<span>
<strong>{LABEL_FAMILIES.length}</strong>{" "}
{t("policies.labels.categoryCount", "categories")}
</span>
</div>
<ul className="classification-categories">
{LABEL_FAMILIES.map((family) => {
const open = expanded.has(family.id);
return (
<li key={family.id} className="classification-category">
<Button
variant="quiet"
fullWidth
justify="between"
className="classification-category-header"
aria-expanded={open}
onClick={() => toggle(family.id)}
leftSection={
<span className="classification-category-lead">
{open ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={family.icon} width="1.1rem" />
<span className="classification-category-name">
{family.name}
</span>
</span>
}
rightSection={
<span className="classification-category-count">
{family.labels.length}
</span>
}
/>
{open && (
<div className="classification-category-labels">
{family.labels.map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{t(`classification.labels.${label.id}`, label.name)}
</Chip>
))}
</div>
)}
</li>
);
})}
</ul>
<span className="classification-summary-note">
{t(
"policies.labels.sharedNote",
"These labels are built in and shared across your whole team.",
)}
</span>
</div>
</Card>
);
}
@@ -1,19 +1,28 @@
import { useTranslation } from "react-i18next";
import { Card, Chip, StatusBadge } from "@app/ui";
import type { CatalogueEntry } from "@portal/api/policies";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import "@portal/views/Policies.css";
interface PolicyCategoryCardProps {
entry: CatalogueEntry;
onOpen: (entry: CatalogueEntry) => void;
/** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */
locked?: boolean;
/** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */
lockedLabel?: string;
}
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
export function PolicyCategoryCard({
entry,
onOpen,
locked = false,
lockedLabel,
}: PolicyCategoryCardProps) {
const { t } = useTranslation();
const { category, config, policy } = entry;
const comingSoon = category.comingSoon === true;
const openable = !comingSoon;
const openable = !comingSoon && !locked;
const status = policy?.state.status;
const enforces = config.rules.map((r) => t(r)).join(" · ");
@@ -21,7 +30,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
<Card
className={
"portal-policies__card" +
(comingSoon ? " portal-policies__card--locked" : "")
(comingSoon || locked ? " portal-policies__card--locked" : "")
}
interactive={openable}
onClick={openable ? () => onOpen(entry) : undefined}
@@ -39,7 +48,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
}
>
<span className="portal-policies__cat-icon" aria-hidden>
{policyIcon(category.icon)}
{policyCategoryIcon(category.id)}
</span>
<div className="portal-policies__card-identity">
@@ -53,6 +62,10 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
) : locked ? (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
) : policy ? (
<div className="portal-policies__card-meta">
<span className="portal-policies__card-statpair">
@@ -8,6 +8,9 @@ import {
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const classification = POLICY_CATEGORIES.find(
(c) => c.id === "classification",
)!;
const meta: Meta<typeof PolicySetupWizard> = {
title: "Portal/Policies/PolicySetupWizard",
@@ -47,3 +50,14 @@ export const Edit: Story = {
},
},
};
/** Classification: the workflow step shows the team label editor, not tool toggles. */
export const Classification: Story = {
args: {
entry: {
category: classification,
config: POLICY_CONFIG.classification,
policy: null,
},
},
};
@@ -1,5 +1,10 @@
import { useMemo, useState } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import CheckIcon from "@mui/icons-material/Check";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined";
import {
Banner,
Button,
@@ -29,12 +34,27 @@ import {
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 { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
import "@portal/views/Policies.css";
/** Outline icon for a source tile, keyed by the backend source `type`. */
function sourceIcon(type: string): ReactNode {
const sx = { fontSize: "1.1rem" } as const;
switch (type) {
case "editor":
return <EditOutlinedIcon sx={sx} />;
case "folder":
return <FolderOutlinedIcon sx={sx} />;
case "s3":
return <CloudOutlinedIcon sx={sx} />;
default:
return <StorageOutlinedIcon sx={sx} />;
}
}
interface PolicySetupWizardProps {
/** The category being configured, or null when closed. */
entry: CatalogueEntry | null;
@@ -119,6 +139,13 @@ const CAPABILITY_META: Record<
descKey: "portal.policies.wizard.capability.compress.desc",
descEn: "Compresses the document to a smaller file size.",
},
classify: {
labelKey: "portal.policies.wizard.capability.classify.label",
labelEn: "Classify the document",
descKey: "portal.policies.wizard.capability.classify.desc",
descEn:
"Identifies the document's type from your team's labels and tags it, so it files and searches by category.",
},
};
function seedTools(entry: CatalogueEntry): ToolState[] {
@@ -179,9 +206,18 @@ function PolicySetupWizardBody({
const { category, config, policy } = entry;
const isEdit = policy != null;
const isClassification = category.id === "classification";
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [tools, setTools] = useState<ToolState[]>(() => {
const seeded = seedTools(entry);
// Classification's single tool has no toggle in the workflow step, so keep it
// enabled unconditionally — otherwise editing a policy whose saved steps
// somehow lack it would strand submit with no way to re-enable it.
return isClassification
? seeded.map((t) => ({ ...t, enabled: true }))
: seeded;
});
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -190,11 +226,25 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
// The editor is always an available source. The backend now returns it as a
// virtual source too, so take that when present (avoids a duplicate tile) and
// otherwise fall back to a synthetic one; keep it first, selected by default.
const editorSource = backendSources.find((s) => s.id === "editor") ?? {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources.filter((s) => s.id !== "editor")];
}, [sourcesAsync.data, t]);
// 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 ?? []);
@@ -285,7 +335,7 @@ function PolicySetupWizardBody({
title={
<span className="portal-policies__wizard-title">
<span className="portal-policies__cat-icon" aria-hidden>
{policyIcon(category.icon)}
{policyCategoryIcon(category.id)}
</span>
{isEdit
? t("portal.policies.wizard.title.edit", {
@@ -349,7 +399,25 @@ function PolicySetupWizardBody({
/>
)}
{step === "workflow" && (
{step === "workflow" && isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
"portal.policies.wizard.classification.description",
"Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team.",
)}
</p>
<h3 className="portal-policies__wizard-heading">
{t(
"portal.policies.wizard.classification.labelsHeading",
"Classification labels",
)}
</h3>
<ClassificationLabelsSection />
</div>
)}
{step === "workflow" && !isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
@@ -448,35 +516,38 @@ function PolicySetupWizardBody({
// The backend always returns the editor as a virtual source, so the
// loaded 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}
variant="quiet"
justify="start"
className={
"portal-policies__source" +
(sources.includes(src.id)
? " portal-policies__source--on"
: "")
}
onClick={() => toggleSource(src.id)}
>
<span className="portal-policies__source-icon" aria-hidden>
{sourceTypeMeta(src.type).icon}
</span>
<span className="portal-policies__source-text">
{availableSources.map((src) => {
const on = sources.includes(src.id);
return (
<Button
key={src.id}
variant={on ? "secondary" : "quiet"}
justify="between"
fullWidth
className={
"portal-policies__source" +
(on ? " portal-policies__source--on" : "")
}
// The check keeps its slot when unselected (hidden) so the
// icon + name stay put whether or not the tile is selected.
rightSection={
<CheckIcon
sx={{
fontSize: "1.1rem",
visibility: on ? "visible" : "hidden",
}}
/>
}
onClick={() => toggleSource(src.id)}
aria-pressed={on}
>
<span className="portal-policies__source-label">
{sourceIcon(src.type)}
{src.name}
</span>
<span className="portal-policies__source-desc">
{src.type}
</span>
</span>
</Button>
))}
</Button>
);
})}
</div>
)}
@@ -1,25 +0,0 @@
/**
* Glyphs for the policy catalogue's string icon keys. The catalogue model
* carries semantic keys (e.g. "shield", "layers") rather than React nodes so
* the data stays portable; the portal owns the rendering and maps each key to a
* glyph here.
*/
export const POLICY_ICON_GLYPHS: Record<string, string> = {
layers: "▤",
shield: "🛡",
check: "✓",
route: "⇉",
clock: "⏲",
// Source icons.
file: "▢",
device: "▣",
globe: "◍",
cloud: "☁",
mail: "✉",
folder: "▤",
};
/** Resolve an icon key to its glyph, falling back to a neutral dot. */
export function policyIcon(key: string): string {
return POLICY_ICON_GLYPHS[key] ?? "•";
}
@@ -0,0 +1,26 @@
// AI-engine flag from the backend's public app-config. Gates Classification
// setup: the card always shows but can't be enabled until the engine is on.
// `loading` lets callers hold the decision rather than flash a locked card.
import { apiClient } from "@portal/api/http";
import { useAsync } from "@portal/hooks/useAsync";
interface AppConfigShape {
aiEngineEnabled?: boolean;
}
export interface AiEngineState {
enabled: boolean;
loading: boolean;
}
export function useAiEngineEnabled(): AiEngineState {
const state = useAsync<AppConfigShape>(
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
[],
);
return {
enabled: Boolean(state.data?.aiEngineEnabled),
loading: state.loading && state.data === null,
};
}
@@ -0,0 +1,10 @@
import { http, HttpResponse } from "msw";
// The Classification policy only needs the app-config `aiEngineEnabled` flag to be
// on; its label vocabulary is a fixed built-in set (no team endpoint anymore).
export const classificationHandlers = [
http.get("/api/v1/config/app-config", () =>
HttpResponse.json({ aiEngineEnabled: true }),
),
];
@@ -12,6 +12,7 @@ import { usersHandlers } from "@portal/mocks/handlers/users";
import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
import { agentsHandlers } from "@portal/mocks/handlers/agents";
import { policiesHandlers } from "@portal/mocks/handlers/policies";
import { classificationHandlers } from "@portal/mocks/handlers/classification";
import { documentsHandlers } from "@portal/mocks/handlers/documents";
import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
@@ -32,6 +33,7 @@ export const handlers = [
...teamSaasHandlers,
...agentsHandlers,
...policiesHandlers,
...classificationHandlers,
...documentsHandlers,
...sdkComponentsHandlers,
...editorDeployHandlers,
+7 -100
View File
@@ -266,116 +266,23 @@
}
/* Sources picker */
/* Selectable source tiles: a vertical stack of full-width shared Buttons
(icon · name · check). Layout is the Button's own leftSection/label/
rightSection — only the selected border/tint is added here. */
.portal-policies__sources {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
@media (max-width: 40rem) {
.portal-policies__sources {
grid-template-columns: 1fr;
}
}
.portal-policies__source {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
text-align: left;
background: var(--color-surface);
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;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: 1rem;
height: 1rem;
border-radius: var(--radius-sm);
border: 1.5px solid var(--color-border-strong);
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-policies__source:hover {
background: var(--color-bg-hover);
}
.portal-policies__source--on {
border-color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-policies__source--on::after {
content: "✓";
font-size: 0.6875rem;
font-weight: 700;
color: #fff;
border-color: var(--color-blue);
background: var(--color-blue);
}
.portal-policies__source-icon {
font-size: 1rem;
line-height: 1.2;
flex-shrink: 0;
color: var(--color-text-3);
}
.portal-policies__source--on .portal-policies__source-icon {
color: var(--color-blue);
}
.portal-policies__source-text {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.portal-policies__source-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-policies__source-desc {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.4;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.portal-policies__link {
@@ -20,6 +20,7 @@ import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled";
import "@portal/views/Policies.css";
export function Policies() {
@@ -34,6 +35,18 @@ export function Policies() {
const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const { enabled: aiEngineEnabled, loading: aiEngineLoading } =
useAiEngineEnabled();
function isLocked(entry: CatalogueEntry): boolean {
return (
entry.category.requiresAiEngine === true &&
!aiEngineEnabled &&
!aiEngineLoading &&
!entry.policy
);
}
const catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []);
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
@@ -57,6 +70,11 @@ export function Policies() {
}));
function openEntry(entry: CatalogueEntry) {
// Block setup of an AI-required policy until the engine is confirmed on (so a
// click during the app-config load can't open a wizard for a disabled
// feature); a configured policy stays openable so it can be paused/deleted.
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
return;
if (entry.policy) setDetail(entry);
else setWizard(entry);
}
@@ -157,6 +175,8 @@ export function Policies() {
key={entry.category.id}
entry={entry}
onOpen={openEntry}
locked={isLocked(entry)}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</div>
@@ -0,0 +1,37 @@
/* Category show/hide list (Files-sidebar visibility picker). */
.category-visibility {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.category-visibility-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 2.25rem;
padding: 0 var(--space-1);
}
.category-visibility-icon {
display: inline-flex;
align-items: center;
flex: none;
color: var(--color-text-2);
}
.category-visibility-name {
flex: 1;
min-width: 0;
font-size: 0.875rem;
color: var(--color-text-1);
}
.category-visibility-count {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* Hidden categories read as dimmed until re-shown. */
.category-visibility-row--hidden .category-visibility-icon,
.category-visibility-row--hidden .category-visibility-name,
.category-visibility-row--hidden .category-visibility-count {
opacity: 0.5;
}
@@ -0,0 +1,46 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager";
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
const CATEGORIES: SidebarCategory[] = [
{ id: "finance", name: "Financial", icon: "payments", labelKeys: [] },
{ id: "legal", name: "Legal", icon: "gavel", labelKeys: [] },
{ id: "hr", name: "HR", icon: "badge", labelKeys: [] },
{ id: "reports", name: "Reports", icon: "monitoring", labelKeys: [] },
];
const COUNTS = new Map<string, number>([
["finance", 12],
["legal", 4],
["reports", 7],
]);
// The store is device-local; the story holds the hidden state so the toggle is live.
function Harness() {
const [categories, setCategories] = useState(CATEGORIES);
return (
<div style={{ maxWidth: 420 }}>
<ClassificationCategoryManager
categories={categories}
counts={COUNTS}
onToggleHidden={(id, hidden) =>
setCategories((prev) =>
prev.map((c) => (c.id === id ? { ...c, hidden } : c)),
)
}
/>
</div>
);
}
const meta: Meta<typeof Harness> = {
title: "Policies/ClassificationCategoryManager",
component: Harness,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Harness>;
/** Show/hide the fixed, shared categories in the Files sidebar. */
export const Default: Story = {};
@@ -0,0 +1,68 @@
// Uber-simple category visibility list for the Files sidebar. Categories are the fixed, shared set
// (the built-in label families) — they can't be created, renamed, re-grouped, or have their labels
// edited. The only choice is showing or hiding each one in this device's sidebar; a hidden category
// forms no group, so its files fall to "Other".
import { useTranslation } from "react-i18next";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import { ActionIcon } from "@app/ui/ActionIcon";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
import "@app/components/policies/ClassificationCategoryManager.css";
interface ClassificationCategoryManagerProps {
categories: SidebarCategory[];
onToggleHidden: (id: string, hidden: boolean) => void;
/** Optional per-category file counts. */
counts?: Map<string, number>;
}
export function ClassificationCategoryManager({
categories,
onToggleHidden,
counts,
}: ClassificationCategoryManagerProps) {
const { t } = useTranslation();
return (
<ul className="category-visibility">
{categories.map((category) => {
const count = counts?.get(category.id);
return (
<li
key={category.id}
className={
category.hidden
? "category-visibility-row category-visibility-row--hidden"
: "category-visibility-row"
}
>
<span className="category-visibility-icon">
<LocalIcon icon={category.icon} width="1.1rem" />
</span>
<span className="category-visibility-name">{category.name}</span>
{count !== undefined && (
<span className="category-visibility-count">{count}</span>
)}
<ActionIcon
variant="quiet"
aria-pressed={!category.hidden}
aria-label={
category.hidden
? t("policies.labels.showCategory", "Show category")
: t("policies.labels.hideCategory", "Hide category")
}
onClick={() => onToggleHidden(category.id, !category.hidden)}
>
{category.hidden ? (
<VisibilityOffIcon sx={{ fontSize: "1.1rem" }} />
) : (
<VisibilityIcon sx={{ fontSize: "1.1rem" }} />
)}
</ActionIcon>
</li>
);
})}
</ul>
);
}
@@ -1,176 +0,0 @@
/**
* The Classification policy's team-labels control, shown in its Edit-Settings
* view: a compact summary (count + chips) with an Expand button that opens the
* fat {@link LabelsEditorModal}. Team-shared; only users who can configure
* policies may edit it. Owns the editable draft and the load/save/import/export
* wiring via {@link useClassificationLabels}.
*/
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Card } from "@app/ui/Card";
import { Button } from "@app/ui/Button";
import { Chip } from "@app/ui/Chip";
import { Banner } from "@app/ui/Banner";
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
import { LabelsEditorModal } from "@app/components/policies/LabelsEditorModal";
import {
downloadLabels,
parseLabelsFile,
validateLabels,
} from "@app/services/labelsFile";
import {
DEFAULT_CLASSIFICATION_LABELS,
type ClassificationLabel,
} from "@app/data/classificationLabels";
import "@app/components/policies/LabelsEditor.css";
/** Chips shown in the collapsed team summary before it gets noisy. */
const SUMMARY_CHIP_COUNT = 12;
interface ClassificationLabelsSectionProps {
canConfigure: boolean;
}
export function ClassificationLabelsSection({
canConfigure,
}: ClassificationLabelsSectionProps) {
const { t } = useTranslation();
const { teamLabels, isCustom, loading, saving, error, saveTeam } =
useClassificationLabels(true);
const [draft, setDraft] = useState<ClassificationLabel[]>(teamLabels);
const [open, setOpen] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
// Sync the draft to server truth whenever it changes (load / save / reset).
// Local edits don't change `teamLabels`, so this never clobbers them mid-edit.
useEffect(() => setDraft(teamLabels), [teamLabels]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(teamLabels),
[draft, teamLabels],
);
const close = () => {
setDraft(teamLabels);
setLocalError(null);
setOpen(false);
};
const onImportFile = (file: File) => {
setLocalError(null);
void parseLabelsFile(file)
.then(setDraft)
.catch((e: unknown) =>
setLocalError(
e instanceof Error
? e.message
: t("policies.labels.importError", "Couldn't import that file."),
),
);
};
const onSave = () => {
const errors = validateLabels({ labels: draft });
if (errors.length > 0) {
setLocalError(errors[0]);
return;
}
setLocalError(null);
void saveTeam(draft).then(() => setOpen(false));
};
return (
<div className="labels-summary">
<p className="pol-section-label">
{t("policies.labels.sectionLabel", "Classification labels")}
</p>
<Card>
{loading ? (
<span className="labels-empty">{t("loading", "Loading…")}</span>
) : (
<div className="labels-summary">
<div className="labels-summary-stats">
<span>
<strong>{teamLabels.length}</strong>{" "}
{t("policies.labels.teamCount", "team labels")}
</span>
</div>
<div className="labels-chips">
{teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{label.name}
</Chip>
))}
{teamLabels.length > SUMMARY_CHIP_COUNT && (
<Chip accent="neutral" size="sm">
+{teamLabels.length - SUMMARY_CHIP_COUNT}
</Chip>
)}
</div>
<span className="labels-summary-note">
{isCustom
? t("policies.labels.customNote", "Customized for your team.")
: t(
"policies.labels.defaultNote",
"Using the built-in default, shared with your team.",
)}
</span>
<Button
variant="secondary"
size="sm"
leftSection={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
onClick={() => setOpen(true)}
style={{ alignSelf: "flex-start" }}
>
{canConfigure
? t("policies.labels.edit", "Edit labels")
: t("policies.labels.view", "View labels")}
</Button>
</div>
)}
</Card>
{!canConfigure && (
<Banner
tone="neutral"
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={t(
"policies.labels.managedNote",
"Team labels are managed by your team leader.",
)}
/>
)}
{error && !open && <Banner tone="danger" description={error} />}
<LabelsEditorModal
open={open}
onClose={close}
draft={draft}
onDraftChange={setDraft}
onImportFile={onImportFile}
onExport={() => downloadLabels(draft)}
onReset={() => {
// Stage the built-in default into the draft — reversible via Cancel,
// only persisted on Save (no immediate destructive server delete).
setLocalError(null);
setDraft(DEFAULT_CLASSIFICATION_LABELS);
}}
onClear={() => {
// Stage an empty list to build from scratch — also reversible until Save.
setLocalError(null);
setDraft([]);
}}
onSave={onSave}
dirty={dirty}
saving={saving}
readOnly={!canConfigure}
error={localError ?? error}
/>
</div>
);
}
@@ -1,92 +0,0 @@
/**
* Small popover picker for a classification label's icon: a trigger button
* showing the current icon, opening a grid of the curated
* {@link LABEL_ICON_OPTIONS}. Icons render via {@link LocalIcon} (Material
* Symbols). No search yet — the palette is small enough to scan.
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Popover, SimpleGrid, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
import { LABEL_ICON_OPTIONS, DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
interface LabelIconPickerProps {
/** Current icon key, or undefined to show the default placeholder. */
value?: string;
onChange: (icon: string) => void;
ariaLabel: string;
}
export function LabelIconPicker({
value,
onChange,
ariaLabel,
}: LabelIconPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<Popover
opened={open}
onChange={setOpen}
onDismiss={() => setOpen(false)}
position="bottom-start"
withArrow
trapFocus
withinPortal
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
>
<Popover.Target>
<ActionIcon
variant="quiet"
className="labels-icon-pick"
onClick={() => setOpen((o) => !o)}
aria-label={ariaLabel}
aria-haspopup="true"
>
<LocalIcon icon={value || DEFAULT_LABEL_ICON} width="1.15rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs" style={{ maxHeight: 280, overflowY: "auto" }}>
<SimpleGrid cols={10} spacing={4} verticalSpacing={4}>
{LABEL_ICON_OPTIONS.map((option) => {
const iconLabel = t(
`policies.labels.iconName.${option.icon}`,
option.label,
);
return (
<Tooltip
key={option.icon}
label={iconLabel}
withArrow
openDelay={300}
>
<ActionIcon
variant="quiet"
className={`labels-icon-option${option.icon === value ? " is-selected" : ""}`}
onClick={() => {
onChange(option.icon);
setOpen(false);
}}
aria-label={t(
"policies.labels.pickIcon",
"Use {{label}} icon",
{
label: iconLabel,
},
)}
aria-pressed={option.icon === value}
>
<LocalIcon icon={option.icon} width="1.25rem" />
</ActionIcon>
</Tooltip>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,183 +0,0 @@
/* Classification-labels editor: add box, chip grid, icon picker, and the
summary/modal chrome around them. */
.labels-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-height: 0;
}
/* ---- Add box ---- */
.labels-add {
display: flex;
align-items: center;
gap: var(--space-2);
}
.labels-add-input {
flex: 1;
min-width: 0;
padding: 0.4rem 0.6rem;
font-size: 0.8125rem;
font-family: inherit;
color: var(--color-text-1);
background: var(--color-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--motion-fast);
}
.labels-add-input:focus {
border-color: var(--color-blue);
}
.labels-add-error {
margin: 0;
font-size: 0.75rem;
color: var(--color-red);
}
.labels-empty {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-3);
}
/* ---- Chip grid (chips themselves are the shared @app/ui/LabelChip) ---- */
.labels-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
overflow-y: auto;
min-height: 0;
}
/* ---- Icon picker (trigger + popover options) ---- */
.labels-icon-pick {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.labels-icon-pick:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.labels-icon-option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.labels-icon-option:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.labels-icon-option.is-selected {
background: var(--color-blue-light);
color: var(--color-blue);
}
/* ---- Section summary (Edit-Settings card) ---- */
.labels-summary {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.labels-summary-stats {
display: flex;
gap: var(--space-3);
font-size: 0.8125rem;
color: var(--color-text-2);
}
.labels-summary-note {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* ---- Fat modal ---- */
.labels-modal {
width: min(1100px, 94vw);
max-width: min(1100px, 94vw);
}
.labels-modal-body {
max-height: min(70vh, 640px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 0;
}
.labels-toolbar {
display: flex;
gap: var(--space-2);
}
.labels-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
width: 100%;
}
.labels-footer-left,
.labels-footer-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ---- Grouped mode: chips under collapsible parent categories ---- */
.labels-groups {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.labels-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.labels-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
border: none;
background: transparent;
padding: 0;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-1, inherit);
}
.labels-group-name {
flex: 1;
text-align: left;
}
.labels-group-count {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-3, rgba(128, 128, 128, 0.9));
}
.labels-group-ungrouped {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-2, inherit);
}
@@ -1,258 +0,0 @@
// Editor for a classification-label list: an add box on top, then the labels as chips (icon picker + remove), duplicate names rejected case-insensitively (optionally also against `reservedNames`). In `grouped` mode the chips are organised under collapsible parent categories (the device-local sidebar categories), matching the sidebar's category picker; labels in no category fall under "Ungrouped". Grouping is presentational — editing still mutates the flat list.
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import { Button } from "@app/ui/Button";
import { LabelChip } from "@app/ui/LabelChip";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
import {
getSidebarCategories,
subscribeSidebarCategories,
} from "@app/services/fileSidebarCategories";
import {
labelId,
type ClassificationLabel,
} from "@app/data/classificationLabels";
const MAX_TEXT_LENGTH = 128;
interface LabelsEditorProps {
value: ClassificationLabel[];
onChange: (next: ClassificationLabel[]) => void;
readOnly?: boolean;
/** Names that can't be added here because another set already owns them. */
reservedNames?: string[];
addPlaceholder?: string;
emptyText?: string;
/** Render chips under collapsible parent categories (like the sidebar picker). */
grouped?: boolean;
}
export function LabelsEditor({
value,
onChange,
readOnly = false,
reservedNames,
addPlaceholder,
emptyText,
grouped = false,
}: LabelsEditorProps) {
const { t } = useTranslation();
const [pending, setPending] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const add = () => {
const name = pending.trim();
if (!name) return;
if (name.length > MAX_TEXT_LENGTH) {
setAddError(
t(
"policies.labels.tooLong",
"Labels can be at most {{max}} characters.",
{ max: MAX_TEXT_LENGTH },
),
);
return;
}
const key = name.toLowerCase();
const taken =
value.some((label) => label.name.toLowerCase() === key) ||
(reservedNames ?? []).some((reserved) => reserved.toLowerCase() === key);
if (taken) {
setAddError(
t("policies.labels.duplicate", '"{{name}}" already exists.', { name }),
);
return;
}
setAddError(null);
setPending("");
onChange([...value, { id: labelId(name), name }]);
};
const removeById = (id: string) =>
onChange(value.filter((label) => label.id !== id));
const setIconById = (id: string, icon: string) =>
onChange(
value.map((label) => (label.id === id ? { ...label, icon } : label)),
);
const renderChip = (label: ClassificationLabel) => {
const key = label.id;
return (
<LabelChip
key={key}
label={label.name}
leading={
readOnly ? (
<span className="sui-labelchip-icon">
<LocalIcon icon={label.icon || DEFAULT_LABEL_ICON} width="1rem" />
</span>
) : (
<LabelIconPicker
value={label.icon}
onChange={(icon) => setIconById(key, icon)}
ariaLabel={t(
"policies.labels.iconAria",
"Choose an icon for {{name}}",
{ name: label.name },
)}
/>
)
}
onRemove={readOnly ? undefined : () => removeById(key)}
removeAriaLabel={t("policies.labels.removeAria", "Remove {{name}}", {
name: label.name,
})}
/>
);
};
return (
<div className="labels-editor">
{!readOnly && (
<div className="labels-add">
<input
className="labels-add-input"
value={pending}
maxLength={MAX_TEXT_LENGTH + 1}
placeholder={
addPlaceholder ??
t("policies.labels.addPlaceholder", "Add a label…")
}
onChange={(e) => {
setPending(e.target.value);
if (addError) setAddError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
onClick={add}
disabled={!pending.trim()}
>
{t("policies.labels.add", "Add")}
</Button>
</div>
)}
{addError && <p className="labels-add-error">{addError}</p>}
{value.length === 0 ? (
<p className="labels-empty">
{emptyText ?? t("policies.labels.empty", "No labels yet.")}
</p>
) : grouped ? (
<GroupedLabels value={value} renderChip={renderChip} />
) : (
<div className="labels-chips" role="list">
{value.map(renderChip)}
</div>
)}
</div>
);
}
interface GroupedLabelsProps {
value: ClassificationLabel[];
renderChip: (label: ClassificationLabel) => ReactNode;
}
/** Chips laid out under collapsible parent categories (device-local structure). */
function GroupedLabels({ value, renderChip }: GroupedLabelsProps) {
const { t } = useTranslation();
const categories = useSyncExternalStore(
subscribeSidebarCategories,
getSidebarCategories,
);
// Category sections that actually contain labels from `value`, plus the leftovers.
const { sections, ungrouped } = useMemo(() => {
const byId = new Map(value.map((l) => [l.id, l]));
const claimed = new Set<string>();
const sections = categories
.map((category) => {
const members = category.labelKeys
.map((id) => byId.get(id))
.filter((l): l is ClassificationLabel => !!l);
members.forEach((m) => claimed.add(m.id));
return { category, members };
})
.filter((s) => s.members.length > 0);
const ungrouped = value.filter((l) => !claimed.has(l.id));
return { sections, ungrouped };
}, [value, categories]);
// Only the first section starts expanded — a scannable overview.
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
() => new Set(sections.length > 0 ? [sections[0].category.id] : []),
);
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div className="labels-groups">
{sections.map(({ category, members }) => {
const isOpen = expanded.has(category.id);
return (
<section key={category.id} className="labels-group">
<Button
variant="quiet"
fullWidth
justify="between"
className="labels-group-header"
aria-expanded={isOpen}
onClick={() => toggle(category.id)}
leftSection={
<>
{isOpen ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={category.icon} width="1.05rem" />
</>
}
rightSection={
<span className="labels-group-count">{members.length}</span>
}
>
<span className="labels-group-name">{category.name}</span>
</Button>
{isOpen && (
<div className="labels-chips" role="list">
{members.map(renderChip)}
</div>
)}
</section>
);
})}
{ungrouped.length > 0 && (
<section className="labels-group">
<p className="labels-group-ungrouped">
{t("policies.labels.ungrouped", "Ungrouped")}
</p>
<div className="labels-chips" role="list">
{ungrouped.map(renderChip)}
</div>
</section>
)}
</div>
);
}
@@ -1,175 +0,0 @@
/**
* Full-screen ("fat") editor for the team's classification labels — the roomy
* view the settings summary's Edit button opens. Hosts the {@link LabelsEditor}
* chip grid plus an Import/Export toolbar and a footer holding the destructive
* actions (reset / start-from-scratch) and the Save/Cancel buttons. Editing is
* staged: nothing is persisted until Save, which stays disabled until the draft
* actually changes. The draft is owned by the caller so the settings summary
* reflects saved changes.
*/
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Modal } from "@app/ui/Modal";
import { Button } from "@app/ui/Button";
import { Banner } from "@app/ui/Banner";
import { LabelsEditor } from "@app/components/policies/LabelsEditor";
import type { ClassificationLabel } from "@app/data/classificationLabels";
interface LabelsEditorModalProps {
open: boolean;
onClose: () => void;
draft: ClassificationLabel[];
onDraftChange: (next: ClassificationLabel[]) => void;
onImportFile: (file: File) => void;
onExport: () => void;
/** Stage the built-in default into the draft. */
onReset: () => void;
/** Stage an empty list into the draft (build from scratch). */
onClear: () => void;
onSave: () => void;
dirty: boolean;
saving: boolean;
readOnly: boolean;
/** Save (server) or import (file) failure to surface, if any. */
error: string | null;
}
export function LabelsEditorModal({
open,
onClose,
draft,
onDraftChange,
onImportFile,
onExport,
onReset,
onClear,
onSave,
dirty,
saving,
readOnly,
error,
}: LabelsEditorModalProps) {
const { t } = useTranslation();
const fileInput = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file twice still fires onChange.
e.target.value = "";
if (file) onImportFile(file);
};
return (
<Modal
open={open}
onClose={onClose}
width="xl"
className="labels-modal"
title={t("policies.labels.modalTitle", "Classification labels")}
subtitle={t(
"policies.labels.modalSubtitle",
"Shared with your whole team. The classifier picks the labels that fit each document.",
)}
footer={
<div className="labels-footer">
<div className="labels-footer-left">
{!readOnly && (
<>
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onClear}
disabled={saving}
>
{t("policies.labels.startFromScratch", "Start from scratch")}
</Button>
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
onClick={onReset}
disabled={saving}
>
{t("policies.labels.resetToDefault", "Reset to default")}
</Button>
</>
)}
</div>
<div className="labels-footer-right">
<Button variant="tertiary" size="sm" onClick={onClose}>
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
</Button>
{!readOnly && (
<Button
variant="primary"
size="sm"
onClick={onSave}
disabled={!dirty || saving}
>
{saving
? t("policies.labels.saving", "Saving…")
: t("policies.labels.saveForTeam", "Save for team")}
</Button>
)}
</div>
</div>
}
>
<div className="labels-modal-body">
{error && (
<Banner
tone="danger"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={error}
/>
)}
{!readOnly && (
<div className="labels-toolbar">
<Button
variant="secondary"
size="sm"
leftSection={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
onClick={() => fileInput.current?.click()}
>
{t("policies.labels.import", "Import JSON")}
</Button>
<Button
variant="secondary"
size="sm"
leftSection={
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onExport}
>
{t("policies.labels.export", "Export JSON")}
</Button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
hidden
onChange={handleFile}
/>
</div>
)}
<LabelsEditor
value={draft}
onChange={onDraftChange}
readOnly={readOnly}
grouped
/>
</div>
</Modal>
);
}
@@ -1,19 +0,0 @@
/**
* The fixed, configurable tool chain per policy category — the locked set the
* config page shows (one section per tool). Tools can be configured + toggled
* on/off, but not added or removed. Each id is a frontend tool-registry key (and
* maps to that tool's backend endpoint via the registry's operationConfig).
*/
export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
// by default comes from the preset's defaultOperations, not this list.
security: ["redact", "watermark", "sanitize"],
// Classification: a single backend step that classifies the document and
// writes the result into its metadata.
classification: ["classify"],
};
/** The configurable tool chain for a category, or null if it has none yet. */
export function getPolicyToolChain(categoryId: string): string[] | null {
return POLICY_TOOL_CHAINS[categoryId] ?? null;
}
@@ -0,0 +1,54 @@
import fs from "fs";
import path from "path";
import { describe, it, expect } from "vitest";
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
// The classifier vocabulary is bundled in TWO places that must not drift:
// - this frontend JSON (source of truth for sidebar categories + display names)
// - a backend copy the classify tool sends to the AI engine per request
// (app/proprietary/src/main/resources/classification/classification-labels.json).
// The backend sends label IDS the engine stores on the document and the frontend
// then displays, so a divergence means the classifier picks a label the UI can't
// render (or the UI advertises labels the classifier never uses). This guards it.
const REPO_ROOT = path.resolve(__dirname, "../../../../..");
const BACKEND_LABELS_FILE = path.join(
REPO_ROOT,
"app/proprietary/src/main/resources/classification/classification-labels.json",
);
interface Label {
id: string;
name: string;
icon?: string | null;
}
function readBackendLabels(): Label[] {
const raw = JSON.parse(fs.readFileSync(BACKEND_LABELS_FILE, "utf8")) as {
labels: Label[];
};
return raw.labels;
}
describe("classification label vocabulary (frontend ↔ backend)", () => {
const frontend = DEFAULT_CLASSIFICATION_LABELS;
const backend = readBackendLabels();
it("has the identical set of label ids on both sides", () => {
const frontendIds = [...new Set(frontend.map((l) => l.id))].sort();
const backendIds = [...new Set(backend.map((l) => l.id))].sort();
expect(backendIds).toEqual(frontendIds);
});
it("has matching name and icon per id on both sides", () => {
const backendById = new Map(backend.map((l) => [l.id, l]));
for (const label of frontend) {
const other = backendById.get(label.id);
expect(other, `backend missing label "${label.id}"`).toBeDefined();
expect({ name: other!.name, icon: other!.icon ?? null }).toEqual({
name: label.name,
icon: label.icon ?? null,
});
}
});
});
@@ -1,14 +1,14 @@
// Default classification labels. The SOURCE OF TRUTH is the co-located static
// JSON (`classificationLabels.json`), imported here and shaped into typed
// objects — edit THAT file, not this one. This is the ONLY copy of the label
// data: it seeds a team's editable set and drives the sidebar's grouping,
// icons, and display names. Neither the backend nor the engine keeps a copy.
// Classification labels. The SOURCE OF TRUTH is the co-located static JSON
// (`classificationLabels.json`), imported here and shaped into typed objects —
// edit THAT file, not this one. It's a fixed, built-in vocabulary shared by
// everyone (no per-team customization); it drives the sidebar's grouping, icons,
// and display names.
//
// NOTE: this is only the built-in default vocabulary. A team's own
// (admin-editable) labels live in the backend store and are what the backend
// sends to the engine per classify request — the engine holds no vocabulary of
// its own. Edits to a team's set reach Python on the next run, independent of
// this file.
// The backend keeps a SECOND copy of this list
// (`app/proprietary/src/main/resources/classification/classification-labels.json`)
// which it sends to the engine per classify request (the engine holds no
// vocabulary of its own). The two copies must not drift — `classificationLabels.drift.test.ts`
// guards that. When you edit the labels here, update the backend copy too.
//
// `labels` is the flat set: each has a stable `id` (slug — the value on the wire,
// in storage and keyed on) and a human `name` (display, translatable via
@@ -1,22 +1,21 @@
/**
* Curated palette of icons a classification label can use, shown in the label
* icon picker and rendered in the file sidebar's label groups. Keys are
* Material Symbols names rendered via {@link LocalIcon}.
* Curated palette of icons the classification labels use, rendered in the file
* sidebar's label/category groups. Keys are Material Symbols names rendered via
* {@link LocalIcon}.
*
* IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler
* (`scripts/generate-icons.js`, which regex-scans for icon literals) picks every
* one up and bundles it — otherwise a picked icon would fall back to the CDN and
* render blank offline. After adding/removing entries run `task frontend:prepare:icons`.
*
* Search is intentionally omitted for now (no synonyms to maintain); the palette
* is small enough to eyeball.
* IMPORTANT — this list is also the icon BUNDLING MANIFEST for classification.
* The label/family icons live in `classificationLabels.json`, but the bundler
* (`scripts/generate-icons.js`) only regex-scans `.ts/.tsx` for `icon: "…"`
* literals, not JSON — so every icon those labels use must appear here as a
* literal or it falls back to the CDN and renders blank offline. Do NOT delete
* `LABEL_ICON_OPTIONS` as "unused": nothing imports it, but it's load-bearing
* for offline icons. After editing, run `task frontend:prepare:icons`.
*/
export interface LabelIconOption {
/** Material Symbols key (no `material-symbols:` prefix). */
icon: string;
/** Short English name — the en-US default for the icon's tooltip/aria; the
* picker translates it via `policies.labels.iconName.<icon>`. */
/** Short English name for the icon (documentation only). */
label: string;
}
@@ -188,8 +187,3 @@ export const LABEL_ICON_OPTIONS: LabelIconOption[] = [
{ icon: "recycling", label: "Recycling" },
{ icon: "agriculture", label: "Agriculture" },
];
/** Set of valid palette keys, for validating a stored/imported icon. */
export const LABEL_ICON_KEYS: ReadonlySet<string> = new Set(
LABEL_ICON_OPTIONS.map((option) => option.icon),
);
@@ -5,18 +5,13 @@
* user's real files, not defined here.
*/
import LayersIcon from "@mui/icons-material/Layers";
import ShieldIcon from "@mui/icons-material/Shield";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import StorageIcon from "@mui/icons-material/Storage";
import DescriptionIcon from "@mui/icons-material/Description";
import ComputerIcon from "@mui/icons-material/Computer";
import PublicIcon from "@mui/icons-material/Public";
import CloudIcon from "@mui/icons-material/Cloud";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import type {
PolicyCategory,
PolicyConfigDef,
@@ -30,7 +25,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "ingestion",
label: "Ingestion",
icon: <LayersIcon sx={ICON_SX} />,
icon: policyCategoryIcon("ingestion", ICON_SX),
desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
// The classifier the wizard's "Set up Classification" action routes to.
providesClassification: true,
@@ -40,13 +35,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "security",
label: "Security",
icon: <ShieldIcon sx={ICON_SX} />,
icon: policyCategoryIcon("security", ICON_SX),
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
},
{
id: "classification",
label: "Classification",
icon: <LabelOutlinedIcon sx={ICON_SX} />,
icon: policyCategoryIcon("classification", ICON_SX),
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
// Needs the AI engine to classify; hidden from the policy list when it's off.
requiresAiEngine: true,
@@ -54,21 +49,21 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "compliance",
label: "Compliance",
icon: <CheckCircleIcon sx={ICON_SX} />,
icon: policyCategoryIcon("compliance", ICON_SX),
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
comingSoon: true,
},
{
id: "routing",
label: "Routing",
icon: <ArrowForwardIcon sx={ICON_SX} />,
icon: policyCategoryIcon("routing", ICON_SX),
desc: "Auto-route documents to the right team, folder, or system.",
comingSoon: true,
},
{
id: "retention",
label: "Retention",
icon: <StorageIcon sx={ICON_SX} />,
icon: policyCategoryIcon("retention", ICON_SX),
desc: "Set how long documents are kept, when to archive, and when to delete.",
comingSoon: true,
},
@@ -1,85 +0,0 @@
/**
* Loads and persists the team's classification labels
* (`/api/v1/classification/labels`) — one server-truth set shared by the whole
* team. When the team has none, this shows the built-in default as a starting
* point for the editor/sidebar; note the classifier itself does NOT use that
* default — the backend only classifies against a team's saved set (an unsaved
* team is not classified). Editing is gated to team leaders / admins by the
* backend — callers pass `canConfigure` (the policy gate) to keep read-only
* users out of the save path.
*
* `teamLabels` is also what the sidebar/editor display uses — the team set, or
* the built-in default when the team has none.
*/
import { useCallback, useEffect, useState } from "react";
import {
DEFAULT_CLASSIFICATION_LABELS,
type ClassificationLabel,
} from "@app/data/classificationLabels";
import { fetchTeamLabels, saveTeamLabels } from "@app/services/labelsBackend";
export interface UseClassificationLabels {
/** Server-truth team labels (or the built-in default when the team has none). */
teamLabels: ClassificationLabel[];
/** Whether the team has a stored set (vs. the built-in default). */
isCustom: boolean;
loading: boolean;
saving: boolean;
/** Last save failure, cleared on the next attempt. */
error: string | null;
/** Persist the team set; resolves once server state is updated. */
saveTeam: (next: ClassificationLabel[]) => Promise<void>;
}
export function useClassificationLabels(
enabled: boolean,
): UseClassificationLabels {
const [teamLabels, setTeamLabels] = useState<ClassificationLabel[]>(
DEFAULT_CLASSIFICATION_LABELS,
);
const [isCustom, setIsCustom] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
void (async () => {
const team = await fetchTeamLabels().catch(() => null);
if (cancelled) return;
setTeamLabels(team ?? DEFAULT_CLASSIFICATION_LABELS);
setIsCustom(team != null);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [enabled]);
const saveTeam = useCallback(async (next: ClassificationLabel[]) => {
setSaving(true);
setError(null);
try {
const saved = await saveTeamLabels(next);
setTeamLabels(saved);
setIsCustom(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't save the labels.");
throw e;
} finally {
setSaving(false);
}
}, []);
return {
teamLabels,
isCustom,
loading,
saving,
error,
saveTeam,
};
}
@@ -35,8 +35,6 @@ import {
removePolicy,
} from "@app/services/policyBackend";
import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi";
import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend";
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
import type { PolicyToStore } from "@app/services/policyPipeline";
import type {
PoliciesByCategory,
@@ -139,13 +137,6 @@ export function usePolicies() {
async (id: string, result: PolicyWizardResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
// A policy whose chain classifies runs against the team's stored label set
// (the engine has no default). Seed it with the built-in defaults now so
// the very first enforced file has a vocabulary to classify against; no-op
// if the team already has a set (never clobbers admin edits).
if (getPolicyToolChain(id)?.includes("classify")) {
await seedTeamLabelsIfEmpty();
}
// One policy per category, ever: reuse any existing backend record.
const existingBackendId =
loadPolicies()[id]?.backendId ??
@@ -225,12 +216,6 @@ export function usePolicies() {
async (id: string, result: PolicyConfigResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
// Seed the team's default label set on first classification-policy setup
// (see enablePolicy) — the engine has no default, so the stored set is the
// only vocabulary. No-op once the team has any set.
if (getPolicyToolChain(id)?.includes("classify")) {
await seedTeamLabelsIfEmpty();
}
const current = loadPolicies()[id];
// One policy per category, ever: reuse the existing backend record (even
// if the local link was lost) so a save never creates a duplicate.
@@ -13,8 +13,9 @@ const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
describe("POLICY_OPERATIONS", () => {
test("every category operation is a typed descriptor with a known endpoint", () => {
// The catalogue uses these six across all categories; each must be wired.
// The catalogue uses these across all categories; each must be wired.
expect(ALL_TOOL_IDS.sort()).toEqual([
"classify",
"compress",
"flatten",
"ocr",
@@ -11,10 +11,39 @@ import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAd
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type { WirePipelineStep } from "@app/policies/types";
/**
* Endpoints for AI-dispatched policy tools. Intentionally NOT part of the generated
* {@link ToolEndpoint} union: the backend controllers are `@Hidden` and `/api/v1/ai/tools/` is
* deliberately excluded from tool-model generation, so they can't be typed like standard tools.
*/
export type AiPolicyEndpoint = "/api/v1/ai/tools/classify-and-label";
/** An AI tool usable in a policy — the AI-endpoint analogue of a standard tool descriptor. */
export interface AiToolDescriptor<TParams> {
readonly endpoint: AiPolicyEndpoint;
readonly defaultParameters: TParams;
toApi(params: TParams): Record<string, unknown>;
fromApi(api: Record<string, unknown>): TParams;
}
/**
* Describe an AI-dispatched policy tool. AI tools carry no tunable parameters today, so params are
* empty and the (de)serializers are identity over an empty object.
*/
export function describeAiToolOperation(
endpoint: AiPolicyEndpoint,
): AiToolDescriptor<Record<string, never>> {
return {
endpoint,
defaultParameters: {},
toApi: () => ({}),
fromApi: () => ({}),
};
}
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
@@ -37,17 +66,13 @@ export const POLICY_OPERATIONS = {
"/api/v1/misc/compress-pdf",
compressOperationConfig,
),
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
} as const;
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
export type PolicyParams<Id extends PolicyToolId> =
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
ToolEndpoint,
infer P
>
? P
: never;
(typeof POLICY_OPERATIONS)[Id]["defaultParameters"];
/** Discriminated on `toolId` so `params` matches the tool. */
export type PolicyToolStep = {
@@ -65,7 +90,10 @@ const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
);
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
/** A policy step's endpoint: a standard {@link ToolEndpoint} or an {@link AiPolicyEndpoint}. */
export type PolicyEndpoint = ToolEndpoint | AiPolicyEndpoint;
export function policyEndpoint(toolId: PolicyToolId): PolicyEndpoint {
return POLICY_OPERATIONS[toolId].endpoint;
}
@@ -90,19 +118,29 @@ export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
return serializeStep(step);
}
/**
* Minimal runtime shape shared by standard tool descriptors and {@link AiToolDescriptor}s — enough
* to (de)serialize a step at the wire boundary regardless of how its endpoint is typed.
*/
interface PolicyOperation<TParams> {
readonly endpoint: string;
readonly defaultParameters: TParams;
toApi(params: TParams): Record<string, unknown>;
fromApi(api: Record<string, unknown>): TParams;
}
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
// the union, so `op` is widened here (a contained cast at the wire boundary).
function serializeStep<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
const op = POLICY_OPERATIONS[step.toolId] as unknown as PolicyOperation<
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params) as Record<string, unknown>,
parameters: op.toApi(step.params),
};
}
@@ -119,13 +157,10 @@ function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
const op = POLICY_OPERATIONS[toolId] as unknown as PolicyOperation<
PolicyParams<Id>
>;
// Wire params are untyped JSON; this is the one point they enter the typed model.
const params = op.fromApi(
parameters as unknown as ToolApiParams[ToolEndpoint],
);
const params = op.fromApi(parameters);
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
}
@@ -1,97 +1,82 @@
// Device-local (localStorage) category structure for the Files sidebar: which parent
// categories exist, their name/icon/order, and which labels roll up into each. It's an editable
// override of the built-in LABEL_FAMILIES default — until the user customizes it, the default is
// used verbatim (so the store stays empty and the default can evolve). A label may sit in more
// than one category (multi-membership); a category with `hidden` set stays defined but isn't shown
// as a sidebar group. This is presentational only — the classifier never sees categories.
// The Files-sidebar categories are a fixed, built-in set shared by everyone (derived from
// LABEL_FAMILIES) — the team can't create, rename, or re-group them. The only per-user choice is
// which categories to SHOW or HIDE in the sidebar, kept device-local in localStorage. A hidden
// category isn't rendered as a group; its files fall to "Other". Presentational only — the
// classifier never sees categories.
import { LABEL_FAMILIES } from "@app/data/classificationLabels";
export interface SidebarCategory {
/** Stable id — built-ins reuse their family id; custom ones get `custom:<n>`. */
/** Stable id (the label family's id). */
id: string;
name: string;
icon: string;
/** Label ids in this category (matches a file's stored classification ids). */
labelKeys: string[];
/** Defined but not rendered as a sidebar group. */
/** Hidden from the sidebar (device-local, personal). */
hidden?: boolean;
}
// v2: labelKeys hold label ids (was lower-cased names in v1); bumping discards
// stale name-keyed prefs so they don't silently stop matching.
const STORAGE_KEY = "stirling.fileSidebarCategories.v2";
const HIDDEN_STORAGE_KEY = "stirling.fileSidebarHiddenCategories.v1";
/** The built-in default, derived from LABEL_FAMILIES. Fresh copy per call (callers may mutate). */
export function defaultCategories(): SidebarCategory[] {
return LABEL_FAMILIES.map((family) => ({
/** The fixed, shared category set — never mutated. */
const BASE_CATEGORIES: readonly Omit<SidebarCategory, "hidden">[] =
LABEL_FAMILIES.map((family) => ({
id: family.id,
name: family.name,
icon: family.icon,
labelKeys: family.labels.map((label) => label.id),
}));
}
function readStorage(): SidebarCategory[] | null {
function readHidden(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
return parsed
.filter((c): c is SidebarCategory => {
const cat = c as Partial<SidebarCategory>;
return (
typeof cat.id === "string" &&
typeof cat.name === "string" &&
typeof cat.icon === "string" &&
Array.isArray(cat.labelKeys)
);
})
.map((c) => ({
id: c.id,
name: c.name,
icon: c.icon,
labelKeys: c.labelKeys.filter((k) => typeof k === "string"),
hidden: c.hidden === true,
}));
return Array.isArray(parsed)
? new Set(parsed.filter((id): id is string => typeof id === "string"))
: new Set();
} catch {
return null;
return new Set();
}
}
// null = using the built-in default (not yet customized). Cached so useSyncExternalStore sees a
// stable reference between writes.
let stored: SidebarCategory[] | null = readStorage();
let hiddenIds = readHidden();
const listeners = new Set<() => void>();
// Effective list, recomputed only on write so its identity is stable for memo/useSyncExternalStore.
let effective: SidebarCategory[] = stored ?? defaultCategories();
// Recomputed only on write so its identity is stable for memo/useSyncExternalStore.
let effective: SidebarCategory[] = compute();
function recompute() {
effective = stored ?? defaultCategories();
function compute(): SidebarCategory[] {
return BASE_CATEGORIES.map((c) => ({
...c,
labelKeys: [...c.labelKeys],
hidden: hiddenIds.has(c.id),
}));
}
function write(next: SidebarCategory[]) {
stored = next;
recompute();
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...hiddenIds]));
} catch {
// Quota/private-mode failures degrade to session-only categories.
// Quota/private-mode failures degrade to session-only visibility.
}
effective = compute();
for (const listener of listeners) listener();
}
/** Mutate the current effective list (snapshotting the default on first edit). */
function mutate(fn: (categories: SidebarCategory[]) => SidebarCategory[]) {
write(fn(effective.map((c) => ({ ...c, labelKeys: [...c.labelKeys] }))));
}
/** The shared categories, each flagged with the user's device-local visibility. */
export function getSidebarCategories(): SidebarCategory[] {
return effective;
}
/** Show or hide a category in this device's sidebar. */
export function setCategoryHidden(id: string, hidden: boolean) {
if (hidden) hiddenIds.add(id);
else hiddenIds.delete(id);
persist();
}
export function subscribeSidebarCategories(listener: () => void) {
listeners.add(listener);
return () => {
@@ -99,98 +84,8 @@ export function subscribeSidebarCategories(listener: () => void) {
};
}
export function isCustomized(): boolean {
return stored !== null;
}
/** Map each label key to the ids of every VISIBLE category it belongs to. */
export function labelCategoryMap(
categories: SidebarCategory[],
): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const category of categories) {
if (category.hidden) continue;
for (const key of category.labelKeys) {
const ids = map.get(key);
if (ids) ids.push(category.id);
else map.set(key, [category.id]);
}
}
return map;
}
/** Set of every label key that belongs to any category (visible or not). */
export function categorizedLabelKeys(
categories: SidebarCategory[],
): Set<string> {
const keys = new Set<string>();
for (const category of categories) {
for (const key of category.labelKeys) keys.add(key);
}
return keys;
}
// ---- editing ----
/** Create a new empty category; returns its id. */
export function addCategory(name: string, icon: string): string {
const id = `custom:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${
effective.length
}`;
mutate((categories) => [...categories, { id, name, icon, labelKeys: [] }]);
return id;
}
export function renameCategory(id: string, name: string) {
mutate((categories) =>
categories.map((c) => (c.id === id ? { ...c, name } : c)),
);
}
export function setCategoryIcon(id: string, icon: string) {
mutate((categories) =>
categories.map((c) => (c.id === id ? { ...c, icon } : c)),
);
}
export function setCategoryHidden(id: string, hidden: boolean) {
mutate((categories) =>
categories.map((c) => (c.id === id ? { ...c, hidden } : c)),
);
}
export function deleteCategory(id: string) {
mutate((categories) => categories.filter((c) => c.id !== id));
}
export function addLabelToCategory(id: string, labelId: string) {
mutate((categories) =>
categories.map((c) =>
c.id === id && !c.labelKeys.includes(labelId)
? { ...c, labelKeys: [...c.labelKeys, labelId] }
: c,
),
);
}
export function removeLabelFromCategory(id: string, labelId: string) {
mutate((categories) =>
categories.map((c) =>
c.id === id
? { ...c, labelKeys: c.labelKeys.filter((k) => k !== labelId) }
: c,
),
);
}
/** Restore the built-in default and clear the customized flag (undoes any prior `mutate`). */
export function resetSidebarCategories() {
stored = null;
recompute();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Quota/private-mode failures degrade to session-only categories.
}
for (const listener of listeners) listener();
/** Show every category again (clear the device-local hidden set). */
export function resetHiddenCategories() {
hiddenIds = new Set();
persist();
}
@@ -1,108 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import apiClient from "@app/services/apiClient";
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend";
vi.mock("@app/services/apiClient");
const get = vi.mocked(apiClient.get);
const put = vi.mocked(apiClient.put);
// The service only reads `status`/`data` off the axios response; a partial shape
// is all these tests need, so cast the mock values through this helper.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = (value: object): any => value;
// 204 No Content (nothing stored) comes back as an empty body.
const emptyResponse = res({ status: 204, data: "" });
const storedResponse = res({
status: 200,
data: { labels: [{ id: "invoice", name: "Invoice", icon: "receipt" }] },
});
const putEcho = res({ data: { labels: DEFAULT_CLASSIFICATION_LABELS } });
describe("seedTeamLabelsIfEmpty", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("seeds the built-in defaults when the team has no set", async () => {
get.mockResolvedValue(emptyResponse);
put.mockResolvedValue(putEcho);
await seedTeamLabelsIfEmpty();
expect(put).toHaveBeenCalledTimes(1);
expect(put).toHaveBeenCalledWith("/api/v1/classification/labels", {
labels: DEFAULT_CLASSIFICATION_LABELS,
});
});
it("is a no-op when the team already has a set (never clobbers)", async () => {
get.mockResolvedValue(storedResponse);
await seedTeamLabelsIfEmpty();
expect(put).not.toHaveBeenCalled();
});
it("rides out a transient fetch failure, then seeds", async () => {
vi.useFakeTimers();
get
.mockRejectedValueOnce(new Error("network blip"))
.mockResolvedValueOnce(emptyResponse);
put.mockResolvedValue(putEcho);
const pending = seedTeamLabelsIfEmpty();
await vi.runAllTimersAsync();
await pending;
expect(get).toHaveBeenCalledTimes(2);
expect(put).toHaveBeenCalledTimes(1);
});
it("rides out a transient write failure, then seeds", async () => {
vi.useFakeTimers();
get.mockResolvedValue(emptyResponse);
put
.mockRejectedValueOnce(new Error("network blip"))
.mockResolvedValueOnce(putEcho);
const pending = seedTeamLabelsIfEmpty();
await vi.runAllTimersAsync();
await pending;
expect(put).toHaveBeenCalledTimes(2);
});
it("throws after a persistent fetch failure without writing (clobber-safe)", async () => {
vi.useFakeTimers();
get.mockRejectedValue(new Error("backend down"));
const pending = seedTeamLabelsIfEmpty();
const assertion = expect(pending).rejects.toThrow("backend down");
await vi.runAllTimersAsync();
await assertion;
// Never writes when the fetch can't confirm the team has no set.
expect(put).not.toHaveBeenCalled();
expect(get).toHaveBeenCalledTimes(3);
});
it("throws after a persistent write failure", async () => {
vi.useFakeTimers();
get.mockResolvedValue(emptyResponse);
put.mockRejectedValue(new Error("write failed"));
const pending = seedTeamLabelsIfEmpty();
const assertion = expect(pending).rejects.toThrow("write failed");
await vi.runAllTimersAsync();
await assertion;
expect(put).toHaveBeenCalledTimes(3);
});
});
@@ -1,99 +0,0 @@
/**
* Backend layer for the team's classification labels
* (`/api/v1/classification/labels`) — one shared, server-truth list, editable
* only by a team leader (SaaS) / admin (self-hosted). A team with none reads as
* 204 → `null`, and callers fall back to the built-in
* {@link DEFAULT_CLASSIFICATION_LABELS}.
*/
import apiClient from "@app/services/apiClient";
import {
DEFAULT_CLASSIFICATION_LABELS,
type ClassificationLabel,
} from "@app/data/classificationLabels";
const TEAM_ENDPOINT = "/api/v1/classification/labels";
/** Wire shape shared with the backend: the label list wrapped in an object. */
interface LabelsPayload {
labels: ClassificationLabel[];
}
async function fetchLabels(
endpoint: string,
): Promise<ClassificationLabel[] | null> {
const res = await apiClient.get<LabelsPayload | "">(endpoint, {
suppressErrorToast: true,
});
// 204 No Content (nothing stored) comes back as an empty body. Only an
// explicit 204 / empty string means "none"; anything else is a real payload.
if (res.status === 204 || res.data === "") return null;
return (res.data as LabelsPayload).labels ?? [];
}
async function saveLabels(
endpoint: string,
labels: ClassificationLabel[],
): Promise<ClassificationLabel[]> {
const res = await apiClient.put<LabelsPayload>(endpoint, { labels });
return res.data.labels ?? [];
}
/** The team's stored labels, or `null` when it has none (use the default). */
export function fetchTeamLabels(): Promise<ClassificationLabel[] | null> {
return fetchLabels(TEAM_ENDPOINT);
}
/** Persist the team's labels; returns the stored value. */
export function saveTeamLabels(
labels: ClassificationLabel[],
): Promise<ClassificationLabel[]> {
return saveLabels(TEAM_ENDPOINT, labels);
}
/** Seed-write retry budget: the seed is a hard prerequisite (see below), so ride
* out a transient blip rather than fail setup on the first hiccup. */
const SEED_MAX_ATTEMPTS = 3;
const SEED_RETRY_MS = 400;
async function withRetry<T>(op: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < SEED_MAX_ATTEMPTS; attempt++) {
try {
return await op();
} catch (error) {
lastError = error;
if (attempt < SEED_MAX_ATTEMPTS - 1) {
await new Promise((resolve) =>
setTimeout(resolve, SEED_RETRY_MS * (attempt + 1)),
);
}
}
}
throw lastError;
}
/**
* Seed the team's label set with the built-in defaults when it has none yet.
*
* The engine holds no default vocabulary, so the backend can only send what the
* team has stored — a team with an empty set classifies nothing (documents come
* back unlabelled and don't group). This writes the frontend's single default
* copy into the team set the first time a classification policy is set up, so
* classification works out of the box; the backend gates the write to admins.
*
* The seed is a hard prerequisite of enabling a classification policy, so it must
* land or fail loudly — never silently leave an empty set behind a created
* policy. Both the fetch and the write retry a transient failure; if either
* ultimately fails this THROWS, and the caller aborts the setup (the admin sees
* the error and retries) instead of shipping a policy that classifies nothing —
* the same way a failed policy save already aborts setup. Still clobber-safe: it
* writes only when the fetch DEFINITIVELY reports no set (204 → null), so it
* never overwrites a team's real (possibly customised) labels, and it's a no-op
* once any set exists (later admin edits are the source of truth).
*/
export async function seedTeamLabelsIfEmpty(): Promise<void> {
const existing = await withRetry(fetchTeamLabels);
if (existing != null) return;
await withRetry(() => saveTeamLabels(DEFAULT_CLASSIFICATION_LABELS));
}
@@ -1,107 +0,0 @@
/**
* Client-side import/export + validation for a classification-labels JSON file
* (`{"labels":[{"id":"invoice","name":"Invoice","icon":"receipt-long"}, …]}`).
* Sharing a label set between teams is done by exporting the JSON here and
* importing it on another team. Validation mirrors the backend
* (`LabelsValidator`) so a malformed file is caught before it's uploaded — the
* backend re-validates as the authority. A file may omit `id` (e.g. an older
* export); it is then derived from the name.
*/
import { downloadJsonAsFile } from "@app/utils/downloadUtils";
import { LABEL_ICON_KEYS } from "@app/data/labelIcons";
import {
labelId,
type ClassificationLabel,
} from "@app/data/classificationLabels";
// Kept in sync with the backend LabelsValidator (the authority); enforced here
// too so an oversized import is rejected before upload.
const MAX_LABELS = 500;
const MAX_TEXT_LENGTH = 128;
interface LabelsFileShape {
labels: ClassificationLabel[];
}
/** A label's effective id: the provided one, else derived from the name. */
function effectiveId(label: Partial<ClassificationLabel>): string {
const provided = typeof label.id === "string" ? label.id.trim() : "";
return provided || labelId(label.name?.trim() ?? "");
}
/** Human-readable problems with a candidate labels file; empty means valid. */
export function validateLabels(value: unknown): string[] {
const errors: string[] = [];
if (typeof value !== "object" || value === null) {
return ["File is not a labels object."];
}
const { labels } = value as Partial<LabelsFileShape>;
if (!Array.isArray(labels)) {
return ['File must have a "labels" list.'];
}
if (labels.length > MAX_LABELS) {
errors.push(`Too many labels (max ${MAX_LABELS}).`);
}
const seenIds = new Set<string>();
for (const label of labels) {
if (!isText(label?.name)) {
errors.push("Every label needs a non-empty name.");
continue;
}
const name = label.name.trim();
if (name.length > MAX_TEXT_LENGTH) {
errors.push(`Label "${name}" is over ${MAX_TEXT_LENGTH} characters.`);
}
const id = effectiveId(label);
if (!id) {
errors.push(`Label "${name}" has no usable id.`);
continue;
}
if (seenIds.has(id)) errors.push(`Duplicate label: ${name}`);
seenIds.add(id);
}
return errors;
}
/** Coerce a validated value into a normalized label list (trims, drops extras). */
export function normalizeLabels(
labels: ClassificationLabel[],
): ClassificationLabel[] {
return labels.map((label): ClassificationLabel => {
const name = label.name.trim();
const id = effectiveId(label);
// Keep the icon only if it's a known palette key, so a hand-crafted import
// can't set an unbundled key that renders blank.
return label.icon && LABEL_ICON_KEYS.has(label.icon)
? { id, name, icon: label.icon }
: { id, name };
});
}
/** Parse + validate a picked file, resolving to a normalized label list. */
export async function parseLabelsFile(
file: File,
): Promise<ClassificationLabel[]> {
let parsed: unknown;
try {
parsed = JSON.parse(await file.text());
} catch {
throw new Error("That file isn't valid JSON.");
}
const errors = validateLabels(parsed);
if (errors.length > 0) throw new Error(errors[0]);
return normalizeLabels((parsed as LabelsFileShape).labels);
}
/** Trigger a download of the labels as a pretty-printed JSON file. */
export function downloadLabels(
labels: ClassificationLabel[],
fileName = "classification-labels.json",
): void {
downloadJsonAsFile({ labels }, fileName);
}
function isText(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -1,10 +1,4 @@
/* Category-manager modal: editable parent categories with member-label chips.
Matches the labels editor's width so the two read as a set. */
.fsg-modal {
width: min(1100px, 94vw);
max-width: min(1100px, 94vw);
}
/* Category show/hide modal (Files-sidebar visibility picker). */
.fsg-body {
display: flex;
flex-direction: column;
@@ -14,119 +8,6 @@
padding-right: 4px;
}
.fsg-cat {
display: flex;
flex-direction: column;
gap: 6px;
}
.fsg-cat-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary, inherit);
}
.fsg-cat-toggle {
display: inline-flex;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
}
.fsg-chevron {
font-size: 1.1rem !important;
color: var(--text-muted, rgba(128, 128, 128, 0.9));
margin-left: -4px;
}
.fsg-cat-name {
flex: 1;
text-align: left;
border: none;
background: transparent;
padding: 2px 4px;
border-radius: 4px;
font: inherit;
color: inherit;
cursor: text;
}
.fsg-cat-name:hover {
background: var(--hover-bg, rgba(128, 128, 128, 0.12));
}
.fsg-rename {
flex: 1;
}
.fsg-cat-action {
display: inline-flex;
border: none;
background: transparent;
padding: 2px;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted, rgba(128, 128, 128, 0.9));
}
.fsg-cat-action:hover {
background: var(--hover-bg, rgba(128, 128, 128, 0.12));
color: var(--text-primary, inherit);
}
.fsg-cat-body {
display: flex;
flex-direction: column;
gap: 6px;
/* Indent past the chevron so the hierarchy reads at a glance. */
padding-left: 24px;
}
/* Member chips are the shared @app/ui/LabelChip. */
.fsg-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fsg-count {
font-size: 11px;
font-weight: 500;
color: var(--text-muted, rgba(128, 128, 128, 0.9));
}
.fsg-add,
.fsg-new {
display: flex;
align-items: center;
gap: 6px;
}
.fsg-new {
padding-top: 4px;
border-top: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.2));
}
.fsg-add-input {
flex: 1;
min-width: 0;
padding: 4px 8px;
font-size: 12px;
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.35));
border-radius: 6px;
background: transparent;
color: var(--text-primary, inherit);
}
.fsg-add-input:focus {
outline: none;
border-color: var(--color-primary, #4f46e5);
}
.fsg-footer {
display: flex;
justify-content: space-between;
@@ -1,74 +1,46 @@
// The Files-sidebar category manager: a "tune" button opening a modal where you shape the parent categories your files group under. Each category (collapsible, busiest first) has an editable name + icon, a hide toggle, delete, and its member-label chips; add existing team labels to a category, create new categories, reset to the built-in defaults. All device-local (grouping only — it never changes the team's label vocabulary); files in no visible category fall back to "Other".
// The Files-sidebar category picker: a "tune" button opening a modal that lists the fixed, shared
// categories and lets the user show or hide each one in their own sidebar. Device-local and
// presentational only — it never changes the shared categories or the label vocabulary; files in a
// hidden (or no) category fall back to "Other".
import { useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import TuneIcon from "@mui/icons-material/Tune";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import AddIcon from "@mui/icons-material/Add";
import { TextInput } from "@mantine/core";
import { Modal } from "@app/ui/Modal";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { LabelChip } from "@app/ui/LabelChip";
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager";
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
import { bucketStubsByLabel } from "@app/components/shared/fileSidebarGroupingLogic";
import {
addCategory,
addLabelToCategory,
deleteCategory,
getSidebarCategories,
removeLabelFromCategory,
renameCategory,
resetSidebarCategories,
resetHiddenCategories,
setCategoryHidden,
setCategoryIcon,
subscribeSidebarCategories,
} from "@app/services/fileSidebarCategories";
import type { StirlingFileStub } from "@app/types/fileContext";
import "@app/components/shared/FileSidebarGroupControls.css";
interface FileSidebarGroupControlsProps {
/** The files currently listed, for live per-label counts. */
/** The files currently listed, for live per-category counts. */
stubs: StirlingFileStub[];
}
/** New categories start with a neutral folder icon the user can change. */
const NEW_CATEGORY_ICON = "folder";
export function FileSidebarGroupControls({
stubs,
}: FileSidebarGroupControlsProps) {
const { t } = useTranslation();
const enabled = useClassificationEnabled();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const categories = useSyncExternalStore(
subscribeSidebarCategories,
getSidebarCategories,
);
// Only fetch the team label set while the picker is open.
const { teamLabels: labelSet } = useClassificationLabels(open);
// Bucketed once and reused for both the per-label and per-category counts below.
const byLabel = useMemo(() => bucketStubsByLabel(stubs), [stubs]);
// Per-label file counts from the same bucketing the sidebar groups use.
const labelCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const [key, bucket] of byLabel) counts.set(key, bucket.stubs.length);
return counts;
}, [byLabel]);
// Files per category (deduped across its labels).
// Files per category (deduped across its labels), from the same bucketing the sidebar groups use.
const categoryCounts = useMemo(() => {
const byLabel = bucketStubsByLabel(stubs);
const counts = new Map<string, number>();
for (const category of categories) {
const ids = new Set<string>();
@@ -80,90 +52,7 @@ export function FileSidebarGroupControls({
counts.set(category.id, ids.size);
}
return counts;
}, [byLabel, categories]);
// Translated display name + icon per label id, plus a name→id lookup for the
// add-label input (which matches on the visible/canonical text).
const vocab = useMemo(() => {
const byId = new Map<string, { display: string; icon?: string }>();
const idByName = new Map<string, string>();
for (const label of labelSet) {
const display = t(`classification.labels.${label.id}`, label.name);
byId.set(label.id, { display, icon: label.icon });
idByName.set(display.toLowerCase(), label.id);
idByName.set(label.name.toLowerCase(), label.id);
}
return { byId, idByName };
}, [labelSet, t]);
const labelDisplay = (id: string) => vocab.byId.get(id)?.display ?? id;
const labelIcon = (id: string) =>
vocab.byId.get(id)?.icon ?? DEFAULT_LABEL_ICON;
const q = query.trim().toLowerCase();
const matches = (text: string) => q === "" || text.toLowerCase().includes(q);
// Busiest categories first (ties keep declaration order).
const sortedCategories = useMemo(
() =>
[...categories].sort(
(a, b) =>
(categoryCounts.get(b.id) ?? 0) - (categoryCounts.get(a.id) ?? 0),
),
[categories, categoryCounts],
);
// Per-category collapse; on open only the busiest starts expanded.
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
const toggleExpanded = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Inline rename + add-label drafts, keyed by category id.
const [renaming, setRenaming] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [addDraft, setAddDraft] = useState<Record<string, string>>({});
const [newCategory, setNewCategory] = useState("");
const openPicker = () => {
setQuery("");
setRenaming(null);
setNewCategory("");
setExpanded(
new Set(sortedCategories.length > 0 ? [sortedCategories[0].id] : []),
);
setOpen(true);
};
const commitRename = (id: string) => {
const name = renameDraft.trim();
if (name) renameCategory(id, name);
setRenaming(null);
};
// Add an existing team label to a category (device-local grouping only — categories don't change
// the team vocabulary). A name that isn't a known team label is ignored; the input's datalist
// steers callers to real ones.
const addLabel = (categoryId: string) => {
const name = (addDraft[categoryId] ?? "").trim();
if (!name) return;
const labelId = vocab.idByName.get(name.toLowerCase());
if (!labelId) return;
addLabelToCategory(categoryId, labelId);
setAddDraft((prev) => ({ ...prev, [categoryId]: "" }));
};
const createCategory = () => {
const name = newCategory.trim();
if (!name) return;
const id = addCategory(name, NEW_CATEGORY_ICON);
setExpanded((prev) => new Set(prev).add(id));
setNewCategory("");
};
}, [stubs, categories]);
// Classification off (non-AI SaaS tenant) → no "customize groups" affordance,
// matching the flat, ungrouped list. (All hooks above run unconditionally.)
@@ -175,7 +64,7 @@ export function FileSidebarGroupControls({
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={openPicker}
onClick={() => setOpen(true)}
aria-label={t("fileSidebar.customizeGroups", "Customize groups")}
data-testid="customize-groups"
>
@@ -185,12 +74,11 @@ export function FileSidebarGroupControls({
<Modal
open={open}
onClose={() => setOpen(false)}
width="xl"
className="fsg-modal"
width="md"
title={t("fileSidebar.groupsModal.title", "Sidebar categories")}
subtitle={t(
"fileSidebar.groupsModal.subtitle",
"Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”.",
"Show or hide categories in the files sidebar.",
)}
footer={
<div className="fsg-footer">
@@ -198,9 +86,9 @@ export function FileSidebarGroupControls({
variant="tertiary"
size="sm"
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
onClick={resetSidebarCategories}
onClick={resetHiddenCategories}
>
{t("fileSidebar.groupsModal.reset", "Reset to defaults")}
{t("fileSidebar.groupsModal.reset", "Show all")}
</Button>
<Button variant="primary" size="sm" onClick={() => setOpen(false)}>
{t("fileSidebar.groupsModal.done", "Done")}
@@ -209,201 +97,11 @@ export function FileSidebarGroupControls({
}
>
<div className="fsg-body">
<TextInput
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={t("fileSidebar.groupsModal.search", "Search labels…")}
size="sm"
data-autofocus
<ClassificationCategoryManager
categories={categories}
onToggleHidden={setCategoryHidden}
counts={categoryCounts}
/>
{sortedCategories.map((category) => {
const memberKeys = category.labelKeys.filter((key) =>
matches(labelDisplay(key)),
);
// Show the category if its name matches, or any member label matches.
if (!matches(category.name) && memberKeys.length === 0) return null;
const isExpanded = q !== "" || expanded.has(category.id);
const suggestions = [...vocab.byId.keys()]
.filter((id) => !category.labelKeys.includes(id))
.map((id) => labelDisplay(id));
return (
<section key={category.id} className="fsg-cat">
<div className="fsg-cat-header">
<ActionIcon
variant="quiet"
className="fsg-cat-toggle"
aria-expanded={isExpanded}
aria-label={category.name}
onClick={() => toggleExpanded(category.id)}
>
{isExpanded ? (
<KeyboardArrowDownIcon className="fsg-chevron" />
) : (
<KeyboardArrowRightIcon className="fsg-chevron" />
)}
</ActionIcon>
<LabelIconPicker
value={category.icon}
onChange={(icon) => setCategoryIcon(category.id, icon)}
ariaLabel={t(
"fileSidebar.groupsModal.categoryIconAria",
"Choose an icon for {{name}}",
{ name: category.name },
)}
/>
{renaming === category.id ? (
<TextInput
className="fsg-rename"
size="xs"
value={renameDraft}
autoFocus
onChange={(e) => setRenameDraft(e.currentTarget.value)}
onBlur={() => commitRename(category.id)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename(category.id);
if (e.key === "Escape") setRenaming(null);
}}
/>
) : (
<Button
variant="quiet"
size="sm"
justify="start"
className="fsg-cat-name"
title={t("fileSidebar.groupsModal.rename", "Rename")}
onClick={() => {
setRenaming(category.id);
setRenameDraft(category.name);
}}
>
{category.name}
</Button>
)}
<span className="fsg-count">
{categoryCounts.get(category.id) ?? 0}
</span>
<ActionIcon
variant="quiet"
className="fsg-cat-action"
aria-label={
category.hidden
? t("fileSidebar.groupsModal.show", "Show category")
: t("fileSidebar.groupsModal.hide", "Hide category")
}
aria-pressed={!category.hidden}
onClick={() =>
setCategoryHidden(category.id, !category.hidden)
}
>
{category.hidden ? (
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
) : (
<VisibilityIcon sx={{ fontSize: "1rem" }} />
)}
</ActionIcon>
<ActionIcon
variant="quiet"
className="fsg-cat-action"
aria-label={t(
"fileSidebar.groupsModal.delete",
"Delete category",
)}
onClick={() => deleteCategory(category.id)}
>
<DeleteOutlineIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
{isExpanded && (
<div className="fsg-cat-body">
<div className="fsg-chips">
{memberKeys.map((key) => (
<LabelChip
key={key}
label={labelDisplay(key)}
icon={labelIcon(key)}
count={labelCounts.get(key)}
onRemove={() =>
removeLabelFromCategory(category.id, key)
}
removeAriaLabel={t(
"fileSidebar.groupsModal.removeLabel",
"Remove {{name}}",
{ name: labelDisplay(key) },
)}
/>
))}
</div>
<div className="fsg-add">
<input
className="fsg-add-input"
list={`fsg-vocab-${category.id}`}
value={addDraft[category.id] ?? ""}
placeholder={t(
"fileSidebar.groupsModal.addLabel",
"Add a label…",
)}
onChange={(e) =>
setAddDraft((prev) => ({
...prev,
[category.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addLabel(category.id);
}
}}
/>
<datalist id={`fsg-vocab-${category.id}`}>
{suggestions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "0.9rem" }} />}
onClick={() => addLabel(category.id)}
disabled={!(addDraft[category.id] ?? "").trim()}
>
{t("fileSidebar.groupsModal.add", "Add")}
</Button>
</div>
</div>
)}
</section>
);
})}
<div className="fsg-new">
<input
className="fsg-add-input"
value={newCategory}
placeholder={t(
"fileSidebar.groupsModal.newCategory",
"New category name…",
)}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
createCategory();
}
}}
/>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "0.9rem" }} />}
onClick={createCategory}
disabled={!newCategory.trim()}
>
{t("fileSidebar.groupsModal.createCategory", "Create category")}
</Button>
</div>
</div>
</Modal>
</>
@@ -35,7 +35,6 @@ describe("buildLabelGroups", () => {
stub("b", ["contract"]),
stub("c", ["nda", "contract"]),
],
[],
t,
[LEGAL],
)!;
@@ -44,20 +43,15 @@ describe("buildLabelGroups", () => {
expect(legal.stubs.map((s) => s.id)).toEqual(["a", "b", "c"]);
});
it("gives a label not in any category its own group", () => {
const groups = buildLabelGroups(
[stub("a", ["weird-one"])],
[{ id: "weird-one", name: "Weird one" }],
t,
[LEGAL],
)!;
const group = groups.find((g) => g.id === "label:weird-one")!;
expect(group.label).toBe("Weird one");
expect(group.stubs.map((s) => s.id)).toEqual(["a"]);
it("puts a label in no category into Other", () => {
const groups = buildLabelGroups([stub("a", ["weird-one"])], t, [LEGAL])!;
expect(groups.some((g) => g.id.startsWith("category:"))).toBe(false);
expect(groups.at(-1)!.id).toBe("other");
expect(groups.at(-1)!.stubs.map((s) => s.id)).toEqual(["a"]);
});
it("moves a hidden category's files into Other", () => {
const groups = buildLabelGroups([stub("a", ["invoice"])], [], t, [
const groups = buildLabelGroups([stub("a", ["invoice"])], t, [
cat("finance", "Financial", ["invoice"], true),
])!;
expect(groups.some((g) => g.id === "category:finance")).toBe(false);
@@ -66,7 +60,7 @@ describe("buildLabelGroups", () => {
});
it("a file with labels in two categories appears in both", () => {
const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], [], t, [
const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], t, [
LEGAL,
FINANCE,
])!;
@@ -82,7 +76,6 @@ describe("buildLabelGroups", () => {
it("puts unlabelled files in Other at the bottom", () => {
const groups = buildLabelGroups(
[stub("a", ["invoice"]), stub("b"), stub("c", [])],
[],
t,
[FINANCE],
)!;
@@ -91,7 +84,7 @@ describe("buildLabelGroups", () => {
expect(other.stubs.map((s) => s.id)).toEqual(["b", "c"]);
});
it("orders groups: Recent, groups alphabetically, Other last", () => {
it("orders groups: Recent, categories alphabetically, Other last", () => {
const groups = buildLabelGroups(
[
stub("a", ["invoice"]),
@@ -99,20 +92,19 @@ describe("buildLabelGroups", () => {
stub("c", ["zzz"]),
stub("d"),
],
[],
t,
[LEGAL, FINANCE],
)!;
// "zzz" is in no category, so its file falls to Other with the unlabelled one.
expect(groups.map((g) => g.id)).toEqual([
"recent",
"category:finance",
"category:legal",
"label:zzz",
"other",
]);
});
it("returns null for an empty library", () => {
expect(buildLabelGroups([], [], t, [LEGAL])).toBeNull();
expect(buildLabelGroups([], t, [LEGAL])).toBeNull();
});
});
@@ -1,4 +1,4 @@
// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (device-local, editable — default from the built-in label families), a standalone group for any label not yet in a category, then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap.
// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (the fixed, shared label families; each can be hidden device-local), then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap.
import {
useEffect,
@@ -10,7 +10,6 @@ import {
import { useTranslation } from "react-i18next";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
import { fileStorage } from "@app/services/fileStorage";
import { readStubClassificationLabels } from "@app/services/fileClassification";
import { hasInFlightPolicyRuns } from "@app/components/policies/policyRunStore";
@@ -55,7 +54,6 @@ export function useFileSidebarGroups(
// like core, and don't fetch team labels or backfill from metadata. Gates the
// whole feature so an AI-off SaaS tenant sees no Recent/Other/category chrome.
const enabled = useClassificationEnabled();
const { teamLabels: labelSet } = useClassificationLabels(enabled);
const { bumpRevision } = useIndexedDB();
// Attempted reads keyed by id+lastModified: a re-classified file (new version bumps lastModified) is re-read and leaves "Other" on its own, while a truly-unlabelled file keeps a stable key and is read once.
const attempted = useRef<Set<string>>(new Set());
@@ -113,7 +111,7 @@ export function useFileSidebarGroups(
getSidebarCategories,
);
return useMemo(
() => (enabled ? buildLabelGroups(stubs, labelSet, t, categories) : null),
[enabled, stubs, labelSet, t, categories],
() => (enabled ? buildLabelGroups(stubs, t, categories) : null),
[enabled, stubs, t, categories],
);
}
@@ -2,10 +2,7 @@
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
import { accentColor, accentCycleColor } from "@app/utils/accentColors";
import {
categorizedLabelKeys,
type SidebarCategory,
} from "@app/services/fileSidebarCategories";
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping";
@@ -30,37 +27,22 @@ export function bucketStubsByLabel(
}
/**
* Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then visible groups sorted
* alphabetically, then Other (files in no visible group) last. Visible groups are the non-hidden
* {@link SidebarCategory} entries with ≥1 file (a file lands in every category it has a label in),
* plus a standalone group for any label on a file that isn't in a category yet.
* Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then one group per VISIBLE
* category with ≥1 file (a file lands in every category it has a label in), sorted alphabetically,
* then Other (files in no visible category) last. Categories are the fixed, shared set; a category
* the user has hidden forms no group, so its files fall to Other.
*/
export function buildLabelGroups(
stubs: StirlingFileStub[],
labelSet: readonly { id: string; name: string; icon?: string }[],
t: (key: string, fallback: string) => string,
categories: SidebarCategory[],
): FileSidebarGroup[] | null {
if (stubs.length === 0) return null;
// Display name + icon per label id from the effective set; ids no longer in the
// set still group, resolving to the id text and the default icon.
const nameById = new Map<string, string>();
const iconById = new Map<string, string | undefined>();
for (const label of labelSet) {
nameById.set(label.id, label.name);
iconById.set(label.id, label.icon);
}
const labelName = (id: string) =>
t(`classification.labels.${id}`, nameById.get(id) ?? id);
const recent = [...stubs]
.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0))
.slice(0, RECENT_COUNT);
const byLabel = bucketStubsByLabel(stubs);
const inACategory = categorizedLabelKeys(categories);
const visible: Omit<FileSidebarGroup, "color">[] = [];
// One group per visible category: every file carrying any of its labels, in the input's order
@@ -81,19 +63,6 @@ export function buildLabelGroups(
});
}
// A label on a file but not in any category still gets its own group, so nothing is stranded
// until the user files it into a category.
for (const [labelId, bucket] of byLabel) {
if (inACategory.has(labelId)) continue;
visible.push({
id: `label:${labelId}`,
label: labelName(labelId),
icon: iconById.get(labelId) ?? DEFAULT_LABEL_ICON,
stubs: bucket.stubs,
defaultExpanded: false,
});
}
visible.sort((a, b) =>
a.label.localeCompare(b.label, undefined, { sensitivity: "base" }),
);