Skip to content

Commit 222b98b

Browse files
committed
feat: per-vault permissions (v2.20.0)
- Add vault_id column to team_permissions and user_permissions tables - Teams and users can have different permission levels per vault - Update get_user_permissions and user_has_permission functions for vault scoping - Add vault selector UI to PermissionsEditor and UserPermissionsDialog - Schema version bumped to v20
1 parent 74042ba commit 222b98b

File tree

8 files changed

+345
-48
lines changed

8 files changed

+345
-48
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to BluePLM will be documented in this file.
44

5+
## [2.20.0] - 2025-12-31
6+
7+
### Added
8+
- **Per-vault permissions**: Teams and individual users can now have different permission levels per vault. Set "All Vaults (Global)" for org-wide permissions, or select a specific vault for vault-scoped permissions. Example: Engineering team can have admin on Production vault but view-only on Archive vault
9+
10+
### Changed
11+
- **Schema version**: Bumped to v20
12+
- **Permission functions**: `get_user_permissions` and `user_has_permission` now accept optional `vault_id` parameter for vault-scoped checks
13+
14+
---
15+
516
## [2.19.3] - 2025-12-31
617

718
### Fixed

bluePLM.code-workspace

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"folders": [
33
{
44
"path": "."
5+
},
6+
{
7+
"path": "../blueplm-site"
58
}
69
],
710
"settings": {}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "blue-plm",
3-
"version": "2.19.3",
3+
"version": "2.20.0",
44
"description": "Product Lifecycle Management for everyone who builds",
55
"main": "dist-electron/main.js",
66
"scripts": {

src/components/settings/PermissionsEditor.tsx

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ import {
4141
hasPermission
4242
} from '../../types/permissions'
4343

44+
interface Vault {
45+
id: string
46+
name: string
47+
slug: string
48+
}
49+
4450
interface PermissionsEditorProps {
4551
team: Team
4652
onClose: () => void
@@ -122,11 +128,45 @@ export function PermissionsEditor({ team, onClose, userId, isAdmin }: Permission
122128
const [showPresets, setShowPresets] = useState(false)
123129
const [presets, setPresets] = useState<PermissionPreset[]>([])
124130

125-
// Load permissions
131+
// Vault scope state
132+
const [vaults, setVaults] = useState<Vault[]>([])
133+
const [selectedVaultId, setSelectedVaultId] = useState<string | null>(null) // null = "All Vaults"
134+
const [vaultsLoading, setVaultsLoading] = useState(true)
135+
136+
// Load vaults for this org
137+
useEffect(() => {
138+
const loadVaults = async () => {
139+
try {
140+
const { data: orgData } = await supabase
141+
.from('teams')
142+
.select('org_id')
143+
.eq('id', team.id)
144+
.single()
145+
146+
if (!orgData) return
147+
148+
const { data, error } = await supabase
149+
.from('vaults')
150+
.select('id, name, slug')
151+
.eq('org_id', orgData.org_id)
152+
.order('name')
153+
154+
if (error) throw error
155+
setVaults(data || [])
156+
} catch (err) {
157+
console.error('Failed to load vaults:', err)
158+
} finally {
159+
setVaultsLoading(false)
160+
}
161+
}
162+
loadVaults()
163+
}, [team.id])
164+
165+
// Load permissions when team or vault selection changes
126166
useEffect(() => {
127167
loadPermissions()
128168
loadPresets()
129-
}, [team.id])
169+
}, [team.id, selectedVaultId])
130170

131171
// Track changes
132172
useEffect(() => {
@@ -137,11 +177,22 @@ export function PermissionsEditor({ team, onClose, userId, isAdmin }: Permission
137177
const loadPermissions = async () => {
138178
setIsLoading(true)
139179
try {
140-
const { data, error } = await supabase
180+
// Build query - filter by vault_id (null for "All Vaults", specific ID for vault-specific)
181+
let query = supabase
141182
.from('team_permissions')
142183
.select('*')
143184
.eq('team_id', team.id)
144185

186+
if (selectedVaultId === null) {
187+
// "All Vaults" - only load global permissions (vault_id IS NULL)
188+
query = query.is('vault_id', null)
189+
} else {
190+
// Specific vault - load vault-specific permissions
191+
query = query.eq('vault_id', selectedVaultId)
192+
}
193+
194+
const { data, error } = await query
195+
145196
if (error) throw error
146197

147198
const permsMap: Record<string, PermissionAction[]> = {}
@@ -187,18 +238,29 @@ export function PermissionsEditor({ team, onClose, userId, isAdmin }: Permission
187238

188239
setIsSaving(true)
189240
try {
190-
// Delete existing permissions
191-
await supabase
241+
// Delete existing permissions for this vault scope only
242+
let deleteQuery = supabase
192243
.from('team_permissions')
193244
.delete()
194245
.eq('team_id', team.id)
195246

247+
if (selectedVaultId === null) {
248+
// Delete only global permissions (vault_id IS NULL)
249+
deleteQuery = deleteQuery.is('vault_id', null)
250+
} else {
251+
// Delete only vault-specific permissions
252+
deleteQuery = deleteQuery.eq('vault_id', selectedVaultId)
253+
}
254+
255+
await deleteQuery
256+
196257
// Insert new permissions (only those with at least one action)
197258
const newPerms = Object.entries(permissions)
198259
.filter(([_, actions]) => actions.length > 0)
199260
.map(([resource, actions]) => ({
200261
team_id: team.id,
201262
resource,
263+
vault_id: selectedVaultId, // null for "All Vaults", UUID for specific vault
202264
actions,
203265
granted_by: userId
204266
}))
@@ -213,7 +275,10 @@ export function PermissionsEditor({ team, onClose, userId, isAdmin }: Permission
213275

214276
setOriginalPermissions({ ...permissions })
215277
setHasChanges(false)
216-
addToast('success', 'Permissions saved successfully')
278+
const vaultName = selectedVaultId
279+
? vaults.find(v => v.id === selectedVaultId)?.name || 'selected vault'
280+
: 'all vaults'
281+
addToast('success', `Permissions saved for ${vaultName}`)
217282
} catch (err) {
218283
console.error('Failed to save permissions:', err)
219284
addToast('error', 'Failed to save permissions')
@@ -396,6 +461,27 @@ export function PermissionsEditor({ team, onClose, userId, isAdmin }: Permission
396461
</p>
397462
</div>
398463

464+
{/* Vault scope selector */}
465+
<div className="flex items-center gap-2">
466+
<Database size={16} className="text-plm-fg-muted" />
467+
<select
468+
value={selectedVaultId || 'all'}
469+
onChange={(e) => {
470+
const value = e.target.value
471+
setSelectedVaultId(value === 'all' ? null : value)
472+
}}
473+
disabled={vaultsLoading}
474+
className="px-3 py-1.5 text-sm bg-plm-bg border border-plm-border rounded-lg text-plm-fg focus:outline-none focus:border-plm-accent min-w-[160px]"
475+
>
476+
<option value="all">All Vaults (Global)</option>
477+
{vaults.map(vault => (
478+
<option key={vault.id} value={vault.id}>
479+
{vault.name}
480+
</option>
481+
))}
482+
</select>
483+
</div>
484+
399485
{/* Action buttons */}
400486
<div className="flex items-center gap-2">
401487
{hasChanges && (
@@ -810,6 +896,12 @@ export function PermissionsEditor({ team, onClose, userId, isAdmin }: Permission
810896
{Object.entries(permissions).filter(([_, a]) => a.length > 0).length} resources with permissions
811897
</span>
812898
</div>
899+
<div className="flex items-center gap-1.5">
900+
<Database size={14} />
901+
<span>
902+
Editing: {selectedVaultId ? vaults.find(v => v.id === selectedVaultId)?.name || 'Vault' : 'All Vaults (Global)'}
903+
</span>
904+
</div>
813905
{hasChanges && (
814906
<div className="flex items-center gap-1.5 text-plm-warning">
815907
<AlertTriangle size={14} />

src/components/settings/TeamMembersSettings.tsx

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4672,25 +4672,66 @@ function UserPermissionsDialog({
46724672
onClose: () => void
46734673
currentUserId?: string
46744674
}) {
4675-
const { addToast } = usePDMStore()
4675+
const { addToast, organization } = usePDMStore()
46764676
const [permissions, setPermissions] = useState<Record<string, PermissionAction[]>>({})
46774677
const [originalPermissions, setOriginalPermissions] = useState<Record<string, PermissionAction[]>>({})
46784678
const [isLoading, setIsLoading] = useState(true)
46794679
const [isSaving, setIsSaving] = useState(false)
46804680
const [searchQuery, setSearchQuery] = useState('')
46814681

4682+
// Vault scope state
4683+
const [vaults, setVaults] = useState<Vault[]>([])
4684+
const [selectedVaultId, setSelectedVaultId] = useState<string | null>(null) // null = "All Vaults"
4685+
const [vaultsLoading, setVaultsLoading] = useState(true)
4686+
4687+
// Load vaults for this org
4688+
useEffect(() => {
4689+
const loadVaults = async () => {
4690+
if (!organization?.id) {
4691+
setVaultsLoading(false)
4692+
return
4693+
}
4694+
try {
4695+
const { data, error } = await supabase
4696+
.from('vaults')
4697+
.select('id, name, slug')
4698+
.eq('org_id', organization.id)
4699+
.order('name')
4700+
4701+
if (error) throw error
4702+
setVaults(data || [])
4703+
} catch (err) {
4704+
console.error('Failed to load vaults:', err)
4705+
} finally {
4706+
setVaultsLoading(false)
4707+
}
4708+
}
4709+
loadVaults()
4710+
}, [organization?.id])
4711+
46824712
useEffect(() => {
46834713
loadPermissions()
4684-
}, [user.id])
4714+
}, [user.id, selectedVaultId])
46854715

46864716
const loadPermissions = async () => {
46874717
setIsLoading(true)
46884718
try {
4689-
const { data, error } = await supabase
4719+
// Build query - filter by vault_id
4720+
let query = supabase
46904721
.from('user_permissions')
46914722
.select('*')
46924723
.eq('user_id', user.id)
46934724

4725+
if (selectedVaultId === null) {
4726+
// "All Vaults" - only load global permissions
4727+
query = query.is('vault_id', null)
4728+
} else {
4729+
// Specific vault
4730+
query = query.eq('vault_id', selectedVaultId)
4731+
}
4732+
4733+
const { data, error } = await query
4734+
46944735
if (error) throw error
46954736

46964737
const permsMap: Record<string, PermissionAction[]> = {}
@@ -4712,15 +4753,27 @@ function UserPermissionsDialog({
47124753

47134754
setIsSaving(true)
47144755
try {
4715-
// Delete existing permissions
4716-
await supabase.from('user_permissions').delete().eq('user_id', user.id)
4756+
// Delete existing permissions for this vault scope
4757+
let deleteQuery = supabase
4758+
.from('user_permissions')
4759+
.delete()
4760+
.eq('user_id', user.id)
4761+
4762+
if (selectedVaultId === null) {
4763+
deleteQuery = deleteQuery.is('vault_id', null)
4764+
} else {
4765+
deleteQuery = deleteQuery.eq('vault_id', selectedVaultId)
4766+
}
4767+
4768+
await deleteQuery
47174769

47184770
// Insert new permissions
47194771
const newPerms = Object.entries(permissions)
47204772
.filter(([_, actions]) => actions.length > 0)
47214773
.map(([resource, actions]) => ({
47224774
user_id: user.id,
47234775
resource,
4776+
vault_id: selectedVaultId,
47244777
actions,
47254778
granted_by: currentUserId
47264779
}))
@@ -4730,7 +4783,10 @@ function UserPermissionsDialog({
47304783
if (error) throw error
47314784
}
47324785

4733-
addToast('success', `Permissions saved for ${user.full_name || user.email}`)
4786+
const vaultName = selectedVaultId
4787+
? vaults.find(v => v.id === selectedVaultId)?.name || 'selected vault'
4788+
: 'all vaults'
4789+
addToast('success', `Permissions saved for ${user.full_name || user.email} on ${vaultName}`)
47344790
onClose()
47354791
} catch (err) {
47364792
console.error('Failed to save permissions:', err)
@@ -4779,6 +4835,28 @@ function UserPermissionsDialog({
47794835
These permissions are added to any team permissions (union of all)
47804836
</p>
47814837
</div>
4838+
4839+
{/* Vault scope selector */}
4840+
<div className="flex items-center gap-2">
4841+
<Database size={16} className="text-plm-fg-muted" />
4842+
<select
4843+
value={selectedVaultId || 'all'}
4844+
onChange={(e) => {
4845+
const value = e.target.value
4846+
setSelectedVaultId(value === 'all' ? null : value)
4847+
}}
4848+
disabled={vaultsLoading}
4849+
className="px-3 py-1.5 text-sm bg-plm-bg border border-plm-border rounded-lg text-plm-fg focus:outline-none focus:border-plm-accent min-w-[160px]"
4850+
>
4851+
<option value="all">All Vaults (Global)</option>
4852+
{vaults.map(vault => (
4853+
<option key={vault.id} value={vault.id}>
4854+
{vault.name}
4855+
</option>
4856+
))}
4857+
</select>
4858+
</div>
4859+
47824860
<button onClick={onClose} className="p-2 text-plm-fg-muted hover:text-plm-fg hover:bg-plm-highlight rounded-lg">
47834861
<X size={18} />
47844862
</button>

src/lib/schemaVersion.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { supabase } from './supabase'
2121

2222
// The schema version this app version expects
2323
// Increment this when releasing app updates that require schema changes
24-
export const EXPECTED_SCHEMA_VERSION = 19
24+
export const EXPECTED_SCHEMA_VERSION = 20
2525

2626
// Minimum schema version that will still work (for soft warnings vs hard errors)
2727
// Set this to allow some backwards compatibility
@@ -48,6 +48,7 @@ export const VERSION_DESCRIPTIONS: Record<number, string> = {
4848
17: 'admin_remove_user RPC fully removes user from org and auth.users',
4949
18: 'Fix invited users being added to New Users team when they have specific teams',
5050
19: 'ensure_user_org_id creates user record if trigger failed (fixes invite after account deletion)',
51+
20: 'Per-vault permissions: vault_id column on team_permissions and user_permissions',
5152
}
5253

5354
export interface SchemaVersionInfo {

src/types/permissions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,19 @@ export interface TeamPermission {
222222
id: string
223223
team_id: string
224224
resource: string
225+
vault_id: string | null // NULL = all vaults, UUID = specific vault only
226+
actions: PermissionAction[]
227+
granted_at: string
228+
granted_by: string | null
229+
updated_at: string
230+
updated_by: string | null
231+
}
232+
233+
export interface UserPermission {
234+
id: string
235+
user_id: string
236+
resource: string
237+
vault_id: string | null // NULL = all vaults, UUID = specific vault only
225238
actions: PermissionAction[]
226239
granted_at: string
227240
granted_by: string | null

0 commit comments

Comments
 (0)