mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user