@@ -20,7 +20,7 @@ import {
2020} from '@/components/emcn'
2121import { useSession } from '@/lib/auth/auth-client'
2222import { isBillingEnabled } from '@/lib/core/config/env-flags'
23- import { PII_ENTITY_GROUPS } from '@/lib/guardrails/pii-entities'
23+ import { PII_ENTITY_GROUPS , SUPPORTED_PII_ENTITIES } from '@/lib/guardrails/pii-entities'
2424import { getUserRole } from '@/lib/workspaces/organization/utils'
2525import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
2626import { InfoNote } from '@/ee/components/info-note'
@@ -34,8 +34,7 @@ import { useWorkspacesQuery } from '@/hooks/queries/workspace'
3434
3535const logger = createLogger ( 'DataRetentionSettings' )
3636
37- /** Sentinel ChipSelect value representing the all-workspaces scope (`workspaceId: null`). */
38- const ALL_WORKSPACES = '__all__'
37+ const ENTITY_LABELS = SUPPORTED_PII_ENTITIES as Record < string , string >
3938
4039const DAY_OPTIONS = [
4140 { value : '1' , label : '1 day' } ,
@@ -51,12 +50,13 @@ const DAY_OPTIONS = [
5150 { value : 'never' , label : 'Forever' } ,
5251] as const
5352
54- /** Local editable shape of a PII redaction rule. */
53+ /**
54+ * Local editable shape of a PII redaction rule. `workspaceId: null` is the
55+ * all-workspaces default; a non-null id is a per-workspace override of it.
56+ */
5557interface RuleDraft {
5658 id : string
57- name : string
5859 entityTypes : string [ ]
59- /** null = all workspaces; otherwise the single targeted workspace. */
6060 workspaceId : string | null
6161}
6262
@@ -72,15 +72,16 @@ function daysToHours(days: string): number | null {
7272
7373function normalizeRule ( rule : RuleDraft ) : string {
7474 return JSON . stringify ( {
75- name : rule . name . trim ( ) ,
7675 entityTypes : [ ...rule . entityTypes ] . sort ( ) ,
7776 workspaceId : rule . workspaceId ,
7877 } )
7978}
8079
81- function entitySummary ( rule : RuleDraft ) : string {
82- if ( rule . entityTypes . length === 0 ) return 'None'
83- return `${ rule . entityTypes . length } entity type${ rule . entityTypes . length === 1 ? '' : 's' } `
80+ function entitySummary ( entityTypes : string [ ] ) : string {
81+ if ( entityTypes . length === 0 ) return 'Not redacted'
82+ const labels = entityTypes . map ( ( t ) => ENTITY_LABELS [ t ] ?? t )
83+ if ( labels . length <= 3 ) return labels . join ( ', ' )
84+ return `${ labels . slice ( 0 , 3 ) . join ( ', ' ) } +${ labels . length - 3 } more`
8485}
8586
8687interface RetentionSelectProps {
@@ -184,8 +185,8 @@ interface RuleModalProps {
184185 draft : RuleDraft
185186 isNew : boolean
186187 isSaving : boolean
187- /** Selectable scopes for this rule (excludes scopes taken by other rules ). */
188- scopeOptions : { value : string ; label : string } [ ]
188+ /** Workspaces selectable for an override (excludes those taken by other overrides ). */
189+ workspaceOptions : { value : string ; label : string } [ ]
189190 onChange : ( draft : RuleDraft ) => void
190191 onClose : ( ) => void
191192 onSave : ( ) => void
@@ -195,34 +196,32 @@ function RuleModal({
195196 draft,
196197 isNew,
197198 isSaving,
198- scopeOptions ,
199+ workspaceOptions ,
199200 onChange,
200201 onClose,
201202 onSave,
202203} : RuleModalProps ) {
204+ const isDefault = draft . workspaceId === null
203205 return (
204- < ChipModal open onOpenChange = { onClose } size = 'xl' srTitle = 'PII redaction rule ' >
206+ < ChipModal open onOpenChange = { onClose } size = 'xl' srTitle = 'PII redaction' >
205207 < ChipModalHeader onClose = { onClose } >
206- { isNew ? 'Add PII redaction rule' : 'Edit PII redaction rule' }
208+ { isDefault
209+ ? 'Default redaction · all workspaces'
210+ : isNew
211+ ? 'Add workspace override'
212+ : 'Edit workspace override' }
207213 </ ChipModalHeader >
208214 < ChipModalBody >
209- < ChipModalField
210- type = 'input'
211- title = 'Name (optional)'
212- value = { draft . name }
213- onChange = { ( value ) => onChange ( { ...draft , name : value } ) }
214- placeholder = 'e.g., Mask customer contact info'
215- />
216- < ChipModalField type = 'custom' title = 'Applies to' >
217- < ChipSelect
218- value = { draft . workspaceId ?? ALL_WORKSPACES }
219- onChange = { ( value ) =>
220- onChange ( { ...draft , workspaceId : value === ALL_WORKSPACES ? null : value } )
221- }
222- options = { scopeOptions }
223- align = 'start'
224- />
225- </ ChipModalField >
215+ { ! isDefault && (
216+ < ChipModalField type = 'custom' title = 'Workspace' >
217+ < ChipSelect
218+ value = { draft . workspaceId ?? '' }
219+ onChange = { ( value ) => onChange ( { ...draft , workspaceId : value } ) }
220+ options = { workspaceOptions }
221+ align = 'start'
222+ />
223+ </ ChipModalField >
224+ ) }
226225 < ChipModalField type = 'custom' title = 'Redact' >
227226 < EntityCheckboxGrid
228227 selected = { draft . entityTypes }
@@ -233,7 +232,7 @@ function RuleModal({
233232 < ChipModalFooter
234233 onCancel = { onClose }
235234 primaryAction = { {
236- label : isSaving ? 'Saving...' : isNew ? 'Add rule' : 'Save' ,
235+ label : isSaving ? 'Saving...' : 'Save' ,
237236 onClick : onSave ,
238237 disabled : isSaving ,
239238 } }
@@ -289,7 +288,6 @@ export function DataRetentionSettings() {
289288 setRules (
290289 ( data . configured . piiRedaction ?. rules ?? [ ] ) . map ( ( r ) => ( {
291290 id : r . id ,
292- name : r . name ?? '' ,
293291 entityTypes : r . entityTypes ,
294292 workspaceId : r . workspaceId ,
295293 } ) )
@@ -303,25 +301,19 @@ export function DataRetentionSettings() {
303301 modalOriginal !== null &&
304302 normalizeRule ( modalDraft ) !== normalizeRule ( modalOriginal )
305303
306- // Scope availability: at most one all-workspaces rule and one rule per workspace.
307- const allScopeTaken = rules . some ( ( r ) => r . workspaceId === null )
308- const takenWorkspaceIds = new Set (
309- rules . flatMap ( ( r ) => ( r . workspaceId === null ? [ ] : [ r . workspaceId ] ) )
310- )
304+ const defaultRule = rules . find ( ( r ) => r . workspaceId === null ) ?? null
305+ const overrideRules = rules . filter ( ( r ) => r . workspaceId !== null )
306+ const takenWorkspaceIds = new Set ( overrideRules . map ( ( r ) => r . workspaceId as string ) )
311307 const freeWorkspaces = workspaceOptions . filter ( ( w ) => ! takenWorkspaceIds . has ( w . value ) )
312- const hasAvailableScope = ! allScopeTaken || freeWorkspaces . length > 0
313-
314- /** Scopes selectable for `draft` — excludes scopes taken by OTHER rules. */
315- function scopeOptionsForDraft ( draft : RuleDraft ) : { value : string ; label : string } [ ] {
316- const others = rules . filter ( ( r ) => r . id !== draft . id )
317- const otherAll = others . some ( ( r ) => r . workspaceId === null )
318- const otherWs = new Set ( others . flatMap ( ( r ) => ( r . workspaceId === null ? [ ] : [ r . workspaceId ] ) ) )
319- const options : { value : string ; label : string } [ ] = [ ]
320- if ( ! otherAll ) options . push ( { value : ALL_WORKSPACES , label : 'All workspaces' } )
321- for ( const w of workspaceOptions ) {
322- if ( ! otherWs . has ( w . value ) ) options . push ( w )
323- }
324- return options
308+
309+ /** Workspaces selectable for `draft` — excludes workspaces taken by OTHER overrides. */
310+ function overrideOptionsForDraft ( draft : RuleDraft ) : { value : string ; label : string } [ ] {
311+ const otherTaken = new Set (
312+ rules
313+ . filter ( ( r ) => r . id !== draft . id && r . workspaceId !== null )
314+ . map ( ( r ) => r . workspaceId as string )
315+ )
316+ return workspaceOptions . filter ( ( w ) => ! otherTaken . has ( w . value ) )
325317 }
326318
327319 async function persistRules ( nextRules : RuleDraft [ ] ) {
@@ -332,7 +324,6 @@ export function DataRetentionSettings() {
332324 piiRedaction : {
333325 rules : nextRules . map ( ( r ) => ( {
334326 id : r . id ,
335- name : r . name . trim ( ) || undefined ,
336327 entityTypes : r . entityTypes ,
337328 workspaceId : r . workspaceId ,
338329 } ) ) ,
@@ -342,19 +333,23 @@ export function DataRetentionSettings() {
342333 setRules ( nextRules )
343334 }
344335
345- function openAddRule ( ) {
346- const blank : RuleDraft = {
347- id : generateId ( ) ,
348- name : '' ,
349- entityTypes : [ ] ,
350- workspaceId : allScopeTaken ? ( freeWorkspaces [ 0 ] ?. value ?? null ) : null ,
351- }
336+ function openEditDefault ( ) {
337+ const rule : RuleDraft = defaultRule ?? { id : generateId ( ) , entityTypes : [ ] , workspaceId : null }
338+ setModalIsNew ( defaultRule === null )
339+ setModalOriginal ( rule )
340+ setModalDraft ( { ...rule } )
341+ }
342+
343+ function openAddOverride ( ) {
344+ const workspaceId = freeWorkspaces [ 0 ] ?. value
345+ if ( ! workspaceId ) return
346+ const blank : RuleDraft = { id : generateId ( ) , entityTypes : [ ] , workspaceId }
352347 setModalIsNew ( true )
353348 setModalOriginal ( blank )
354349 setModalDraft ( blank )
355350 }
356351
357- function openEditRule ( rule : RuleDraft ) {
352+ function openEditOverride ( rule : RuleDraft ) {
358353 setModalIsNew ( false )
359354 setModalOriginal ( rule )
360355 setModalDraft ( { ...rule } )
@@ -382,21 +377,21 @@ export function DataRetentionSettings() {
382377 try {
383378 await persistRules ( next )
384379 clearModal ( )
385- toast . success ( 'PII redaction rule saved.' )
380+ toast . success ( 'PII redaction saved.' )
386381 } catch ( error ) {
387382 const msg = toError ( error ) . message
388- logger . error ( 'Failed to save PII redaction rule ' , { error : msg } )
383+ logger . error ( 'Failed to save PII redaction' , { error : msg } )
389384 toast . error ( msg )
390385 }
391386 }
392387
393388 async function removeRule ( id : string ) {
394389 try {
395390 await persistRules ( rules . filter ( ( r ) => r . id !== id ) )
396- toast . success ( 'PII redaction rule removed.' )
391+ toast . success ( 'Workspace override removed.' )
397392 } catch ( error ) {
398393 const msg = toError ( error ) . message
399- logger . error ( 'Failed to remove PII redaction rule ' , { error : msg } )
394+ logger . error ( 'Failed to remove workspace override ' , { error : msg } )
400395 toast . error ( msg )
401396 }
402397 }
@@ -497,42 +492,71 @@ export function DataRetentionSettings() {
497492 </ div >
498493 </ SettingsSection >
499494 < SettingsSection label = 'PII Redaction' >
500- < div className = 'flex flex-col gap-3' >
501- { rules . length > 0 && (
502- < div className = 'flex flex-col gap-2' >
503- { rules . map ( ( rule ) => (
504- < div
505- key = { rule . id }
506- className = 'flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
507- >
508- < div className = 'flex min-w-0 flex-col' >
509- < span className = 'truncate text-[var(--text-body)] text-small' >
510- { rule . name . trim ( ) || 'Untitled rule' }
511- </ span >
512- < span className = 'truncate text-[var(--text-muted)] text-caption' >
513- { rule . workspaceId === null
514- ? 'All workspaces'
515- : workspaceName ( rule . workspaceId ) } { ' ' }
516- · { entitySummary ( rule ) }
517- </ span >
518- </ div >
519- < div className = 'flex flex-shrink-0 items-center gap-2' >
520- < Chip onClick = { ( ) => openEditRule ( rule ) } > Edit</ Chip >
521- < Chip
522- onClick = { ( ) => removeRule ( rule . id ) }
523- disabled = { updateMutation . isPending }
524- >
525- Remove
526- </ Chip >
527- </ div >
528- </ div >
529- ) ) }
495+ < div className = 'flex flex-col gap-6' >
496+ < div className = 'flex flex-col gap-2' >
497+ < span className = 'font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide' >
498+ Default · all workspaces
499+ </ span >
500+ < div className = 'flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2' >
501+ < span className = 'truncate text-[var(--text-body)] text-small' >
502+ { entitySummary ( defaultRule ?. entityTypes ?? [ ] ) }
503+ </ span >
504+ < Chip onClick = { openEditDefault } > Edit</ Chip >
530505 </ div >
531- ) }
532- < div >
533- < Chip leftIcon = { Plus } onClick = { openAddRule } disabled = { ! hasAvailableScope } >
534- Add rule
535- </ Chip >
506+ </ div >
507+ < div className = 'flex flex-col gap-2' >
508+ < div className = 'flex items-center justify-between gap-3' >
509+ < div className = 'flex flex-col' >
510+ < span className = 'font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide' >
511+ Workspace overrides
512+ </ span >
513+ < span className = 'text-[var(--text-muted)] text-caption' >
514+ An override replaces the default for that workspace.
515+ </ span >
516+ </ div >
517+ < Chip
518+ leftIcon = { Plus }
519+ onClick = { openAddOverride }
520+ disabled = { freeWorkspaces . length === 0 }
521+ >
522+ Add override
523+ </ Chip >
524+ </ div >
525+ { overrideRules . length === 0 ? (
526+ < p className = 'text-[var(--text-muted)] text-caption' >
527+ No overrides — every workspace uses the default.
528+ </ p >
529+ ) : (
530+ < div className = 'flex flex-col gap-2' >
531+ { overrideRules . map ( ( rule ) => (
532+ < div
533+ key = { rule . id }
534+ className = 'flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
535+ >
536+ < div className = 'flex min-w-0 flex-col' >
537+ < span className = 'truncate text-[var(--text-body)] text-small' >
538+ { workspaceName ( rule . workspaceId as string ) }
539+ </ span >
540+ < span className = 'truncate text-[var(--text-muted)] text-caption' >
541+ { entitySummary ( rule . entityTypes ) }
542+ </ span >
543+ </ div >
544+ < div className = 'flex flex-shrink-0 items-center gap-2' >
545+ < Chip onClick = { ( ) => openEditOverride ( rule ) } > Edit</ Chip >
546+ < Chip
547+ onClick = { ( ) => removeRule ( rule . id ) }
548+ disabled = { updateMutation . isPending }
549+ >
550+ Remove
551+ </ Chip >
552+ </ div >
553+ </ div >
554+ ) ) }
555+ < span className = 'text-[var(--text-muted)] text-caption' >
556+ Workspaces not listed use the default.
557+ </ span >
558+ </ div >
559+ ) }
536560 </ div >
537561 </ div >
538562 </ SettingsSection >
@@ -543,7 +567,7 @@ export function DataRetentionSettings() {
543567 draft = { modalDraft }
544568 isNew = { modalIsNew }
545569 isSaving = { updateMutation . isPending }
546- scopeOptions = { scopeOptionsForDraft ( modalDraft ) }
570+ workspaceOptions = { overrideOptionsForDraft ( modalDraft ) }
547571 onChange = { setModalDraft }
548572 onClose = { requestCloseModal }
549573 onSave = { saveModalRule }
@@ -558,7 +582,7 @@ export function DataRetentionSettings() {
558582 < ChipModalHeader onClose = { ( ) => setShowUnsaved ( false ) } > Unsaved changes</ ChipModalHeader >
559583 < ChipModalBody >
560584 < p className = 'px-2 text-[var(--text-muted)] text-small' >
561- You have unsaved changes to this rule . Save them before closing?
585+ You have unsaved changes. Save them before closing?
562586 </ p >
563587 </ ChipModalBody >
564588 < ChipModalFooter
0 commit comments