Skip to content

Commit f599a7a

Browse files
feat(logs): default + workspace-overrides UI for PII redaction
Reshape the PII redaction settings into a 'Default (all workspaces)' block plus a 'Workspace overrides' list, making the most-specific-wins precedence explicit (overrides replace the default; unlisted workspaces use it). Same data model (workspaceId null = default), UI only.
1 parent b19296c commit f599a7a

1 file changed

Lines changed: 126 additions & 102 deletions

File tree

apps/sim/ee/data-retention/components/data-retention-settings.tsx

Lines changed: 126 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from '@/components/emcn'
2121
import { useSession } from '@/lib/auth/auth-client'
2222
import { 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'
2424
import { getUserRole } from '@/lib/workspaces/organization/utils'
2525
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
2626
import { InfoNote } from '@/ee/components/info-note'
@@ -34,8 +34,7 @@ import { useWorkspacesQuery } from '@/hooks/queries/workspace'
3434

3535
const 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

4039
const 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+
*/
5557
interface 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

7373
function 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

8687
interface 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

Comments
 (0)