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
@@ -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<typeof collectionMemberSchema>;
@@ -157,6 +165,7 @@ export type CollectionCopyToTripRequest = z.infer<typeof collectionCopyToTripReq
export const collectionInviteRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
role: collectionRoleSchema.optional(),
});
export type CollectionInviteRequest = z.infer<typeof collectionInviteRequestSchema>;
@@ -176,6 +185,14 @@ export const collectionRemoveMemberRequestSchema = z.object({
});
export type CollectionRemoveMemberRequest = z.infer<typeof collectionRemoveMemberRequestSchema>;
/** 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<typeof collectionSetMemberRoleRequestSchema>;
// ── Responses ─────────────────────────────────────────────────────────────
export const collectionListResponseSchema = z.object({
collections: z.array(collectionSchema),
+4
View File
@@ -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': 'تحديد الكل',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'Επιλογή όλων',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'すべて選択',
+4
View File
@@ -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': '모두 선택',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'Выбрать все',
+4
View File
@@ -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',
+4
View File
@@ -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ç',
+4
View File
@@ -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': 'Вибрати все',
+4
View File
@@ -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ả',
+4
View File
@@ -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': '全選',
+4
View File
@@ -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': '全选',