mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +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:
@@ -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),
|
||||
|
||||
@@ -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': 'تحديد الكل',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Επιλογή όλων',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'すべて選択',
|
||||
|
||||
@@ -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': '모두 선택',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Выбрать все',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ç',
|
||||
|
||||
@@ -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': 'Вибрати все',
|
||||
|
||||
@@ -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ả',
|
||||
|
||||
@@ -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': '全選',
|
||||
|
||||
@@ -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': '全选',
|
||||
|
||||
Reference in New Issue
Block a user