From c197e5978a127c7b2fbadf5c82541a6e46f11f95 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 30 Sep 2026 09:06:10 +0300 Subject: [PATCH] multiple groups per user --- development specification-v2.md | 29 +-- mobile-client/src/App.tsx | 113 +++++++++--- mobile-client/src/api.ts | 35 ++-- mobile-client/src/screens/CardsScreen.tsx | 50 +++++- mobile-client/src/screens/EditScreen.tsx | 5 +- mobile-client/src/screens/LoginScreen.tsx | 4 +- mobile-client/src/storage.ts | 57 +++++- mobile-client/src/styles.css | 49 +++++ mobile-client/src/sync.ts | 45 +++-- mobile-client/src/ui.tsx | 1 + server-be/src/routes/adminApi.ts | 62 ++++--- server-be/src/routes/clientApi.ts | 67 ++++--- server-be/src/users.ts | 207 ++++++++++++++-------- server-be/test/adminApi.test.ts | 95 +++++++++- server-be/test/clientApi.test.ts | 38 +++- server-fe/src/App.tsx | 9 +- server-fe/src/api.ts | 10 +- server-fe/src/pages/Groups.tsx | 9 +- server-fe/src/pages/Users.tsx | 106 ++++++----- shared/src/models.ts | 24 ++- shared/src/schemas.ts | 11 +- 21 files changed, 749 insertions(+), 277 deletions(-) diff --git a/development specification-v2.md b/development specification-v2.md index 3083f2a..41ec981 100644 --- a/development specification-v2.md +++ b/development specification-v2.md @@ -83,8 +83,9 @@ groups: name: Family users: - email: alice@example.com # уникален глобально, хранится в нижнем регистре - groupId: 3f1c… - role: superadmin # user | admin | superadmin + superadmin: true # глобальный флаг + memberships: # одна или несколько групп, в каждой своя роль + - { groupId: 3f1c…, role: admin } # user | admin keys: - id: 9a0b… # UUID label: Alice's phone # опционально @@ -121,13 +122,13 @@ cards: ### 3.3. Пользователи, группы, роли -- Пользователь состоит ровно в одной группе. +- Пользователь состоит в одной или нескольких группах (но не в нуле), в каждой — со своей ролью `user` или `admin`. `superadmin` — глобальный флаг: суперадмин — администратор всех групп. Записи старого формата (`groupId` + `role`) читаются как членство в одной группе и сохраняются в новом формате при первой записи. - `admin` — администратор только своей группы. `superadmin` — администратор всех групп. - Карты общие для всех пользователей группы. -- Группу, в которой есть хотя бы один суперадмин, удалить нельзя. При удалении группы удаляются все её пользователи, их ключи и все карты с картинками. -- Нельзя удалить самого себя и нельзя понизить свою роль. Нельзя удалить или понизить последнего суперадмина. -- `admin` может создавать, удалять и менять роль (`user`/`admin`) пользователей своей группы. Назначать `superadmin`, изменять суперадминов и переносить пользователей между группами может только `superadmin`. -- Новые ключи генерирует только админ (для пользователей своей группы) или суперадмин. +- Группу нельзя удалить, пока она единственная для какого-либо пользователя (сервер перечисляет таких пользователей). При удалении группы удаляются её карты с картинками и членства в ней; пользователи остаются в своих других группах. +- Нельзя удалить себя из группы, менять свою роль и свой флаг superadmin. Нельзя снять флаг с последнего суперадмина или удалить его. +- Админ группы добавляет в неё пользователей (новый email создаёт пользователя, существующий — добавляет ему членство), меняет их роль в этой группе и убирает их из неё; если группа была у пользователя единственной, пользователь удаляется вместе с ключами. Назначать и снимать superadmin и изменять суперадминов может только суперадмин. +- Новые ключи генерирует только админ (для участников своей группы) или суперадмин. Ключ принадлежит пользователю и даёт приложению доступ ко всем его группам. ### 3.4. Ключи доступа @@ -139,7 +140,7 @@ cards: ### 3.5. Аутентификация административного UI - Пользователь определяется по заголовку `Remote-Email` (без учёта регистра). Нет заголовка или пользователь не найден в `users.yaml` — `401`. Недостаточно прав — `403`. -- Текущая группа: для `user` и `admin` всегда своя группа. Для `superadmin` — значение cookie `cardy_group` (если его нет или группа не существует — своя группа); UI суперадмина позволяет выбрать другую группу, что записывает cookie. +- Текущая группа — значение cookie `cardy_group`, если пользователь в ней состоит (суперадмин — любая существующая группа); иначе первая группа пользователя. Переключатель в шапке есть у всех, у кого больше одной группы (у суперадмина — все группы). Роль и права в UI определяются ролью в текущей группе. - Если задан `ADMIN_PROXY_SECRET`, без правильного заголовка `X-Cardy-Proxy-Secret` — `401`. - Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF). @@ -163,9 +164,9 @@ cards: | `GET /my-keys` | все | свои ключи (расшифрованные) и deep link | | `GET /groups` | superadmin | список групп | | `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id` | superadmin | CRUD групп | -| `PUT /current-group` | superadmin | выбор текущей группы (cookie) | -| `GET /users` | admin | пользователи текущей группы с ключами | -| `POST /users`, `PATCH /users/:email`, `DELETE /users/:email` | admin | CRUD пользователей | +| `PUT /current-group` | все (своя группа), superadmin (любая) | выбор текущей группы (cookie) | +| `GET /users` | admin | участники текущей группы: роль в ней, флаг superadmin, все их группы, ключи | +| `POST /users` `{email, role}` | admin | добавить в текущую группу (новый email создаёт пользователя) | | `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей | | `GET /cards` | admin | все карты текущей группы, включая удалённые | | `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) | @@ -176,11 +177,11 @@ cards: ### 3.8. Клиентский API (публично `/api`, на сервере — от корня) -Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Группа определяется по владельцу ключа. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются. +Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Ключ даёт доступ ко всем группам владельца: маршруты карт принимают группу — `/api/groups/:groupId/cards…` (чужая группа — `404`); прежние маршруты без группы (`/api/cards…`) работают с первой группой владельца — для приложений, выпущенных до поддержки нескольких групп. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются. | Метод и путь | Описание | |---|---| -| `GET /api/auth` | проверка ключа; возвращает email и группу | +| `GET /api/auth` | проверка ключа; возвращает email, `groups` (все группы владельца) и `group` (первая) | | `GET /api/cards` | все неудалённые карты группы, без пагинации | | `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID | | `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий, пустые `tags` — теги; опционально файл `logo`, опционально `removeLogo=true`) | @@ -203,7 +204,7 @@ React + Vite + TS; PWA и Android-приложение через Capacitor. PWA - **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически. - **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает. - **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне. -- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить». +- **Группы**: приложение хранит список групп пользователя и текущую группу; карты, даты использования и версии картинок хранятся отдельно для каждой группы. Если групп больше одной, заголовок экрана карт — название текущей группы, нажатие открывает выбор группы; группа, карты которой ещё не скачивались на устройство, при выборе сразу обновляется. «Обновить» заодно обновляет список групп (данные групп, в которых пользователь больше не состоит, удаляются). Сессии старых версий приложения (без групп) при первом обновлении получают список групп, а их локальные карты переходят в первую группу. (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить». - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы. - **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). - **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. diff --git a/mobile-client/src/App.tsx b/mobile-client/src/App.tsx index 9f312bf..885250a 100644 --- a/mobile-client/src/App.tsx +++ b/mobile-client/src/App.tsx @@ -1,10 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseLoginDeepLink, type Card } from '@cardy/shared'; -import { createApi } from './api.js'; +import { createApi, type GroupApi } from './api.js'; import { useNavigation, type LoginPrefill } from './navigation.js'; import { onBackButton, onDeepLink, onResumeAfter, runOverlayBack } from './platform.js'; -import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js'; -import { refreshCardList, syncImages } from './sync.js'; +import { + LEGACY_GROUP_ID, + cardImageIds, + currentGroup, + images, + sessionGroups, + storage, + type PinRecord, + type Session, + type UsageMap, +} from './storage.js'; +import { refreshCardList, refreshGroups, syncImages } from './sync.js'; import { applyPendingUpdate, checkForApkUpdate, @@ -32,9 +42,12 @@ interface Boot { export function App() { const [boot, setBoot] = useState(); useEffect(() => { - void Promise.all([storage.getSession(), storage.getPin(), storage.getCards(), storage.getUsage()]).then( - ([session, pin, cards, usage]) => setBoot({ session, pin, cards, usage }), - ); + void (async () => { + const [session, pin] = await Promise.all([storage.getSession(), storage.getPin()]); + const groupId = session ? currentGroup(session).id : LEGACY_GROUP_ID; + const [cards, usage] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId)]); + setBoot({ session, pin, cards, usage }); + })(); }, []); if (!boot) return
; return
; @@ -64,25 +77,52 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { const route = stack[stack.length - 1]; const api = useMemo(() => (session ? createApi(session) : undefined), [session]); + // Cards shown are those of the current group (switchable when the user has several). + const groupId = session ? currentGroup(session).id : LEGACY_GROUP_ID; + const groupApi = useMemo(() => api?.forGroup(groupId), [api, groupId]); + const groupIdRef = useRef(groupId); + groupIdRef.current = groupId; + const sessionRef = useRef(session); + sessionRef.current = session; + + /** Shows the stored cards of a group. */ + const loadGroup = useCallback(async (id: string) => { + const [list, u] = await Promise.all([storage.getCards(id), storage.getUsage(id)]); + if (groupIdRef.current !== id) return; + setCards(list); + setUsage(u); + }, []); // ---- data ---- const refresh = useCallback(async () => { - if (!api) return; + const current = sessionRef.current; + if (!current) return; setSync({ busy: true }); let list: Card[]; + let target: string; + let targetApi: GroupApi; try { - list = await refreshCardList(api); + // Groups first: memberships may have changed (and legacy sessions get their groups here). + const updated = await refreshGroups(createApi(current), current); + target = currentGroup(updated).id; + sessionRef.current = updated; + groupIdRef.current = target; + setSession(updated); + targetApi = createApi(updated).forGroup(target); + list = await refreshCardList(targetApi, target); } catch (e) { setSync({ busy: false, error: (e as Error).message }); return; } + if (groupIdRef.current !== target) return setSync({ busy: false }); setCards(list); - setUsage(await storage.getUsage()); + setUsage(await storage.getUsage(target)); setSync({ busy: true, progress: { done: 0, total: 0 } }); try { const result = await syncImages( - api, + targetApi, + target, list, () => setImageEpoch((e) => e + 1), (done, total) => setSync({ busy: true, progress: { done, total } }), @@ -95,33 +135,54 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { } catch (e) { setSync({ busy: false, error: (e as Error).message }); } - }, [api]); + }, []); - /** Applies a card returned by the server after add/edit. */ + /** Switches to another group of the user; a group never downloaded to this device is refreshed. */ + const switchGroup = useCallback( + async (id: string) => { + const current = sessionRef.current; + if (!current || id === groupIdRef.current) return; + const next = { ...current, currentGroupId: id }; + await storage.setSession(next); + // Refs first: the refresh below must already see the new group (state updates land on the next render). + sessionRef.current = next; + groupIdRef.current = id; + setSession(next); + setSync({ busy: false }); + await loadGroup(id); + reset([{ name: 'cards' }]); + if (!(await storage.hasCards(id))) void refresh(); + }, + [loadGroup, reset, refresh], + ); + + /** Applies a card returned by the server after add/edit (in the current group). */ const saveCardLocally = useCallback(async (card: Card) => { - const list = await storage.getCards(); + const gid = groupIdRef.current; + const list = await storage.getCards(gid); const index = list.findIndex((c) => c.id === card.id); if (index >= 0) list[index] = card; else list.push(card); - await storage.setCards(list); + await storage.setCards(gid, list); // Images uploaded from this device are stored already; record the version only if nothing is missing. const stored = await images.storedKeys(); if (cardImageIds(card).every((id) => stored.has(images.key(card.id, id)))) { await images.prune(card.id, cardImageIds(card)); - const versions = await storage.getVersions(); + const versions = await storage.getVersions(gid); versions[card.id] = card.imagesVersion; - await storage.setVersions(versions); + await storage.setVersions(gid, versions); } setCards(list); setImageEpoch((e) => e + 1); }, []); const removeCardLocally = useCallback(async (id: string) => { - const [list, u, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]); + const gid = groupIdRef.current; + const [list, u, versions] = await Promise.all([storage.getCards(gid), storage.getUsage(gid), storage.getVersions(gid)]); const next = list.filter((c) => c.id !== id); delete u[id]; delete versions[id]; - await Promise.all([storage.setCards(next), storage.setUsage(u), storage.setVersions(versions), images.prune(id)]); + await Promise.all([storage.setCards(gid, next), storage.setUsage(gid, u), storage.setVersions(gid, versions), images.prune(id)]); setCards(next); setUsage({ ...u }); }, []); @@ -130,11 +191,12 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { const shownCardId = !locked && route?.name === 'card' ? route.id : undefined; useEffect(() => { if (!shownCardId) return; + const gid = groupIdRef.current; void (async () => { - const u = await storage.getUsage(); + const u = await storage.getUsage(gid); u[shownCardId] = new Date().toISOString(); - await storage.setUsage(u); - setUsage({ ...u }); + await storage.setUsage(gid, u); + if (groupIdRef.current === gid) setUsage({ ...u }); })(); }, [shownCardId, stack.length]); @@ -245,7 +307,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { // ---- screens ---- - if (!session || !api) { + if (!session || !api || !groupApi) { const prefill = route?.name === 'login' ? route.prefill : undefined; return ; } @@ -272,7 +334,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { onEdit={() => push({ name: 'edit', id: card.id })} onDelete={async () => { try { - await api.deleteCard(card.id); + await groupApi.deleteCard(card.id); } catch (e) { // Already deleted elsewhere: drop it locally as well. if ((e as { status?: number }).status !== 404) throw e; @@ -289,7 +351,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { (route.id ? goBack() : replaceTop({ name: 'card', id: saved.id }))} @@ -305,6 +367,9 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { imageEpoch={imageEpoch} sync={sync} session={session} + groups={sessionGroups(session)} + currentGroupId={groupId} + onSwitchGroup={switchGroup} apkUpdate={apkUpdate} onDismissSync={() => setSync({ busy: false })} onRefresh={refresh} diff --git a/mobile-client/src/api.ts b/mobile-client/src/api.ts index ae3dbc6..6e2b743 100644 --- a/mobile-client/src/api.ts +++ b/mobile-client/src/api.ts @@ -1,5 +1,5 @@ import type { ApiAuthResponse, Card } from '@cardy/shared'; -import type { Session } from './storage.js'; +import { LEGACY_GROUP_ID, type Session } from './storage.js'; export class ApiError extends Error { constructor( @@ -62,18 +62,29 @@ export function createApi(session: Session) { const enc = encodeURIComponent; + /** Card endpoints of one group; the legacy pseudo group uses the group-less routes (first group). */ + const forGroup = (groupId: string) => { + const g = groupId === LEGACY_GROUP_ID ? '' : `/groups/${enc(groupId)}`; + return { + cards: () => json('GET', `${g}/cards`), + createCard: (form: FormData) => json('POST', `${g}/cards`, form, UPLOAD_TIMEOUT), + updateCard: (id: string, form: FormData) => json('PUT', `${g}/cards/${enc(id)}`, form, UPLOAD_TIMEOUT), + deleteCard: async (id: string) => void (await send('DELETE', `${g}/cards/${enc(id)}`)), + addImages: (id: string, form: FormData) => json('POST', `${g}/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT), + removeImage: (id: string, imageId: string) => json('DELETE', `${g}/cards/${enc(id)}/images/${enc(imageId)}`), + /** Logo when `imageId` is the card's logo id, otherwise one of its images. */ + file: async (card: Card, imageId: string) => { + const path = + card.logo?.id === imageId ? `${g}/cards/${enc(card.id)}/logo` : `${g}/cards/${enc(card.id)}/images/${enc(imageId)}`; + return (await send('GET', path, undefined, UPLOAD_TIMEOUT)).blob(); + }, + }; + }; + return { auth: () => json('GET', '/auth'), - cards: () => json('GET', '/cards'), - createCard: (form: FormData) => json('POST', '/cards', form, UPLOAD_TIMEOUT), - updateCard: (id: string, form: FormData) => json('PUT', `/cards/${enc(id)}`, form, UPLOAD_TIMEOUT), - deleteCard: async (id: string) => void (await send('DELETE', `/cards/${enc(id)}`)), - addImages: (id: string, form: FormData) => json('POST', `/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT), - removeImage: (id: string, imageId: string) => json('DELETE', `/cards/${enc(id)}/images/${enc(imageId)}`), - /** Logo when `imageId` is the card's logo id, otherwise one of its images. */ - file: async (card: Card, imageId: string) => { - const path = card.logo?.id === imageId ? `/cards/${enc(card.id)}/logo` : `/cards/${enc(card.id)}/images/${enc(imageId)}`; - return (await send('GET', path, undefined, UPLOAD_TIMEOUT)).blob(); - }, + forGroup, }; } + +export type GroupApi = ReturnType; diff --git a/mobile-client/src/screens/CardsScreen.tsx b/mobile-client/src/screens/CardsScreen.tsx index 12e47bd..e6d2036 100644 --- a/mobile-client/src/screens/CardsScreen.tsx +++ b/mobile-client/src/screens/CardsScreen.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react'; -import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared'; +import { cardMatchesQuery, textColorOn, type Card, type Group } from '@cardy/shared'; import { ApkUpdateBanner } from '../ApkUpdateBanner.js'; import type { Session, UsageMap } from '../storage.js'; import type { PublishedApk } from '../updater.js'; @@ -19,6 +19,9 @@ export function CardsScreen({ imageEpoch, sync, session, + groups, + currentGroupId, + onSwitchGroup, apkUpdate, onDismissSync, onRefresh, @@ -31,6 +34,10 @@ export function CardsScreen({ imageEpoch: number; sync: SyncState; session: Session; + /** The user's groups (one or more). */ + groups: Group[]; + currentGroupId: string; + onSwitchGroup: (groupId: string) => void; apkUpdate?: PublishedApk; onDismissSync: () => void; onRefresh: () => void; @@ -40,15 +47,28 @@ export function CardsScreen({ }) { const [filter, setFilter] = useState(''); const [confirmKey, setConfirmKey] = useState(false); + const [pickingGroup, setPickingGroup] = useState(false); const filterInput = useRef(null); + const group = groups.find((g) => g.id === currentGroupId) ?? groups[0]!; // Search by name, tags and comment. const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]); + // With several groups the title is the current group and switches between them. + const title = + groups.length > 1 ? ( + + ) : ( + 'Cards' + ); + return ( setConfirmKey(true)} />} right={} > @@ -97,7 +117,7 @@ export function CardsScreen({ {cards.length === 0 ? (

No cards on this device yet.

-

Tap refresh to download the cards of {session.groupName ? `“${session.groupName}”` : 'your group'}, or add a new one.

+

Tap refresh to download the cards of “{group.name}”, or add a new one.

) : visible.length === 0 ? (
No cards match “{filter}”.
@@ -118,6 +138,30 @@ export function CardsScreen({ + {pickingGroup && ( +
e.target === e.currentTarget && setPickingGroup(false)}> +
+

Group

+
    + {groups.map((g) => ( +
  • + +
  • + ))} +
+
+
+ )} + {confirmKey && ( void; onSaved: (card: Card) => void; onMissing: (id: string) => void; diff --git a/mobile-client/src/screens/LoginScreen.tsx b/mobile-client/src/screens/LoginScreen.tsx index 8ffc642..164523b 100644 --- a/mobile-client/src/screens/LoginScreen.tsx +++ b/mobile-client/src/screens/LoginScreen.tsx @@ -38,7 +38,9 @@ export function LoginScreen({ setError(undefined); try { const me = await createApi({ server: normalized, key: trimmedKey }).auth(); - onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groupName: me.group.name }); + // `groups` comes from multi-group servers; older ones only report the single `group`. + const groups = me.groups?.length ? me.groups : [me.group]; + onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groups, currentGroupId: groups[0]!.id }); } catch (e) { setError((e as Error).message); setBusy(false); diff --git a/mobile-client/src/storage.ts b/mobile-client/src/storage.ts index 97caa47..6c31d7a 100644 --- a/mobile-client/src/storage.ts +++ b/mobile-client/src/storage.ts @@ -1,6 +1,6 @@ import { Preferences } from '@capacitor/preferences'; import { clear, createStore, del, delMany, get, keys, set } from 'idb-keyval'; -import type { Card } from '@cardy/shared'; +import type { Card, Group } from '@cardy/shared'; export interface Session { /** Base URL of the client API, e.g. https://dev.aggtaa.com/api */ @@ -8,9 +8,30 @@ export interface Session { key: string; /** From the server at sign-in, for display. */ email?: string; + /** The key owner's groups (one or more); missing in sessions from before multi-group. */ + groups?: Group[]; + /** Group whose cards are shown. */ + currentGroupId?: string; + /** Sessions from before multi-group: name of their only group. */ groupName?: string; } +/** + * Pseudo group of sessions from before multi-group: its data lives under the old storage keys and is + * served by the group-less API routes (the owner's first group) until the first sync moves it. + */ +export const LEGACY_GROUP_ID = ''; + +/** Groups of the session; a legacy session has the single pseudo group. */ +export function sessionGroups(session: Session): Group[] { + return session.groups?.length ? session.groups : [{ id: LEGACY_GROUP_ID, name: session.groupName ?? 'Cards' }]; +} + +export function currentGroup(session: Session): Group { + const groups = sessionGroups(session); + return groups.find((g) => g.id === session.currentGroupId) ?? groups[0]!; +} + export interface PinRecord { salt: string; hash: string; @@ -44,17 +65,39 @@ async function setJson(key: string, value: unknown): Promise { else await Preferences.set({ key, value: JSON.stringify(value) }); } +/** Per-group key; the legacy pseudo group keeps the old unsuffixed keys. */ +const groupKey = (base: string, groupId: string) => (groupId === LEGACY_GROUP_ID ? base : `${base}:${groupId}`); + export const storage = { getSession: () => getJson(K.session, null), setSession: (s: Session | null) => setJson(K.session, s), getPin: () => getJson(K.pin, null), setPin: (p: PinRecord | null) => setJson(K.pin, p), - getCards: () => getJson(K.cards, []), - setCards: (cards: Card[]) => setJson(K.cards, cards), - getUsage: () => getJson(K.usage, {}), - setUsage: (u: UsageMap) => setJson(K.usage, u), - getVersions: () => getJson(K.versions, {}), - setVersions: (v: VersionMap) => setJson(K.versions, v), + + // Cards, usage and image versions are stored per group. + getCards: (groupId: string) => getJson(groupKey(K.cards, groupId), []), + /** Whether cards of the group were ever downloaded to this device. */ + hasCards: async (groupId: string) => (await Preferences.get({ key: groupKey(K.cards, groupId) })).value !== null, + setCards: (groupId: string, cards: Card[]) => setJson(groupKey(K.cards, groupId), cards), + getUsage: (groupId: string) => getJson(groupKey(K.usage, groupId), {}), + setUsage: (groupId: string, u: UsageMap) => setJson(groupKey(K.usage, groupId), u), + getVersions: (groupId: string) => getJson(groupKey(K.versions, groupId), {}), + setVersions: (groupId: string, v: VersionMap) => setJson(groupKey(K.versions, groupId), v), + + /** Moves the data of one group key to another (legacy pseudo group -> the real first group). */ + async moveGroup(from: string, to: string): Promise { + for (const base of [K.cards, K.usage, K.versions]) { + const { value } = await Preferences.get({ key: groupKey(base, from) }); + if (value !== null) await Preferences.set({ key: groupKey(base, to), value }); + await Preferences.remove({ key: groupKey(base, from) }); + } + }, + + /** Forgets a group's cards (with their images), usage and image versions. */ + async removeGroup(groupId: string): Promise { + for (const card of await storage.getCards(groupId)) await images.prune(card.id); + for (const base of [K.cards, K.usage, K.versions]) await Preferences.remove({ key: groupKey(base, groupId) }); + }, /** Forgets everything: used when signing in with a new key. */ async clearAll(): Promise { diff --git a/mobile-client/src/styles.css b/mobile-client/src/styles.css index 3270126..28a7940 100644 --- a/mobile-client/src/styles.css +++ b/mobile-client/src/styles.css @@ -997,6 +997,55 @@ textarea:focus { color: var(--muted); } +/* Group switcher in the card list title. */ +.group-title { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 6px 10px; + border: none; + border-radius: 10px; + background: transparent; + font-size: 1.15rem; + font-weight: 600; +} + +.group-title span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.group-title:active:not(:disabled) { + background: var(--surface-2); +} + +.group-list { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.group-list button { + width: 100%; + min-height: 48px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); + text-align: left; + font-weight: 500; +} + +.group-list button.selected { + border-color: var(--accent); + color: var(--accent); +} + .dialog-actions { display: flex; justify-content: flex-end; diff --git a/mobile-client/src/sync.ts b/mobile-client/src/sync.ts index b0a6424..bfe3bfb 100644 --- a/mobile-client/src/sync.ts +++ b/mobile-client/src/sync.ts @@ -1,6 +1,6 @@ import type { Card } from '@cardy/shared'; -import type { Api } from './api.js'; -import { cardImageIds, images, storage, type UsageMap, type VersionMap } from './storage.js'; +import type { Api, GroupApi } from './api.js'; +import { LEGACY_GROUP_ID, cardImageIds, images, storage, type Session, type UsageMap, type VersionMap } from './storage.js'; export const MAX_PARALLEL_DOWNLOADS = 5; @@ -31,13 +31,35 @@ export function cardsNeedingImages(cards: Card[], versions: VersionMap, stored: } /** - * Downloads the list of cards and replaces the local list (only when the download succeeded). + * Re-reads the key owner's groups from the server and returns the updated session: + * - a legacy session (from before multi-group) gets its groups, and its local data moves to the first + * group (the one the group-less routes served); + * - local data of groups the user no longer belongs to is removed; + * - the current group falls back to the first group when it is gone. + */ +export async function refreshGroups(api: Api, session: Session): Promise { + const { groups } = await api.auth(); + const firstId = groups[0]!.id; + if (!session.groups?.length) { + await storage.moveGroup(LEGACY_GROUP_ID, firstId); + } else { + for (const old of session.groups) if (!groups.some((g) => g.id === old.id)) await storage.removeGroup(old.id); + } + const current = groups.some((g) => g.id === session.currentGroupId) ? session.currentGroupId : firstId; + const { groupName: _legacy, ...rest } = session; + const next: Session = { ...rest, groups, currentGroupId: current }; + await storage.setSession(next); + return next; +} + +/** + * Downloads the group's list of cards and replaces the local list (only when the download succeeded). * Cards gone from the server are removed locally with their images and usage. */ -export async function refreshCardList(api: Api): Promise { +export async function refreshCardList(api: GroupApi, groupId: string): Promise { const fresh = await api.cards(); - const [old, usage, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]); - await storage.setCards(fresh); + const [old, usage, versions] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId), storage.getVersions(groupId)]); + await storage.setCards(groupId, fresh); const freshIds = new Set(fresh.map((c) => c.id)); const gone = old.filter((c) => !freshIds.has(c.id)); @@ -47,7 +69,7 @@ export async function refreshCardList(api: Api): Promise { delete versions[c.id]; await images.prune(c.id); } - await Promise.all([storage.setUsage(usage), storage.setVersions(versions)]); + await Promise.all([storage.setUsage(groupId, usage), storage.setVersions(groupId, versions)]); } return fresh; } @@ -62,13 +84,14 @@ export interface ImageSyncResult { * A card's version is recorded only after all its files are stored, so failures are retried next time. */ export async function syncImages( - api: Api, + api: GroupApi, + groupId: string, cards: Card[], onCardDone?: (card: Card) => void, onProgress?: (done: number, total: number) => void, ): Promise { const stored = await images.storedKeys(); - const changed = cardsNeedingImages(cards, await storage.getVersions(), stored); + const changed = cardsNeedingImages(cards, await storage.getVersions(groupId), stored); const tasks = changed.flatMap((card) => cardImageIds(card) .filter((id) => !stored.has(images.key(card.id, id))) @@ -85,9 +108,9 @@ export async function syncImages( if (failedCards.has(card.id)) return versionWrites; versionWrites = versionWrites.then(async () => { await images.prune(card.id, cardImageIds(card)); - const versions = await storage.getVersions(); + const versions = await storage.getVersions(groupId); versions[card.id] = card.imagesVersion; - await storage.setVersions(versions); + await storage.setVersions(groupId, versions); onCardDone?.(card); }); return versionWrites; diff --git a/mobile-client/src/ui.tsx b/mobile-client/src/ui.tsx index 6bc3fb1..83cf998 100644 --- a/mobile-client/src/ui.tsx +++ b/mobile-client/src/ui.tsx @@ -24,6 +24,7 @@ const paths = { key: 'M14 10a4 4 0 1 0-3.5 3.97L10 14v3H8v2H5v-3l5.03-5.03A4 4 0 0 0 14 10zM15.5 8.5h.01', backspace: 'M21 5H8l-6 7 6 7h13zM11 9l6 6M17 9l-6 6', rotate: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7', + chevron: 'M6 9l6 6 6-6', } as const; export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) { diff --git a/server-be/src/routes/adminApi.ts b/server-be/src/routes/adminApi.ts index e0c419c..90e3cc4 100644 --- a/server-be/src/routes/adminApi.ts +++ b/server-be/src/routes/adminApi.ts @@ -9,6 +9,7 @@ import { userCreateSchema, userPatchSchema, type Group, + type GroupMembership, type MeResponse, } from '@cardy/shared'; import type { CardsService } from '../cards.js'; @@ -16,7 +17,7 @@ import { ADMIN_API_PREFIX } from '../config.js'; import { forwardedPrefix, publicBaseUrl } from '../forwarded.js'; import { badRequest, forbidden, notFound, unauthorized } from '../errors.js'; import { parseBody, readMultipart, sendImage } from '../http.js'; -import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js'; +import { requireGroupAdmin, requireSuperadmin, roleIn, type StoredUser, type UsersService } from '../users.js'; export const GROUP_COOKIE = 'cardy_group'; export const CSRF_HEADER = 'x-cardy-admin'; @@ -24,7 +25,8 @@ export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret'; export interface AdminContext { user: StoredUser; - ownGroup: Group; + /** The user's groups with their role in each. */ + memberships: GroupMembership[]; currentGroup: Group; } @@ -75,16 +77,22 @@ export const adminApi: FastifyPluginAsync = async (app, { users, cards, if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy'); const user = await users.findUser(email); if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`); - const ownGroup = await users.getGroup(user.groupId); - if (!ownGroup) throw unauthorized('User group does not exist'); - let currentGroup = ownGroup; - const cookieGroupId = request.cookies[GROUP_COOKIE]; - if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) { - currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup; - } - request.admin = { user, ownGroup, currentGroup }; + const memberships = await users.membershipsOf(user); + if (!memberships.length) throw unauthorized('None of your groups exists'); + request.admin = { user, memberships, currentGroup: await resolveCurrentGroup(user, memberships, request.cookies[GROUP_COOKIE]) }; }); + /** The group from the cookie if the user may use it (member, or any group for superadmins), else the first own group. */ + async function resolveCurrentGroup(user: StoredUser, memberships: GroupMembership[], cookieGroupId: string | undefined): Promise { + const first = memberships[0]!; + const fallback = { id: first.id, name: first.name }; + if (!cookieGroupId) return fallback; + const own = memberships.find((m) => m.id === cookieGroupId); + if (own) return { id: own.id, name: own.name }; + if (user.superadmin) return (await users.getGroup(cookieGroupId)) ?? fallback; + return fallback; + } + // Cookie path as the browser sees it, e.g. /api/admin. const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) => reply.setCookie(GROUP_COOKIE, groupId, { @@ -104,9 +112,15 @@ export const adminApi: FastifyPluginAsync = async (app, { users, cards, // ---- current user ---- app.get('/me', async (request, reply): Promise => { - const { user, ownGroup, currentGroup } = request.admin; + const { user, memberships, currentGroup } = request.admin; if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id); - return { email: user.email, role: user.role, group: ownGroup, currentGroup }; + return { + email: user.email, + superadmin: user.superadmin, + role: roleIn(user, currentGroup.id) ?? 'user', + groups: memberships, + currentGroup, + }; }); app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request))); @@ -134,38 +148,42 @@ export const adminApi: FastifyPluginAsync = async (app, { users, cards, return reply.code(204).send(); }); + /** Any user may switch between their groups; superadmins between all groups. */ app.put('/current-group', async (request, reply) => { - requireSuperadmin(request.admin.user); + const { user, memberships } = request.admin; const { groupId } = parseBody(currentGroupSchema, request.body); const group = await users.getGroup(groupId); if (!group) throw notFound('Group not found'); + if (!user.superadmin && !memberships.some((m) => m.id === group.id)) throw forbidden('You are not a member of this group'); setGroupCookie(request, reply, group.id); return group; }); - // ---- users of the current group ---- + // ---- members of the current group ---- app.get('/users', async (request) => { const { user, groupId } = groupAdmin(request); - return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request))); + const members = await users.listMembers(user, groupId); + return Promise.all(members.map((u) => users.userView(u, groupId, apiUrl(request)))); }); + /** Adds a member (an existing user from another group, or a new user). */ app.post('/users', async (request, reply) => { const { user, groupId } = groupAdmin(request); - const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body)); - return reply.code(201).send(users.userView(created, apiUrl(request))); + const added = await users.addMember(user, groupId, parseBody(userCreateSchema, request.body)); + return reply.code(201).send(await users.userView(added, groupId, apiUrl(request))); }); app.patch<{ Params: EmailParams }>('/users/:email', async (request) => { const { user, groupId } = groupAdmin(request); - const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body)); - return users.userView(updated, apiUrl(request)); + const updated = await users.updateMember(user, groupId, request.params.email, parseBody(userPatchSchema, request.body)); + return users.userView(updated, groupId, apiUrl(request)); }); - app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => { + /** Removes the member from the current group; a user without other groups is deleted. */ + app.delete<{ Params: EmailParams }>('/users/:email', async (request) => { const { user, groupId } = groupAdmin(request); - await users.deleteUser(user, groupId, request.params.email); - return reply.code(204).send(); + return users.removeMember(user, groupId, request.params.email); }); app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => { diff --git a/server-be/src/routes/clientApi.ts b/server-be/src/routes/clientApi.ts index c50f950..7befe90 100644 --- a/server-be/src/routes/clientApi.ts +++ b/server-be/src/routes/clientApi.ts @@ -1,8 +1,8 @@ import cors, { type FastifyCorsOptions } from '@fastify/cors'; -import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; +import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify'; import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared'; import type { CardsService } from '../cards.js'; -import { unauthorized } from '../errors.js'; +import { notFound, unauthorized } from '../errors.js'; import { parseBody, readMultipart, sendImage } from '../http.js'; import type { KeyOwner, UsersService } from '../users.js'; @@ -19,8 +19,7 @@ interface Options { const CLIENT_ACCESS = { includeDeleted: false }; -type IdParams = { id: string }; -type ImageParams = { id: string; imageId: string }; +type Params = Record; export const clientApi: FastifyPluginAsync = async (app, { users, cards }) => { // Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies. @@ -46,50 +45,64 @@ export const clientApi: FastifyPluginAsync = async (app, { users, cards request.keyOwner = owner; }); - const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id; - app.get('/auth', async (request): Promise => { - const { user, group } = request.keyOwner; - return { email: user.email, group }; + const { user, groups } = request.keyOwner; + return { email: user.email, groups, group: groups[0]! }; }); - app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS)); + // Cards of a group the key's owner belongs to. + cardRoutes(app, cards, '/groups/:groupId', (request) => { + const groupId = (request.params as Params).groupId; + if (!request.keyOwner.groups.some((g) => g.id === groupId)) throw notFound('Group not found'); + return groupId!; + }); + // Apps from before multi-group: the owner's first group. + cardRoutes(app, cards, '', (request) => request.keyOwner.groups[0]!.id); +}; - app.post('/cards', async (request, reply) => { +function cardRoutes(app: FastifyInstance, cards: CardsService, prefix: string, groupOf: (request: FastifyRequest) => string) { + const param = (request: FastifyRequest, name: string) => (request.params as Params)[name]!; + + app.get(`${prefix}/cards`, async (request) => cards.list(groupOf(request), CLIENT_ACCESS)); + + app.post(`${prefix}/cards`, async (request, reply) => { + const groupId = groupOf(request); const { fields, files } = await readMultipart(request); - const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]); + const card = await cards.create(groupId, parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]); return reply.code(201).send(card); }); - app.put<{ Params: IdParams }>('/cards/:id', async (request) => { + app.put(`${prefix}/cards/:id`, async (request) => { + const groupId = groupOf(request); const { fields, files } = await readMultipart(request); const { removeLogo, ...cardFields } = fields; const patch = parseBody(cardFieldsSchema.partial(), cardFields); const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined); - return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo); + return cards.update(groupId, param(request, 'id'), patch, CLIENT_ACCESS, logo); }); - app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => { - const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS); - await cards.setDeleted(groupId(request), card.id, true); + app.delete(`${prefix}/cards/:id`, async (request, reply) => { + const groupId = groupOf(request); + const card = await cards.get(groupId, param(request, 'id'), CLIENT_ACCESS); + await cards.setDeleted(groupId, card.id, true); return reply.code(204).send(); }); - app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => - sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)), + app.get(`${prefix}/cards/:id/logo`, async (request, reply) => + sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), 'logo', CLIENT_ACCESS)), ); - app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => { - const { id, imageId } = request.params; - return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS)); - }); + app.get(`${prefix}/cards/:id/images/:imageId`, async (request, reply) => + sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS)), + ); - app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => { + app.post(`${prefix}/cards/:id/images`, async (request) => { + const groupId = groupOf(request); const { files } = await readMultipart(request); - return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS); + return cards.addImages(groupId, param(request, 'id'), files.get('file') ?? [], CLIENT_ACCESS); }); - app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) => - cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS), + app.delete(`${prefix}/cards/:id/images/:imageId`, async (request) => + cards.removeImage(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS), ); -}; +} diff --git a/server-be/src/users.ts b/server-be/src/users.ts index 80b70fc..cb9fbc2 100644 --- a/server-be/src/users.ts +++ b/server-be/src/users.ts @@ -1,7 +1,16 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { z } from 'zod'; -import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared'; +import { + GROUP_ROLES, + buildLoginDeepLink, + type AccessKeyView, + type Group, + type GroupMembership, + type GroupRole, + type Role, + type UserView, +} from '@cardy/shared'; import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js'; import { badRequest, conflict, forbidden, notFound } from './errors.js'; import { YamlFile } from './yamlFile.js'; @@ -15,12 +24,29 @@ const storedKeySchema = z.object({ encrypted: z.string(), }); -const storedUserSchema = z.object({ - email: z.string().transform((s) => s.trim().toLowerCase()), - groupId: idSchema, - role: z.enum(ROLES), - keys: z.array(storedKeySchema).default([]), -}); +const membershipSchema = z.object({ groupId: idSchema, role: z.enum(GROUP_ROLES) }); + +/** Users from before multi-group ({ groupId, role }) are read as { superadmin, memberships }. */ +function upgradeLegacyUser(raw: unknown): unknown { + const u = raw as { groupId?: string; role?: string; memberships?: unknown } | null; + if (!u || typeof u !== 'object' || u.memberships !== undefined || typeof u.groupId !== 'string') return raw; + const { groupId, role, ...rest } = u; + return { + ...rest, + superadmin: role === 'superadmin', + memberships: [{ groupId, role: role === 'user' ? 'user' : 'admin' }], + }; +} + +const storedUserSchema = z.preprocess( + upgradeLegacyUser, + z.object({ + email: z.string().transform((s) => s.trim().toLowerCase()), + superadmin: z.boolean().default(false), + memberships: z.array(membershipSchema).min(1, 'A user must belong to at least one group'), + keys: z.array(storedKeySchema).default([]), + }), +); const usersFileSchema = z.object({ groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]), @@ -33,7 +59,8 @@ type UsersFile = z.infer; export interface KeyOwner { user: StoredUser; - group: Group; + /** The user's groups, in membership order (the first one serves the group-less client routes). */ + groups: Group[]; } export class UsersService { @@ -52,15 +79,14 @@ export class UsersService { ); } - /** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */ + /** Creates the configured superadmin (and their group) if missing, and enforces the superadmin flag. */ async ensureSuperadmin(email: string, groupName: string): Promise { const data = await this.file.read(); - const existing = data.users.find((u) => u.email === email); - if (existing?.role === 'superadmin') return; + if (data.users.find((u) => u.email === email)?.superadmin) return; await this.file.update((d) => { const user = d.users.find((u) => u.email === email); if (user) { - user.role = 'superadmin'; + user.superadmin = true; return; } let group = d.groups.find((g) => sameName(g.name, groupName)); @@ -68,7 +94,7 @@ export class UsersService { group = { id: randomUUID(), name: groupName }; d.groups.push(group); } - d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] }); + d.users.push({ email, superadmin: true, memberships: [{ groupId: group.id, role: 'admin' }], keys: [] }); }); } @@ -87,6 +113,15 @@ export class UsersService { return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name)); } + /** The user's groups with their role in each, in membership order. */ + async membershipsOf(user: StoredUser): Promise { + const { groups } = await this.file.read(); + return user.memberships.flatMap((m) => { + const group = groups.find((g) => g.id === m.groupId); + return group ? [{ ...group, role: m.role }] : []; + }); + } + async authenticateKey(key: string): Promise { const data = await this.file.read(); if (this.keyIndex?.source !== data) { @@ -98,8 +133,9 @@ export class UsersService { } const email = this.keyIndex.map.get(key); const user = email ? data.users.find((u) => u.email === email) : undefined; - const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined; - return user && group ? { user, group } : undefined; + if (!user) return undefined; + const groups = (await this.membershipsOf(user)).map(({ id, name }) => ({ id, name })); + return groups.length ? { user, groups } : undefined; } // ---- views ---- @@ -112,8 +148,15 @@ export class UsersService { }); } - userView(user: StoredUser, apiUrl: string): UserView { - return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) }; + /** A member of `groupId`, as shown to the admins of that group. */ + async userView(user: StoredUser, groupId: string, apiUrl: string): Promise { + return { + email: user.email, + superadmin: user.superadmin, + role: membership(user, groupId)?.role ?? 'user', + groups: await this.membershipsOf(user), + keys: this.keyViews(user, apiUrl), + }; } // ---- groups (superadmin) ---- @@ -139,86 +182,95 @@ export class UsersService { }); } - /** Removes the group with its users and keys. The caller removes the group's cards. */ + /** + * Removes the group and its memberships. Refused while the group is the only one of some user + * (users always belong to at least one group). The caller removes the group's cards. + */ async deleteGroup(actor: StoredUser, id: string): Promise { requireSuperadmin(actor); await this.file.update((d) => { if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found'); - if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) { - throw conflict('A group that contains a superadmin cannot be deleted'); + const onlyGroupOf = d.users.filter((u) => u.memberships.length === 1 && u.memberships[0]!.groupId === id); + if (onlyGroupOf.length) { + const list = onlyGroupOf.map((u) => u.email).join(', '); + throw conflict(`The group is the only group of ${list}. Add them to another group or remove them first.`); } d.groups = d.groups.filter((g) => g.id !== id); - d.users = d.users.filter((u) => u.groupId !== id); + for (const u of d.users) u.memberships = u.memberships.filter((m) => m.groupId !== id); }); } - // ---- users (admin of the current group) ---- + // ---- members of the current group (its admins) ---- - async listUsers(actor: StoredUser, groupId: string): Promise { + async listMembers(actor: StoredUser, groupId: string): Promise { requireGroupAdmin(actor, groupId); const data = await this.file.read(); - return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email)); + return data.users.filter((u) => membership(u, groupId)).sort((a, b) => a.email.localeCompare(b.email)); } - async createUser( - actor: StoredUser, - currentGroupId: string, - input: { email: string; role: Role; groupId?: string }, - ): Promise { - const groupId = input.groupId ?? currentGroupId; - if (groupId !== currentGroupId) requireSuperadmin(actor); + /** Adds a user to the group; creates the user when the email is new. */ + async addMember(actor: StoredUser, groupId: string, input: { email: string; role: GroupRole }): Promise { requireGroupAdmin(actor, groupId); - if (input.role === 'superadmin') requireSuperadmin(actor); return this.file.update((d) => { if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found'); - if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`); - const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] }; + const existing = d.users.find((u) => u.email === input.email); + if (existing) { + if (membership(existing, groupId)) throw conflict(`${input.email} is already in this group`); + existing.memberships.push({ groupId, role: input.role }); + return existing; + } + const user: StoredUser = { email: input.email, superadmin: false, memberships: [{ groupId, role: input.role }], keys: [] }; d.users.push(user); return user; }); } - async updateUser( + /** Changes the role in the group and/or (superadmins only) the global superadmin flag. */ + async updateMember( actor: StoredUser, - currentGroupId: string, + groupId: string, email: string, - patch: { role?: Role; groupId?: string }, + patch: { role?: GroupRole; superadmin?: boolean }, ): Promise { - if (patch.role === 'superadmin') requireSuperadmin(actor); - if (patch.groupId !== undefined) requireSuperadmin(actor); + if (patch.superadmin !== undefined) requireSuperadmin(actor); return this.file.update((d) => { - const user = findManagedUser(d, actor, currentGroupId, email); - if (patch.role !== undefined && patch.role !== user.role) { - if (user.email === actor.email) throw badRequest('You cannot change your own role'); - if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin'); - user.role = patch.role; + const user = findManagedMember(d, actor, groupId, email); + const self = user.email === actor.email; + const m = membership(user, groupId)!; + if (patch.role !== undefined && patch.role !== m.role) { + if (self) throw badRequest('You cannot change your own role'); + m.role = patch.role; } - if (patch.groupId !== undefined && patch.groupId !== user.groupId) { - if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found'); - user.groupId = patch.groupId; + if (patch.superadmin !== undefined && patch.superadmin !== user.superadmin) { + if (self) throw badRequest('You cannot change your own superadmin status'); + if (!patch.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin'); + user.superadmin = patch.superadmin; } return user; }); } - async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise { - await this.file.update((d) => { - const user = findManagedUser(d, actor, currentGroupId, email); - if (user.email === actor.email) throw badRequest('You cannot delete yourself'); - if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin'); + /** + * Removes the user from the group. For a user whose only group it is, this deletes the user with + * all keys (users always belong to at least one group). Returns whether the user was deleted. + */ + async removeMember(actor: StoredUser, groupId: string, email: string): Promise<{ deleted: boolean }> { + return this.file.update((d) => { + const user = findManagedMember(d, actor, groupId, email); + if (user.email === actor.email) throw badRequest('You cannot remove yourself'); + if (user.memberships.length > 1) { + user.memberships = user.memberships.filter((m) => m.groupId !== groupId); + return { deleted: false }; + } + if (user.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin'); d.users = d.users.filter((u) => u !== user); + return { deleted: true }; }); } - // ---- keys ---- + // ---- keys (a key signs the user in to all their groups) ---- - async createKey( - actor: StoredUser, - currentGroupId: string, - email: string, - label: string | undefined, - apiUrl: string, - ): Promise { + async createKey(actor: StoredUser, groupId: string, email: string, label: string | undefined, apiUrl: string): Promise { const plain = generateAccessKey(); const stored: StoredKey = { id: randomUUID(), @@ -227,7 +279,7 @@ export class UsersService { encrypted: encryptSecret(plain, this.secret), }; await this.file.update((d) => { - findManagedUser(d, actor, currentGroupId, email).keys.push(stored); + findManagedMember(d, actor, groupId, email).keys.push(stored); }); return { id: stored.id, @@ -238,9 +290,9 @@ export class UsersService { }; } - async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise { + async deleteKey(actor: StoredUser, groupId: string, email: string, keyId: string): Promise { await this.file.update((d) => { - const user = findManagedUser(d, actor, currentGroupId, email); + const user = findManagedMember(d, actor, groupId, email); const before = user.keys.length; user.keys = user.keys.filter((k) => k.id !== keyId); if (user.keys.length === before) throw notFound('Key not found'); @@ -253,11 +305,22 @@ function sameName(a: string, b: string): boolean { } function countSuperadmins(d: UsersFile): number { - return d.users.filter((u) => u.role === 'superadmin').length; + return d.users.filter((u) => u.superadmin).length; +} + +export function membership(user: StoredUser, groupId: string) { + return user.memberships.find((m) => m.groupId === groupId); +} + +/** Effective role in a group; undefined when the user is not a member (and not a superadmin). */ +export function roleIn(user: StoredUser, groupId: string): Role | undefined { + if (user.superadmin) return 'superadmin'; + return membership(user, groupId)?.role; } export function isGroupAdmin(actor: StoredUser, groupId: string): boolean { - return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId); + const role = roleIn(actor, groupId); + return role === 'superadmin' || role === 'admin'; } export function requireGroupAdmin(actor: StoredUser, groupId: string): void { @@ -265,15 +328,15 @@ export function requireGroupAdmin(actor: StoredUser, groupId: string): void { } export function requireSuperadmin(actor: StoredUser): void { - if (actor.role !== 'superadmin') throw forbidden(); + if (!actor.superadmin) throw forbidden(); } -/** A user of the current group that the actor may manage (only superadmins manage superadmins). */ -function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser { - requireGroupAdmin(actor, currentGroupId); +/** A member of the group that the actor may manage (only superadmins manage superadmins). */ +function findManagedMember(d: UsersFile, actor: StoredUser, groupId: string, email: string): StoredUser { + requireGroupAdmin(actor, groupId); const normalized = email.trim().toLowerCase(); - const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId); - if (!user) throw notFound('User not found'); - if (user.role === 'superadmin') requireSuperadmin(actor); + const user = d.users.find((u) => u.email === normalized && membership(u, groupId)); + if (!user) throw notFound('User not found in this group'); + if (user.superadmin) requireSuperadmin(actor); return user; } diff --git a/server-be/test/adminApi.test.ts b/server-be/test/adminApi.test.ts index 7ca76c6..3288fee 100644 --- a/server-be/test/adminApi.test.ts +++ b/server-be/test/adminApi.test.ts @@ -24,7 +24,7 @@ async function setup() { const { app, config } = await testApp(); const root = admin(app, SUPERADMIN); const me = (await root('GET', '/me')).body as MeResponse; - return { app, config, root, defaultGroup: me.group }; + return { app, config, root, defaultGroup: me.currentGroup }; } describe('admin API authentication', () => { @@ -32,7 +32,13 @@ describe('admin API authentication', () => { const { root } = await setup(); const { status, body } = await root('GET', '/me'); expect(status).toBe(200); - expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } }); + expect(body).toMatchObject({ + email: SUPERADMIN, + superadmin: true, + role: 'superadmin', + groups: [{ name: 'default', role: 'admin' }], + currentGroup: { name: 'default' }, + }); }); it('returns 401 without header or for unknown users', async () => { @@ -77,18 +83,33 @@ describe('groups and current group', () => { expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id); }); - it('ignores the group cookie for non-superadmins', async () => { + it('lets members switch only between their own groups', async () => { const { app, root, defaultGroup } = await setup(); const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; await root('POST', '/users', { email: 'adm@example.com', role: 'admin' }); const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`); + // Not a member of "Other" yet: the cookie is ignored and switching is refused. expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id); expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403); expect((await adm('GET', '/groups')).status).toBe(403); + + const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); + expect((await inOther('POST', '/users', { email: 'adm@example.com' })).status).toBe(201); + expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(200); + const me = (await adm('GET', '/me')).body as MeResponse; + expect(me.currentGroup.id).toBe(other.id); + expect(me.role).toBe('user'); + expect(me.groups.map((g) => [g.name, g.role])).toEqual([ + ['default', 'admin'], + ['Other', 'user'], + ]); + // Admin in "default" only: no admin rights in "Other". + expect((await adm('GET', '/users')).status).toBe(403); }); - it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => { + it('refuses to delete a group that is the only group of some user', async () => { const { app, config, root, defaultGroup } = await setup(); + // "default" is the superadmin's only group. expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409); const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; @@ -97,9 +118,16 @@ describe('groups and current group', () => { await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' }); expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true); + const refused = await root('DELETE', `/groups/${other.id}`); + expect(refused.status).toBe(409); + expect((refused.body as { error: string }).error).toContain('u@example.com'); + + // Once the user has another group, the group can go: its cards are removed, the user stays. + await root('POST', '/users', { email: 'u@example.com' }); expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204); expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false); - expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401); + const me = (await admin(app, 'u@example.com')('GET', '/me')).body as MeResponse; + expect(me.groups.map((g) => g.id)).toEqual([defaultGroup.id]); }); }); @@ -113,7 +141,8 @@ describe('users and keys', () => { expect((await user('GET', '/users')).status).toBe(403); expect((await user('GET', '/cards')).status).toBe(403); - expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403); + expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(400); + expect((await adm('PATCH', '/users/user@example.com', { superadmin: true })).status).toBe(403); expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403); expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403); expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400); @@ -121,7 +150,59 @@ describe('users and keys', () => { expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200); expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409); expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400); - expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400); + expect((await root('PATCH', `/users/${SUPERADMIN}`, { superadmin: false })).status).toBe(400); + + const promoted = await root('PATCH', '/users/user@example.com', { superadmin: true }); + expect((promoted.body as UserView).superadmin).toBe(true); + // Now a superadmin: plain admins can no longer manage them. + expect((await adm('PATCH', '/users/user@example.com', { role: 'user' })).status).toBe(403); + }); + + it('keeps roles per group and removes users from one group or entirely', async () => { + const { app, root } = await setup(); + const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; + const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); + await root('POST', '/users', { email: 'multi@example.com', role: 'admin' }); + const added = await inOther('POST', '/users', { email: 'multi@example.com', role: 'user' }); + expect(added.status).toBe(201); + expect((added.body as UserView).role).toBe('user'); + expect((added.body as UserView).groups.map((g) => [g.name, g.role])).toEqual([ + ['default', 'admin'], + ['Other', 'user'], + ]); + + // Leaving one group keeps the user. + expect((await inOther('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: false }); + const me = (await admin(app, 'multi@example.com')('GET', '/me')).body as MeResponse; + expect(me.groups.map((g) => g.name)).toEqual(['default']); + + // Removing the user from their only group deletes the user. + expect((await root('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: true }); + expect((await admin(app, 'multi@example.com')('GET', '/me')).status).toBe(401); + }); + + it('reads users.yaml from before multi-group', async () => { + const { mkdir, writeFile } = await import('node:fs/promises'); + const { testConfig } = await import('./helpers.js'); + const { buildApp } = await import('../src/app.js'); + const config = await testConfig(); + await mkdir(config.dataDir, { recursive: true }); + await writeFile( + path.join(config.dataDir, 'users.yaml'), + [ + 'groups:', + ' - { id: g1, name: Home }', + 'users:', + ` - { email: ${SUPERADMIN}, groupId: g1, role: superadmin, keys: [] }`, + ' - { email: adm@example.com, groupId: g1, role: admin, keys: [] }', + ' - { email: kid@example.com, groupId: g1, role: user, keys: [] }', + ].join('\n'), + ); + const app = await buildApp(config); + const view = (email: string) => admin(app, email)('GET', '/me').then((r) => r.body as MeResponse); + expect(await view(SUPERADMIN)).toMatchObject({ superadmin: true, role: 'superadmin', groups: [{ id: 'g1', role: 'admin' }] }); + expect(await view('adm@example.com')).toMatchObject({ superadmin: false, role: 'admin', groups: [{ id: 'g1', role: 'admin' }] }); + expect(await view('kid@example.com')).toMatchObject({ superadmin: false, role: 'user', groups: [{ id: 'g1', role: 'user' }] }); }); it('admin generates keys; users see their own keys with deep links', async () => { diff --git a/server-be/test/clientApi.test.ts b/server-be/test/clientApi.test.ts index 91700de..b3870f7 100644 --- a/server-be/test/clientApi.test.ts +++ b/server-be/test/clientApi.test.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { describe, expect, it } from 'vitest'; -import type { AccessKeyView, Card, Group } from '@cardy/shared'; +import type { AccessKeyView, ApiAuthResponse, Card, Group } from '@cardy/shared'; import { SUPERADMIN, multipart, testApp } from './helpers.js'; const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' }; @@ -179,6 +179,42 @@ describe('client API', () => { expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']); }); + it('serves each of the owner\'s groups and keeps /cards on the first one', async () => { + const { app, key } = await setup(); + const api = client(app, key); + const other = ( + await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } }) + ).json() as Group; + const stranger = ( + await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Stranger' } }) + ).json() as Group; + await app.inject({ + method: 'POST', + url: '/admin/users', + headers: { ...adminHeaders, cookie: `cardy_group=${other.id}` }, + payload: { email: SUPERADMIN }, + }); + + const auth = (await api('GET', '/auth')).json() as ApiAuthResponse; + expect(auth.groups.map((g) => g.name)).toEqual(['default', 'Other']); + expect(auth.group.name).toBe('default'); + + const inOther = (await api('POST', `/groups/${other.id}/cards`, cardParts({ name: 'In other' }))).json() as Card; + const inDefault = (await api('POST', `/groups/${auth.groups[0]!.id}/cards`, cardParts({ name: 'In default' }))).json() as Card; + expect(((await api('GET', `/groups/${other.id}/cards`)).json() as Card[]).map((c) => c.name)).toEqual(['In other']); + // The group-less routes (apps from before multi-group) use the first group. + expect(((await api('GET', '/cards')).json() as Card[]).map((c) => c.name)).toEqual(['In default']); + + // Cards are addressed within their group only. + expect((await api('DELETE', `/groups/${other.id}/cards/${inDefault.id}`)).statusCode).toBe(404); + expect((await api('PUT', `/groups/${other.id}/cards/${inOther.id}`, [{ name: 'name', value: 'Renamed' }])).json()).toMatchObject({ + name: 'Renamed', + }); + // Groups the owner is not a member of do not exist for the key. + expect((await api('GET', `/groups/${stranger.id}/cards`)).statusCode).toBe(404); + expect((await api('POST', `/groups/${stranger.id}/cards`, cardParts())).statusCode).toBe(404); + }); + it('returns JSON 404 for unknown API routes', async () => { const { app, key } = await setup(); const res = await client(app, key)('GET', '/nope'); diff --git a/server-fe/src/App.tsx b/server-fe/src/App.tsx index 374c4cc..ac1f51e 100644 --- a/server-fe/src/App.tsx +++ b/server-fe/src/App.tsx @@ -28,8 +28,10 @@ export function App() { function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise }) { const path = usePath(); + // Rights in the current group (admin role is per group; superadmin is global). const isAdmin = me.role !== 'user'; - const isSuper = me.role === 'superadmin'; + const isSuper = me.superadmin; + const canSwitch = isSuper || me.groups.length > 1; const tabs = [ ...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []), @@ -61,7 +63,7 @@ function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: ))}
- {isSuper ? : {me.currentGroup.name}} + {canSwitch ? : {me.currentGroup.name}} {me.email} · {me.role} @@ -72,8 +74,9 @@ function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: ); } +/** Superadmins switch between all groups, everyone else between their own groups. */ function GroupSwitcher({ me, onChanged, epoch }: { me: MeResponse; onChanged: () => Promise; epoch: number }) { - const groups = useLoad(api.groups, [epoch]); + const groups = useLoad(() => (me.superadmin ? api.groups() : Promise.resolve(me.groups)), [epoch, me.superadmin]); const [error, setError] = useState(); return (
@@ -145,6 +138,7 @@ function UserKeys({ user, onChanged }: { user: UserView; onChanged: () => Promis return (
+

A key signs the app in to all groups of the user.

{user.keys.length === 0 &&

No keys.

}
    {user.keys.map((k) => ( diff --git a/shared/src/models.ts b/shared/src/models.ts index 0fc945d..3af3038 100644 --- a/shared/src/models.ts +++ b/shared/src/models.ts @@ -1,13 +1,22 @@ import type { CodeType } from './codeTypes.js'; +/** Effective role of a user in a group: superadmin (global flag) > group admin > member. */ export const ROLES = ['user', 'admin', 'superadmin'] as const; export type Role = (typeof ROLES)[number]; +/** Role of a membership in one group. A user belongs to one or more groups. */ +export const GROUP_ROLES = ['user', 'admin'] as const; +export type GroupRole = (typeof GROUP_ROLES)[number]; + export interface Group { id: string; name: string; } +export interface GroupMembership extends Group { + role: GroupRole; +} + export interface ImageRef { id: string; file: string; @@ -41,22 +50,31 @@ export interface AccessKeyView { deepLink: string; } +/** A member of the current group, as shown to its admins. */ export interface UserView { email: string; - groupId: string; - role: Role; + superadmin: boolean; + /** Role in the current group. */ + role: GroupRole; + /** All groups of the user. */ + groups: GroupMembership[]; keys: AccessKeyView[]; } export interface MeResponse { email: string; + superadmin: boolean; + /** Effective role in the current group. */ role: Role; - group: Group; + groups: GroupMembership[]; currentGroup: Group; } export interface ApiAuthResponse { email: string; + /** All groups of the key's owner (one or more). */ + groups: Group[]; + /** First group: the one served by the group-less /cards routes (apps from before multi-group). */ group: Group; } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 83d20d6..2488e6b 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { CODE_TYPES } from './codeTypes.js'; -import { ROLES } from './models.js'; +import { GROUP_ROLES } from './models.js'; export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB'); @@ -63,15 +63,16 @@ export const groupInputSchema = z.object({ name: z.string().trim().min(1, 'Name is required').max(100), }); +/** Adds a user to the current group (creates the user when the email is new). */ export const userCreateSchema = z.object({ email: emailSchema, - role: z.enum(ROLES).default('user'), - groupId: z.string().optional(), + role: z.enum(GROUP_ROLES).default('user'), }); +/** Role in the current group; `superadmin` is the global flag (superadmins only). */ export const userPatchSchema = z.object({ - role: z.enum(ROLES).optional(), - groupId: z.string().optional(), + role: z.enum(GROUP_ROLES).optional(), + superadmin: z.boolean().optional(), }); export const keyCreateSchema = z.object({