diff --git a/lexicons/com/sds/repo/grantAccess.json b/lexicons/com/sds/repo/grantAccess.json index bcf89af5f9d..91ee0531e19 100644 --- a/lexicons/com/sds/repo/grantAccess.json +++ b/lexicons/com/sds/repo/grantAccess.json @@ -73,20 +73,32 @@ }, "permissions": { "type": "object", - "description": "Repository access permissions", - "required": ["read", "write"], + "description": "Repository access permissions aligned with OAuth's granular action model", + "required": ["read", "create", "update", "delete"], "properties": { "read": { "type": "boolean", "description": "Permission to read repository content." }, - "write": { + "create": { "type": "boolean", - "description": "Permission to write/modify repository content." + "description": "Permission to create new records in the repository." + }, + "update": { + "type": "boolean", + "description": "Permission to update existing records in the repository." + }, + "delete": { + "type": "boolean", + "description": "Permission to delete records from the repository." }, "admin": { "type": "boolean", "description": "Administrative permissions (manage collaborators, etc.)." + }, + "owner": { + "type": "boolean", + "description": "Owner permissions (full control including ownership transfer)." } } }, diff --git a/lexicons/com/sds/repo/transferOwnership.json b/lexicons/com/sds/repo/transferOwnership.json new file mode 100644 index 00000000000..f3877321c50 --- /dev/null +++ b/lexicons/com/sds/repo/transferOwnership.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "com.sds.repo.transferOwnership", + "defs": { + "main": { + "type": "procedure", + "description": "Transfer repository ownership to another user. Only the current owner can perform this operation.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repo", "newOwnerDid"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repository." + }, + "newOwnerDid": { + "type": "string", + "format": "did", + "description": "The DID of the new owner." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["success", "previousOwner", "newOwner", "transferredAt"], + "properties": { + "success": { + "type": "boolean" + }, + "previousOwner": { + "type": "string", + "format": "did" + }, + "newOwner": { + "type": "string", + "format": "did" + }, + "transferredAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "errors": [ + { + "name": "Unauthorized", + "description": "Only the repository owner can transfer ownership." + } + ] + } + } +} diff --git a/packages/dev-env/src/network-with-sds.ts b/packages/dev-env/src/network-with-sds.ts index 3a81a22014f..dc521e325a0 100644 --- a/packages/dev-env/src/network-with-sds.ts +++ b/packages/dev-env/src/network-with-sds.ts @@ -128,7 +128,9 @@ export class TestNetworkWithSds extends TestNetworkNoAppView { await lexiconAuthorityProfile.migrateTo(sds) await lexiconAuthorityProfile.createRecords() - console.log(`Lexicon authority ${lexiconAuthorityProfile.did} migrated to both PDS and SDS servers`) + console.log( + `Lexicon authority ${lexiconAuthorityProfile.did} migrated to both PDS and SDS servers`, + ) console.log(`PDS URL: ${pds.url}, SDS URL: ${sds.url}`) await ozone.addAdminDid(ozoneServiceProfile.did) diff --git a/packages/dev-env/src/service-profile.ts b/packages/dev-env/src/service-profile.ts index 41ecb8f2da6..cc530c478a1 100644 --- a/packages/dev-env/src/service-profile.ts +++ b/packages/dev-env/src/service-profile.ts @@ -24,7 +24,10 @@ export class ServiceProfile { return this.client.assertDid } - async migrateTo(newPds: TestPds | any, options: ServiceMigrationOptions = {}) { + async migrateTo( + newPds: TestPds | any, + options: ServiceMigrationOptions = {}, + ) { const newClient = newPds.getClient() const newPdsDesc = await newClient.com.atproto.server.describeServer() diff --git a/packages/sds-demo/src/components/collaboration-modal.tsx b/packages/sds-demo/src/components/collaboration-modal.tsx index f7fa05fe960..1df33bf0f10 100644 --- a/packages/sds-demo/src/components/collaboration-modal.tsx +++ b/packages/sds-demo/src/components/collaboration-modal.tsx @@ -1,19 +1,19 @@ // Repository Collaboration Modal - Manage repository access and collaborators import { useState } from 'react' -import { Button } from './button.tsx' -import { Spinner } from './spinner.tsx' import { + useCanManageRepository, useGrantAccessMutation, - useRevokeAccessMutation, useListCollaboratorsQuery, - useCanManageRepository, + useRevokeAccessMutation, } from '../queries/use-collaboration-queries.ts' import { - validateDid, + type RepositoryPermissions, formatCollaboratorName, getPermissionLevel, - type RepositoryPermissions, + validateDid, } from '../services/collaboration-service.ts' +import { Button } from './button.tsx' +import { Spinner } from './spinner.tsx' interface CollaborationModalProps { isOpen: boolean @@ -28,36 +28,44 @@ export function CollaborationModal({ repositoryDid, repositoryHandle, }: CollaborationModalProps) { - const [activeTab, setActiveTab] = useState<'collaborators' | 'add'>('collaborators') + const [activeTab, setActiveTab] = useState<'collaborators' | 'add'>( + 'collaborators', + ) const [userDid, setUserDid] = useState('') const [permissions, setPermissions] = useState({ read: true, write: false, }) - const [selectedRole, setSelectedRole] = useState<'viewer' | 'contributor' | 'admin'>('viewer') + const [selectedRole, setSelectedRole] = useState< + 'viewer' | 'contributor' | 'admin' + >('viewer') // Role definitions for collaborators (owner role is handled separately) const roles = { viewer: { name: 'Viewer', description: 'Can view repository content', - permissions: { read: true, write: false, admin: false } + permissions: { read: true, write: false, admin: false }, }, contributor: { name: 'Contributor', description: 'Can view and modify repository content', - permissions: { read: true, write: true, admin: false } + permissions: { read: true, write: true, admin: false }, }, admin: { name: 'Admin', description: 'Full access including user management', - permissions: { read: true, write: true, admin: true } - } + permissions: { read: true, write: true, admin: true }, + }, } // Query hooks const collaboratorsQuery = useListCollaboratorsQuery(repositoryDid, isOpen) - const { canManage, isDirectOwner, isLoading: canManageLoading } = useCanManageRepository(repositoryDid) + const { + canManage, + isDirectOwner, + isLoading: canManageLoading, + } = useCanManageRepository(repositoryDid) // Mutation hooks const grantAccessMutation = useGrantAccessMutation() @@ -100,7 +108,11 @@ export function CollaborationModal({ } const handleRevokeAccess = async (collaboratorDid: string) => { - if (!window.confirm(`Are you sure you want to revoke access for ${collaboratorDid}?`)) { + if ( + !window.confirm( + `Are you sure you want to revoke access for ${collaboratorDid}?`, + ) + ) { return } @@ -111,19 +123,24 @@ export function CollaborationModal({ }) } catch (error) { console.error('Failed to revoke access:', error) - alert(`Failed to revoke access: ${error instanceof Error ? error.message : 'Unknown error'}`) + alert( + `Failed to revoke access: ${error instanceof Error ? error.message : 'Unknown error'}`, + ) } } return (
-
+
{/* Header */}
-

Repository Collaboration

+

+ Repository Collaboration +

- Manage access to {repositoryHandle} + Manage access to{' '} + {repositoryHandle}

@@ -147,7 +174,8 @@ export function CollaborationModal({ : 'text-gray-500 hover:text-gray-700' }`} > - Collaborators ({collaboratorsQuery.data?.collaborators?.length || 0}) + Collaborators ({collaboratorsQuery.data?.collaborators?.length || 0} + ) + )}
- {canManage && ( - - )} -
- ))} + ), + )}
)} @@ -272,92 +334,114 @@ export function CollaborationModal({ )} - {activeTab === 'add' && ( - canManage ? ( -
-
- - setUserDid(e.target.value)} - placeholder="did:plc:example123..." - className="mt-1 w-full rounded-lg border border-gray-300 p-3 focus:border-blue-500 focus:outline-none" - /> -

- Enter the DID of the user you want to grant access to -

-
+ {activeTab === 'add' && + (canManage ? ( +
+
+ + setUserDid(e.target.value)} + placeholder="did:plc:example123..." + className="mt-1 w-full rounded-lg border border-gray-300 p-3 focus:border-blue-500 focus:outline-none" + /> +

+ Enter the DID of the user you want to grant access to +

+
-
- -
- {Object.entries(roles).map(([roleKey, role]) => ( -
) : (
- +
-

Owner Access Required

+

+ Owner Access Required +

Only repository owners can add new collaborators.

- ) - )} + ))} {/* Footer */}
@@ -366,4 +450,4 @@ export function CollaborationModal({
) -} \ No newline at end of file +} diff --git a/packages/sds-demo/src/components/permission-badge.tsx b/packages/sds-demo/src/components/permission-badge.tsx index 31f1c64a855..3c53b6f1a6a 100644 --- a/packages/sds-demo/src/components/permission-badge.tsx +++ b/packages/sds-demo/src/components/permission-badge.tsx @@ -1,5 +1,8 @@ // Permission Badge Component - Visual indicator for permission levels -import { type RepositoryPermissions, getPermissionLevel } from '../services/collaboration-service.ts' +import { + type RepositoryPermissions, + getPermissionLevel, +} from '../services/collaboration-service.ts' interface PermissionBadgeProps { permissions: RepositoryPermissions @@ -7,7 +10,11 @@ interface PermissionBadgeProps { className?: string } -export function PermissionBadge({ permissions, size = 'medium', className = '' }: PermissionBadgeProps) { +export function PermissionBadge({ + permissions, + size = 'medium', + className = '', +}: PermissionBadgeProps) { const level = getPermissionLevel(permissions) // Determine styling based on permission level @@ -19,9 +26,10 @@ export function PermissionBadge({ permissions, size = 'medium', className = '' } } const colorStyles = { - 'Owner': 'bg-red-100 text-red-800', - 'Admin': 'bg-purple-100 text-purple-800', + Owner: 'bg-red-100 text-red-800', + Admin: 'bg-purple-100 text-purple-800', 'Read & Write': 'bg-green-100 text-green-800', + 'Write Only': 'bg-amber-100 text-amber-800', 'Read Only': 'bg-blue-100 text-blue-800', 'No Access': 'bg-gray-100 text-gray-800', } @@ -29,11 +37,7 @@ export function PermissionBadge({ permissions, size = 'medium', className = '' } return `${baseStyles[size]} ${colorStyles[level as keyof typeof colorStyles]} font-medium rounded-full` } - return ( - - {level} - - ) + return {level} } interface DetailedPermissionBadgesProps { @@ -41,49 +45,63 @@ interface DetailedPermissionBadgesProps { className?: string } -export function DetailedPermissionBadges({ permissions, className = '' }: DetailedPermissionBadgesProps) { +export function DetailedPermissionBadges({ + permissions, + className = '', +}: DetailedPermissionBadgesProps) { + const permissionEntries: Array< + { label: string; value: boolean } & { + highlight?: 'primary' | 'warning' + } + > = [ + { label: 'Read', value: permissions.read, highlight: 'primary' }, + { label: 'Create', value: permissions.create, highlight: 'primary' }, + { label: 'Update', value: permissions.update, highlight: 'primary' }, + { label: 'Delete', value: permissions.delete, highlight: 'primary' }, + ] + + if (permissions.admin !== undefined) { + permissionEntries.push({ + label: 'Admin', + value: permissions.admin, + highlight: 'warning', + }) + } + + if (permissions.owner !== undefined) { + permissionEntries.push({ + label: 'Owner', + value: permissions.owner, + highlight: 'warning', + }) + } + + const getBadgeClasses = ( + value: boolean, + highlight: 'primary' | 'warning' = 'primary', + ) => { + if (value) { + return highlight === 'primary' + ? 'bg-green-100 text-green-700' + : 'bg-purple-100 text-purple-700' + } + + return 'bg-red-100 text-red-700' + } + return ( -
- - Read: {permissions.read ? '✓' : '✗'} - - - Write: {permissions.write ? '✓' : '✗'} - - {permissions.admin !== undefined && ( +
+ {permissionEntries.map(({ label, value, highlight }) => ( - Admin: {permissions.admin ? '✓' : '✗'} + {label}: {value ? '✓' : '✗'} - )} - {permissions.owner !== undefined && ( - - Owner: {permissions.owner ? '✓' : '✗'} - - )} + ))}
) -} \ No newline at end of file +} diff --git a/packages/sds-demo/src/components/repository-card.tsx b/packages/sds-demo/src/components/repository-card.tsx index 05fa4e9969d..e7e1da4f813 100644 --- a/packages/sds-demo/src/components/repository-card.tsx +++ b/packages/sds-demo/src/components/repository-card.tsx @@ -1,7 +1,11 @@ // Repository Card Component - Display repository info with collaboration features import { Repository } from '../contexts/repository-context.tsx' -import { Button } from './button.tsx' import { useListCollaboratorsQuery } from '../queries/use-collaboration-queries.ts' +import { Button } from './button.tsx' +import { + DetailedPermissionBadges, + PermissionBadge, +} from './permission-badge.tsx' interface RepositoryCardProps { repository: Repository @@ -16,11 +20,13 @@ export function RepositoryCard({ onSelect, onManageCollaborators, }: RepositoryCardProps) { - // Query to get collaborator count for this repository - // Only enable for owners to reduce simultaneous API calls on mount + const canManage = Boolean( + repository.permissions?.owner || repository.permissions?.admin, + ) + const collaboratorsQuery = useListCollaboratorsQuery( repository.did, - repository.accessType === 'owner', + canManage, ) const collaboratorCount = collaboratorsQuery.data?.collaborators?.length || 0 @@ -34,12 +40,20 @@ export function RepositoryCard({ > {/* Repository Header */}
-

- {repository.handle} -

+
+

+ {repository.handle} +

+ {repository.permissions && ( + + )} +
{/* Permissions Display */} -
-
- - Read: {repository.permissions?.read ? '✓' : '✗'} - - - Write: {repository.permissions?.write ? '✓' : '✗'} - -
-
+ {repository.permissions && ( + + )} {/* Collaborator Info and Management */}
@@ -95,8 +90,8 @@ export function RepositoryCard({ : 'No collaborators'} - {/* Manage Collaborators Button - Only show for owners */} - {repository.accessType === 'owner' && ( + {/* Manage Collaborators Button - Owners & Admins */} + {canManage && (
{/* Content Creation Panel */} - {selectedRepo && ( + {selectedRepo && selectedRepository && (

- Create Content in{' '} - {repositories.find((r) => r.did === selectedRepo)?.handle} + Create Content in {selectedRepository.handle}

- {repositories.find((r) => r.did === selectedRepo)?.permissions - .write ? ( + {selectedRepository.permissions && + (selectedRepository.permissions.create || + selectedRepository.permissions.update || + selectedRepository.permissions.delete) ? (