diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 0262d5a78..3825e939a 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -12,6 +12,7 @@ import type { CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, + CollectionRole, CollectionInviteActionRequest, CollectionInviteCancelRequest, CollectionStatus, @@ -76,8 +77,10 @@ export const collectionsApi = { membership: (params: MembershipQuery): Promise => ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data), - invite: (collectionId: number, userId: number): Promise => - 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 => + 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 => + ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data), acceptInvite: (collectionId: number): Promise => ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data), declineInvite: (collectionId: number): Promise => diff --git a/client/src/components/Collections/CollectionList.tsx b/client/src/components/Collections/CollectionList.tsx index 2c6274133..ed7ee9d83 100644 --- a/client/src/components/Collections/CollectionList.tsx +++ b/client/src/components/Collections/CollectionList.tsx @@ -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({ ) })()} - onStatusChange(place.id, next)} t={t} /> + onStatusChange(place.id, next)} t={t} /> ) diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index eb28826fb..1df880d7b 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -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({ )} - {/* Status — always live */} - + {/* Status — live for editors, read-only for viewers */} + {}} t={t} /> {editing ? (
@@ -212,7 +213,7 @@ export default function CollectionPlaceDetail({ {canEdit && }
- {canEdit && } + {canDelete && } )}
diff --git a/client/src/components/Collections/ShareCollectionModal.tsx b/client/src/components/Collections/ShareCollectionModal.tsx index 46ae19337..4586c448b 100644 --- a/client/src/components/Collections/ShareCollectionModal.tsx +++ b/client/src/components/Collections/ShareCollectionModal.tsx @@ -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('') + const [inviteRole, setInviteRole] = useState('editor') + const [settingRoleId, setSettingRoleId] = useState(null) const [inviting, setInviting] = useState(false) const [cancellingId, setCancellingId] = useState(null) const [removingId, setRemovingId] = useState(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({ {t('collections.share.pending')} + ) : isOwner ? ( + ) : ( - - {t('collections.share.accepted')} + + {t(`collections.role.${member.role ?? 'editor'}`)} )} {isOwner && pending && ( @@ -238,6 +264,14 @@ export default function ShareCollectionModal({ searchable />
+ {t('collections.selectedCount', { count: c.selectedIds.length })}
- + {c.canEdit && ( + + )} - + {c.canDelete && ( + + )} @@ -278,7 +282,8 @@ export default function CollectionsPage(): React.ReactElement { {c.selectedPlace && c.view !== 'map' && ( 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, diff --git a/client/src/store/collectionStore.ts b/client/src/store/collectionStore.ts index e597a9d20..02ba8e081 100644 --- a/client/src/store/collectionStore.ts +++ b/client/src/store/collectionStore.ts @@ -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 duplicateToList: (placeIds: number[], targetId: number) => Promise - invite: (collectionId: number, userId: number) => Promise + invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise + setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise acceptInvite: (collectionId: number) => Promise declineInvite: (collectionId: number) => Promise cancelInvite: (collectionId: number, userId: number) => Promise @@ -275,8 +277,13 @@ export const useCollectionStore = create((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) }, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index b7de365b0..ce8b39ed9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3261,6 +3261,11 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE collections ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } try { db.exec('ALTER TABLE collection_places ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, + // Migration 152: per-member permission role on a shared list. Existing + // accepted members default to 'editor' so nothing regresses. + () => { + try { db.exec("ALTER TABLE collection_members ADD COLUMN role TEXT NOT NULL DEFAULT 'editor'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index ece4a0c87..e5e159519 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -391,6 +391,7 @@ function createTables(db: Database.Database): void { collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending', + role TEXT NOT NULL DEFAULT 'editor', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(collection_id, user_id) ); diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index 5f2a67167..1ec882275 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -39,6 +39,7 @@ import { collectionInviteActionRequestSchema, collectionInviteCancelRequestSchema, collectionRemoveMemberRequestSchema, + collectionSetMemberRoleRequestSchema, type CollectionCreateRequest, type CollectionUpdateRequest, type CollectionSavePlaceRequest, @@ -50,6 +51,7 @@ import { type CollectionInviteActionRequest, type CollectionInviteCancelRequest, type CollectionRemoveMemberRequest, + type CollectionSetMemberRoleRequest, } from '@trek/shared'; const MAX_COVER_SIZE = 20 * 1024 * 1024; @@ -190,7 +192,7 @@ export class CollectionsController { if (!this.collections.isOwner(user.id, body.collection_id)) { throw new HttpException({ error: 'Only the owner can invite' }, 403); } - const result = this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id); + const result = this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -243,6 +245,17 @@ export class CollectionsController { return { success: true }; } + @Post('members/role') + @HttpCode(200) + setMemberRole(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSetMemberRoleRequestSchema)) body: CollectionSetMemberRoleRequest) { + this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible + if (!this.collections.isOwner(user.id, body.collection_id)) { + throw new HttpException({ error: 'Only the owner can change member roles' }, 403); + } + this.collections.setMemberRole(user.id, body.collection_id, body.user_id, body.role); + return { success: true }; + } + // ── /:id (declared last so static prefixes win) ───────────────────────────── @Get(':id/available-users') availableUsers(@CurrentUser() user: User, @Param('id') id: string) { diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 40f2ab5e0..6e7b2be1c 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -42,13 +42,14 @@ export class CollectionsService { assertAccess(userId: number, collectionId: number) { return svc.assertAccess(userId, collectionId); } isOwner(userId: number, collectionId: number) { return svc.isOwner(userId, collectionId); } - sendInvite(collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number) { - return svc.sendInvite(collectionId, inviterId, inviterUsername, inviterEmail, targetUserId); + sendInvite(collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, role?: 'viewer' | 'editor' | 'admin') { + return svc.sendInvite(collectionId, inviterId, inviterUsername, inviterEmail, targetUserId, role); } acceptInvite(userId: number, collectionId: number, socketId?: string) { return svc.acceptInvite(userId, collectionId, socketId); } declineInvite(userId: number, collectionId: number, socketId?: string) { return svc.declineInvite(userId, collectionId, socketId); } cancelInvite(collectionId: number, ownerId: number, targetUserId: number) { return svc.cancelInvite(collectionId, ownerId, targetUserId); } leaveCollection(userId: number, collectionId: number, socketId?: string) { return svc.leaveCollection(userId, collectionId, socketId); } removeMember(ownerId: number, collectionId: number, targetUserId: number) { return svc.removeMember(ownerId, collectionId, targetUserId); } + setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin') { return svc.setMemberRole(ownerId, collectionId, targetUserId, role); } availableUsers(ownerId: number, collectionId: number) { return svc.availableUsers(ownerId, collectionId); } } diff --git a/server/src/services/collectionsService.ts b/server/src/services/collectionsService.ts index d85778ecc..6401d06be 100644 --- a/server/src/services/collectionsService.ts +++ b/server/src/services/collectionsService.ts @@ -90,6 +90,30 @@ export function isOwner(userId: number, collectionId: number): boolean { return !!row; } +export type EffectiveRole = 'owner' | 'admin' | 'editor' | 'viewer' | null; +/** The viewer's effective permission on a list: owner (full), or their accepted + * member role, or null when they have no access. */ +export function roleOf(userId: number, collectionId: number): EffectiveRole { + if (isOwner(userId, collectionId)) return 'owner'; + const row = db.prepare("SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'") + .get(collectionId, userId) as { role: string } | undefined; + if (!row) return null; + return row.role === 'admin' || row.role === 'viewer' ? row.role : 'editor'; +} +/** Add/edit a place — owner, admin or editor. 404 hides lists you can't see, + * 403 for a read-only (viewer) member. */ +export function assertCanEdit(userId: number, collectionId: number): void { + const r = roleOf(userId, collectionId); + if (r === null) httpError(404, 'Collection not found'); + if (r === 'viewer') httpError(403, 'You have read-only access to this list'); +} +/** Delete a place — owner or admin only. */ +export function assertCanDelete(userId: number, collectionId: number): void { + const r = roleOf(userId, collectionId); + if (r === null) httpError(404, 'Collection not found'); + if (r !== 'owner' && r !== 'admin') httpError(403, 'Only an admin can delete places from this list'); +} + function ownerOf(collectionId: number): number { const row = db.prepare('SELECT owner_id FROM collections WHERE id = ?').get(collectionId) as { owner_id: number } | undefined; if (!row) httpError(404, 'Collection not found'); @@ -163,13 +187,13 @@ function buildMembers(collectionId: number): CollectionMember[] { WHERE col.id = ? `).get(collectionId) as Omit | undefined; const members = db.prepare(` - SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status + SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role FROM collection_members cm JOIN users u ON u.id = cm.user_id WHERE cm.collection_id = ? ORDER BY u.username `).all(collectionId) as Omit[]; const result: CollectionMember[] = []; - if (owner) result.push({ ...owner, status: 'accepted', is_owner: true }); + if (owner) result.push({ ...owner, status: 'accepted', role: 'admin', is_owner: true }); for (const m of members) result.push({ ...m, is_owner: false }); return result; } @@ -239,7 +263,7 @@ export function createCollection(userId: number, body: CollectionCreateRequest): } export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Collection { - assertAccess(userId, id); + assertCanEdit(userId, id); const updates: string[] = []; const params: (string | number | null)[] = []; if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); } @@ -261,7 +285,7 @@ export function updateCollection(userId: number, id: number, body: CollectionUpd /** Set (or clear) a list's cover image, reclaiming the previous file. */ export function setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): Collection { - assertAccess(userId, id); + assertCanEdit(userId, id); const prev = (db.prepare('SELECT cover_image FROM collections WHERE id = ?').get(id) as { cover_image: string | null } | undefined)?.cover_image ?? null; db.prepare('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(coverUrl, id); if (prev && prev !== coverUrl) deleteOldCollectionCover(prev); @@ -367,7 +391,7 @@ function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): vo } export function savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult { - assertAccess(userId, body.collection_id); + assertCanEdit(userId, body.collection_id); if (!body.force) { const dup = findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng }); @@ -400,7 +424,7 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest, sock export function saveFromTripPlace( userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean, ): CollectionSaveResult { - assertAccess(userId, collectionId); + assertCanEdit(userId, collectionId); if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record | undefined; @@ -431,7 +455,7 @@ export function saveFromTripPlace( export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace { const currentCollection = collectionIdOfPlace(placeId); - assertAccess(userId, currentCollection); + assertCanEdit(userId, currentCollection); const updates: string[] = []; const params: (string | number | null)[] = []; @@ -444,7 +468,7 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek let movedTo: number | null = null; if (body.collection_id !== undefined && body.collection_id !== currentCollection) { - assertAccess(userId, body.collection_id); + assertCanEdit(userId, body.collection_id); updates.push('collection_id = ?'); params.push(body.collection_id); updates.push('owner_id = ?'); params.push(ownerOf(body.collection_id)); movedTo = body.collection_id; @@ -468,7 +492,7 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek export function setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace { const collectionId = collectionIdOfPlace(placeId); - assertAccess(userId, collectionId); + assertCanEdit(userId, collectionId); db.prepare("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, placeId); notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return getPlaceById(placeId); @@ -476,7 +500,7 @@ export function setStatus(userId: number, placeId: number, status: CollectionSta export function deletePlace(userId: number, placeId: number, socketId?: string): void { const collectionId = collectionIdOfPlace(placeId); - assertAccess(userId, collectionId); + assertCanDelete(userId, collectionId); db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim. notifyCollectionUsers(collectionId, socketId, 'collections:updated'); } @@ -486,7 +510,7 @@ export function deletePlacesMany(userId: number, ids: number[], socketId?: strin const touched = new Set(); for (const id of ids) { const collectionId = collectionIdOfPlace(id); - assertAccess(userId, collectionId); + assertCanDelete(userId, collectionId); db.prepare('DELETE FROM collection_places WHERE id = ?').run(id); deleted.push(id); touched.add(collectionId); @@ -616,6 +640,7 @@ export function notifyCollectionUsers(collectionId: number, excludeSid: string | export function sendInvite( collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, + role: 'viewer' | 'editor' | 'admin' = 'editor', ): { error?: string; status?: number } { if (!isOwner(inviterId, collectionId)) return { error: 'Not allowed', status: 403 }; if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; @@ -629,7 +654,7 @@ export function sendInvite( if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; } - db.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'pending')").run(collectionId, targetUserId); + db.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'pending', ?)").run(collectionId, targetUserId, role); broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId }); @@ -675,6 +700,15 @@ export function removeMember(ownerId: number, collectionId: number, targetUserId broadcastToUser(targetUserId, { type: 'collections:removed', collectionId }); // bounce the removed user } +/** Owner changes an accepted member's permission role (viewer/editor/admin). */ +export function setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin'): void { + if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + const res = db.prepare("UPDATE collection_members SET role = ? WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(role, collectionId, targetUserId); + if (res.changes === 0) httpError(404, 'Member not found'); + notifyCollectionUsers(collectionId, undefined, 'collections:updated'); // re-gate the member live + broadcastToUser(targetUserId, { type: 'collections:updated', collectionId }); +} + export function availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] { return db.prepare(` SELECT u.id, u.username FROM users u diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts index 0d7f12330..295bcf1dd 100644 --- a/shared/src/collection/collection.schema.ts +++ b/shared/src/collection/collection.schema.ts @@ -6,6 +6,13 @@ export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const; export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea'); export type CollectionStatus = (typeof COLLECTION_STATUSES)[number]; +/** Per-member permission on a shared list. viewer = read + copy-to-trip only; + * editor (default) = add + edit places; admin = full incl. delete. The owner + * is always full and is not a member row. */ +export const COLLECTION_ROLES = ['viewer', 'editor', 'admin'] as const; +export const collectionRoleSchema = z.enum(COLLECTION_ROLES).catch('editor').default('editor'); +export type CollectionRole = (typeof COLLECTION_ROLES)[number]; + /** A user-added link on a list or a saved place (stored as a JSON array). */ export const collectionLinkSchema = z.object({ label: z.string().max(120).optional(), @@ -55,6 +62,7 @@ export const collectionMemberSchema = z.object({ email: z.string().optional(), avatar: z.string().nullable().optional(), status: z.enum(['pending', 'accepted']), + role: collectionRoleSchema.optional(), is_owner: z.boolean().optional(), }); export type CollectionMember = z.infer; @@ -157,6 +165,7 @@ export type CollectionCopyToTripRequest = z.infer; @@ -176,6 +185,14 @@ export const collectionRemoveMemberRequestSchema = z.object({ }); export type CollectionRemoveMemberRequest = z.infer; +/** Owner changes an accepted member's permission role. */ +export const collectionSetMemberRoleRequestSchema = z.object({ + collection_id: z.number(), + user_id: z.number(), + role: collectionRoleSchema, +}); +export type CollectionSetMemberRoleRequest = z.infer; + // ── Responses ───────────────────────────────────────────────────────────── export const collectionListResponseSchema = z.object({ collections: z.array(collectionSchema), diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index a5d9cbc6f..b3c4d980a 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'الدور', + 'collections.role.viewer': 'مشاهد', + 'collections.role.editor': 'محرر', + 'collections.role.admin': 'مشرف', 'collections.picker.allLists': 'كل القوائم', 'collections.select': 'تحديد', 'collections.selectAll': 'تحديد الكل', diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index 326ae13a2..af5b2f13e 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Função', + 'collections.role.viewer': 'Visualizador', + 'collections.role.editor': 'Editor', + 'collections.role.admin': 'Administrador', 'collections.picker.allLists': 'Todas as listas', 'collections.select': 'Selecionar', 'collections.selectAll': 'Selecionar tudo', diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index b57c672cb..a48459ba5 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Role', + 'collections.role.viewer': 'Divák', + 'collections.role.editor': 'Editor', + 'collections.role.admin': 'Správce', 'collections.picker.allLists': 'Všechny seznamy', 'collections.select': 'Vybrat', 'collections.selectAll': 'Vybrat vše', diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index 97c57c553..42d2116d1 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Rolle', + 'collections.role.viewer': 'Betrachter', + 'collections.role.editor': 'Bearbeiter', + 'collections.role.admin': 'Admin', 'collections.picker.allLists': 'Alle Listen', 'collections.select': 'Auswählen', 'collections.selectAll': 'Alle auswählen', diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index c859c8948..589cab6c0 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Role', + 'collections.role.viewer': 'Viewer', + 'collections.role.editor': 'Editor', + 'collections.role.admin': 'Admin', 'collections.picker.allLists': 'All lists', 'collections.select': 'Select', 'collections.selectAll': 'Select all', diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index 6a9f5fd8a..c2b23b311 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Rol', + 'collections.role.viewer': 'Lector', + 'collections.role.editor': 'Editor', + 'collections.role.admin': 'Administrador', 'collections.picker.allLists': 'Todas las listas', 'collections.select': 'Seleccionar', 'collections.selectAll': 'Seleccionar todo', diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index d0b108f17..337dd3a3f 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Rôle', + 'collections.role.viewer': 'Lecteur', + 'collections.role.editor': 'Éditeur', + 'collections.role.admin': 'Admin', 'collections.picker.allLists': 'Toutes les listes', 'collections.select': 'Sélectionner', 'collections.selectAll': 'Tout sélectionner', diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index b0270510d..e3ed977b5 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Ρόλος', + 'collections.role.viewer': 'Θεατής', + 'collections.role.editor': 'Συντάκτης', + 'collections.role.admin': 'Διαχειριστής', 'collections.picker.allLists': 'Όλες οι λίστες', 'collections.select': 'Επιλογή', 'collections.selectAll': 'Επιλογή όλων', diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index fb7a66f26..425c2b7a1 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Szerep', + 'collections.role.viewer': 'Néző', + 'collections.role.editor': 'Szerkesztő', + 'collections.role.admin': 'Adminisztrátor', 'collections.picker.allLists': 'Összes lista', 'collections.select': 'Kijelölés', 'collections.selectAll': 'Összes kijelölése', diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index 6d22a097f..d381e8dd2 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Peran', + 'collections.role.viewer': 'Peninjau', + 'collections.role.editor': 'Editor', + 'collections.role.admin': 'Admin', 'collections.picker.allLists': 'Semua daftar', 'collections.select': 'Pilih', 'collections.selectAll': 'Pilih semua', diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index 5a52f901e..ead62b6cc 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Ruolo', + 'collections.role.viewer': 'Visualizzatore', + 'collections.role.editor': 'Editore', + 'collections.role.admin': 'Admin', 'collections.picker.allLists': 'Tutte le liste', 'collections.select': 'Seleziona', 'collections.selectAll': 'Seleziona tutto', diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index 3b251f62f..e9a6c5440 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': '役割', + 'collections.role.viewer': '閲覧者', + 'collections.role.editor': '編集者', + 'collections.role.admin': '管理者', 'collections.picker.allLists': 'すべてのリスト', 'collections.select': '選択', 'collections.selectAll': 'すべて選択', diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index 086f15c44..b3b3cf2bd 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': '역할', + 'collections.role.viewer': '뷰어', + 'collections.role.editor': '편집자', + 'collections.role.admin': '관리자', 'collections.picker.allLists': '모든 목록', 'collections.select': '선택', 'collections.selectAll': '모두 선택', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index e005749b7..448f45e06 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Rol', + 'collections.role.viewer': 'Kijker', + 'collections.role.editor': 'Bewerker', + 'collections.role.admin': 'Beheerder', 'collections.picker.allLists': 'Alle lijsten', 'collections.select': 'Selecteren', 'collections.selectAll': 'Alles selecteren', diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index 53e78380c..00e5cc367 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Rola', + 'collections.role.viewer': 'Widz', + 'collections.role.editor': 'Edytor', + 'collections.role.admin': 'Administrator', 'collections.picker.allLists': 'Wszystkie listy', 'collections.select': 'Zaznacz', 'collections.selectAll': 'Zaznacz wszystko', diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index c0ecd9dc7..f7e505161 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Роль', + 'collections.role.viewer': 'Наблюдатель', + 'collections.role.editor': 'Редактор', + 'collections.role.admin': 'Админ', 'collections.picker.allLists': 'Все списки', 'collections.select': 'Выбрать', 'collections.selectAll': 'Выбрать все', diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index 470465d77..61a306476 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Roll', + 'collections.role.viewer': 'Läsare', + 'collections.role.editor': 'Redigerare', + 'collections.role.admin': 'Admin', 'collections.picker.allLists': 'Alla listor', 'collections.select': 'Välj', 'collections.selectAll': 'Välj alla', diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index daf0c9fa0..9a612342c 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Rol', + 'collections.role.viewer': 'Görüntüleyici', + 'collections.role.editor': 'Editör', + 'collections.role.admin': 'Yönetici', 'collections.picker.allLists': 'Tüm listeler', 'collections.select': 'Seç', 'collections.selectAll': 'Tümünü seç', diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index 328e749db..eb138d6ba 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Роль', + 'collections.role.viewer': 'Глядач', + 'collections.role.editor': 'Редактор', + 'collections.role.admin': 'Адмін', 'collections.picker.allLists': 'Усі списки', 'collections.select': 'Вибрати', 'collections.selectAll': 'Вибрати все', diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index efdd585d8..a617a638a 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': 'Vai trò', + 'collections.role.viewer': 'Người xem', + 'collections.role.editor': 'Người sửa', + 'collections.role.admin': 'Quản trị', 'collections.picker.allLists': 'Tất cả danh sách', 'collections.select': 'Chọn', 'collections.selectAll': 'Chọn tất cả', diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index d68cacaa0..dbef6a5e3 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': '角色', + 'collections.role.viewer': '檢視者', + 'collections.role.editor': '編輯者', + 'collections.role.admin': '管理員', 'collections.picker.allLists': '所有清單', 'collections.select': '選擇', 'collections.selectAll': '全選', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index f892b5c42..853eafd34 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const collection: TranslationStrings = { + 'collections.role.label': '角色', + 'collections.role.viewer': '查看者', + 'collections.role.editor': '编辑者', + 'collections.role.admin': '管理员', 'collections.picker.allLists': '所有列表', 'collections.select': '选择', 'collections.selectAll': '全选',