feat(collections): per-member permission roles on shared lists (#1081)

The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.
This commit is contained in:
Maurice
2026-07-01 17:11:16 +02:00
committed by Maurice
parent f2972481fe
commit 1e0feb93dd
35 changed files with 263 additions and 40 deletions
+5 -2
View File
@@ -12,6 +12,7 @@ import type {
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
CollectionRole,
CollectionInviteActionRequest,
CollectionInviteCancelRequest,
CollectionStatus,
@@ -76,8 +77,10 @@ export const collectionsApi = {
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
invite: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
acceptInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
declineInvite: (collectionId: number): Promise<unknown> =>
@@ -12,7 +12,7 @@ interface CollectionListProps {
selectMode: boolean
selectedIds: number[]
onOpenPlace: (id: number) => void
onStatusChange: (placeId: number, status: CollectionStatus) => void
onStatusChange?: (placeId: number, status: CollectionStatus) => void
onToggleSelect: (id: number) => void
t: TranslationFn
}
@@ -72,7 +72,7 @@ export default function CollectionList({
</>
)
})()}
<StatusBadge status={place.status} onChange={selectMode ? undefined : next => onStatusChange(place.id, next)} t={t} />
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
)
@@ -20,6 +20,7 @@ function linkHost(url: string): string {
interface CollectionPlaceDetailProps {
place: CollectionPlace
canEdit: boolean
canDelete: boolean
categories: Category[]
/** When set, dock the sheet over that column (desktop split) instead of centred. */
anchorRect?: { left: number; width: number } | null
@@ -55,7 +56,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
* is an always-live segmented control (auto-saves).
*/
export default function CollectionPlaceDetail({
place, canEdit, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const toast = useToast()
const [editing, setEditing] = useState(false)
@@ -139,8 +140,8 @@ export default function CollectionPlaceDetail({
</div>
)}
{/* Status — always live */}
<StatusSegment status={place.status} onSet={onSetStatus} t={t} />
{/* Status — live for editors, read-only for viewers */}
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
{editing ? (
<div className="col-detail-edit">
@@ -212,7 +213,7 @@ export default function CollectionPlaceDetail({
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
<div className="col-detail-footer-spacer" />
{canEdit && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
</>
)}
</div>
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import { UserPlus, UserMinus, Loader2, Clock, Check, Crown, LogOut } from 'lucide-react'
import type { CollectionMember } from '@trek/shared'
import { UserPlus, UserMinus, Loader2, Clock, Crown, LogOut } from 'lucide-react'
import type { CollectionMember, CollectionRole } from '@trek/shared'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { useToast } from '../shared/Toast'
@@ -22,6 +22,8 @@ interface ShareCollectionModalProps {
t: TranslationFn
}
const ROLE_ORDER: CollectionRole[] = ['viewer', 'editor', 'admin']
function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement {
const initial = (member.username || '?').charAt(0).toUpperCase()
return (
@@ -57,10 +59,13 @@ export default function ShareCollectionModal({
const invite = useCollectionStore(s => s.invite)
const cancelInvite = useCollectionStore(s => s.cancelInvite)
const removeMember = useCollectionStore(s => s.removeMember)
const setMemberRole = useCollectionStore(s => s.setMemberRole)
const leave = useCollectionStore(s => s.leave)
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor')
const [settingRoleId, setSettingRoleId] = useState<number | null>(null)
const [inviting, setInviting] = useState(false)
const [cancellingId, setCancellingId] = useState<number | null>(null)
const [removingId, setRemovingId] = useState<number | null>(null)
@@ -97,7 +102,7 @@ export default function ShareCollectionModal({
if (selectedUserId === '' || inviting) return
setInviting(true)
try {
await invite(collectionId, Number(selectedUserId))
await invite(collectionId, Number(selectedUserId), inviteRole)
toast.success(t('collections.invite.sent'))
setSelectedUserId('')
} catch (err) {
@@ -107,6 +112,17 @@ export default function ShareCollectionModal({
}
}
const handleSetRole = async (userId: number, role: CollectionRole) => {
setSettingRoleId(userId)
try {
await setMemberRole(collectionId, userId, role)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSettingRoleId(null)
}
}
const handleCancel = async (userId: number) => {
if (cancellingId != null) return
setCancellingId(userId)
@@ -185,9 +201,19 @@ export default function ShareCollectionModal({
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Clock size={11} /> {t('collections.share.pending')}
</span>
) : isOwner ? (
<select
value={member.role ?? 'editor'}
onChange={e => handleSetRole(member.user_id, e.target.value as CollectionRole)}
disabled={settingRoleId === member.user_id}
aria-label={t('collections.role.label')}
className="shrink-0 text-[11.5px] font-medium rounded-md border border-edge bg-surface-input text-content-secondary px-1.5 py-1 outline-none focus:border-accent disabled:opacity-50"
>
{ROLE_ORDER.map(r => <option key={r} value={r}>{t(`collections.role.${r}`)}</option>)}
</select>
) : (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-emerald-500/12 text-emerald-600 dark:text-emerald-400 shrink-0">
<Check size={11} /> {t('collections.share.accepted')}
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-surface-secondary text-content-secondary shrink-0">
{t(`collections.role.${member.role ?? 'editor'}`)}
</span>
)}
{isOwner && pending && (
@@ -238,6 +264,14 @@ export default function ShareCollectionModal({
searchable
/>
</div>
<select
value={inviteRole}
onChange={e => setInviteRole(e.target.value as CollectionRole)}
aria-label={t('collections.role.label')}
className="shrink-0 text-[12.5px] font-medium rounded-lg border border-edge bg-surface-input text-content-secondary px-2 outline-none focus:border-accent"
>
{ROLE_ORDER.map(r => <option key={r} value={r}>{t(`collections.role.${r}`)}</option>)}
</select>
<button
type="button"
onClick={handleInvite}
+14 -9
View File
@@ -61,7 +61,7 @@ export default function CollectionsPage(): React.ReactElement {
const desktopSplit = c.isWide && mappable.length > 0
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
const canAddPlace = typeof c.activeId === 'number' // a real list, not "All saved"
const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit
const listEl = (
<CollectionList
@@ -70,7 +70,7 @@ export default function CollectionsPage(): React.ReactElement {
selectMode={c.selectMode}
selectedIds={c.selectedIds}
onOpenPlace={openPlace}
onStatusChange={c.handleStatusChange}
onStatusChange={c.canEdit ? c.handleStatusChange : undefined}
onToggleSelect={c.toggleSelect}
t={t}
/>
@@ -234,18 +234,22 @@ export default function CollectionsPage(): React.ReactElement {
</button>
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
</button>
{c.canEdit && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
</button>
)}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<CopyPlus size={14} /> {t('collections.duplicateToList')}
</button>
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyToTrip')}
</button>
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
</button>
{c.canDelete && (
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
</button>
)}
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
<X size={15} />
</button>
@@ -278,7 +282,8 @@ export default function CollectionsPage(): React.ReactElement {
{c.selectedPlace && c.view !== 'map' && (
<CollectionPlaceDetail
place={c.selectedPlace}
canEdit
canEdit={c.canEdit}
canDelete={c.canDelete}
categories={c.categories}
anchorRect={desktopSplit ? c.listColRect : null}
onClose={c.handleCloseDetail}
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
import { useElementSize } from '../../hooks/useElementSize'
import { useElementRect } from '../../hooks/useElementRect'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import { addListener, removeListener } from '../../api/websocket'
@@ -139,6 +140,18 @@ export function useCollections() {
const isAllSaved = activeId === ALL_SAVED
const isOwner = activeCollection?.is_owner ?? false
// The current user's permission on the active list drives what the UI offers.
// "All saved" spans many lists (each enforced per-place on the server), so it
// stays permissive on the client; a viewer of a shared list loses add/edit/delete.
const currentUserId = useAuthStore(s => s.user?.id)
const myRole = useMemo(() => {
if (isOwner) return 'owner' as const
const me = members.find(m => m.user_id === currentUserId && !m.is_owner)
return me?.role ?? null
}, [isOwner, members, currentUserId])
const canEdit = isAllSaved || myRole === 'owner' || myRole === 'admin' || myRole === 'editor'
const canDelete = isAllSaved || myRole === 'owner' || myRole === 'admin'
// Sharing is offered on any real (non "All saved") list the user can see. The
// badge counts everyone but the owner — accepted collaborators + pending invites.
const canShare = typeof activeId === 'number' && activeCollection != null
@@ -320,6 +333,7 @@ export function useCollections() {
listColRef: listCol.ref, listColRect: listCol.rect, categories,
// store data
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
myRole, canEdit, canDelete,
canShare, shareMemberCount,
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
+10 -3
View File
@@ -5,6 +5,7 @@ import type {
CollectionPlace,
CollectionMember,
CollectionStatus,
CollectionRole,
CollectionListResponse,
CollectionCreateRequest,
CollectionUpdateRequest,
@@ -56,7 +57,8 @@ interface CollectionState {
moveToList: (placeIds: number[], targetId: number) => Promise<void>
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
invite: (collectionId: number, userId: number) => Promise<void>
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
acceptInvite: (collectionId: number) => Promise<void>
declineInvite: (collectionId: number) => Promise<void>
cancelInvite: (collectionId: number, userId: number) => Promise<void>
@@ -275,8 +277,13 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
await get().loadAll()
},
invite: async (collectionId: number, userId: number) => {
await collectionsApi.invite(collectionId, userId)
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
await collectionsApi.invite(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
setMemberRole: async (collectionId: number, userId: number, role: CollectionRole) => {
await collectionsApi.setMemberRole(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},