multiple groups per user

This commit is contained in:
azykov@mail.ru
2026-09-30 09:06:10 +03:00
parent 45158cde38
commit c197e5978a
21 changed files with 749 additions and 277 deletions
+15 -14
View File
@@ -83,8 +83,9 @@ groups:
name: Family name: Family
users: users:
- email: alice@example.com # уникален глобально, хранится в нижнем регистре - email: alice@example.com # уникален глобально, хранится в нижнем регистре
groupId: 3f1c… superadmin: true # глобальный флаг
role: superadmin # user | admin | superadmin memberships: # одна или несколько групп, в каждой своя роль
- { groupId: 3f1c…, role: admin } # user | admin
keys: keys:
- id: 9a0b… # UUID - id: 9a0b… # UUID
label: Alice's phone # опционально label: Alice's phone # опционально
@@ -121,13 +122,13 @@ cards:
### 3.3. Пользователи, группы, роли ### 3.3. Пользователи, группы, роли
- Пользователь состоит ровно в одной группе. - Пользователь состоит в одной или нескольких группах (но не в нуле), в каждой — со своей ролью `user` или `admin`. `superadmin` — глобальный флаг: суперадмин — администратор всех групп. Записи старого формата (`groupId` + `role`) читаются как членство в одной группе и сохраняются в новом формате при первой записи.
- `admin` — администратор только своей группы. `superadmin` — администратор всех групп. - `admin` — администратор только своей группы. `superadmin` — администратор всех групп.
- Карты общие для всех пользователей группы. - Карты общие для всех пользователей группы.
- Группу, в которой есть хотя бы один суперадмин, удалить нельзя. При удалении группы удаляются все её пользователи, их ключи и все карты с картинками. - Группу нельзя удалить, пока она единственная для какого-либо пользователя (сервер перечисляет таких пользователей). При удалении группы удаляются её карты с картинками и членства в ней; пользователи остаются в своих других группах.
- Нельзя удалить самого себя и нельзя понизить свою роль. Нельзя удалить или понизить последнего суперадмина. - Нельзя удалить себя из группы, менять свою роль и свой флаг superadmin. Нельзя снять флаг с последнего суперадмина или удалить его.
- `admin` может создавать, удалять и менять роль (`user`/`admin`) пользователей своей группы. Назначать `superadmin`, изменять суперадминов и переносить пользователей между группами может только `superadmin`. - Админ группы добавляет в неё пользователей (новый email создаёт пользователя, существующий — добавляет ему членство), меняет их роль в этой группе и убирает их из неё; если группа была у пользователя единственной, пользователь удаляется вместе с ключами. Назначать и снимать superadmin и изменять суперадминов может только суперадмин.
- Новые ключи генерирует только админ (для пользователей своей группы) или суперадмин. - Новые ключи генерирует только админ (для участников своей группы) или суперадмин. Ключ принадлежит пользователю и даёт приложению доступ ко всем его группам.
### 3.4. Ключи доступа ### 3.4. Ключи доступа
@@ -139,7 +140,7 @@ cards:
### 3.5. Аутентификация административного UI ### 3.5. Аутентификация административного UI
- Пользователь определяется по заголовку `Remote-Email` (без учёта регистра). Нет заголовка или пользователь не найден в `users.yaml` — `401`. Недостаточно прав — `403`. - Пользователь определяется по заголовку `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`. - Если задан `ADMIN_PROXY_SECRET`, без правильного заголовка `X-Cardy-Proxy-Secret` — `401`.
- Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF). - Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF).
@@ -163,9 +164,9 @@ cards:
| `GET /my-keys` | все | свои ключи (расшифрованные) и deep link | | `GET /my-keys` | все | свои ключи (расшифрованные) и deep link |
| `GET /groups` | superadmin | список групп | | `GET /groups` | superadmin | список групп |
| `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id` | superadmin | CRUD групп | | `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id` | superadmin | CRUD групп |
| `PUT /current-group` | superadmin | выбор текущей группы (cookie) | | `PUT /current-group` | все (своя группа), superadmin (любая) | выбор текущей группы (cookie) |
| `GET /users` | admin | пользователи текущей группы с ключами | | `GET /users` | admin | участники текущей группы: роль в ней, флаг superadmin, все их группы, ключи |
| `POST /users`, `PATCH /users/:email`, `DELETE /users/:email` | admin | CRUD пользователей | | `POST /users` `{email, role}` | admin | добавить в текущую группу (новый email создаёт пользователя) |
| `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей | | `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей |
| `GET /cards` | admin | все карты текущей группы, включая удалённые | | `GET /cards` | admin | все карты текущей группы, включая удалённые |
| `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) | | `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) |
@@ -176,11 +177,11 @@ cards:
### 3.8. Клиентский API (публично `/api`, на сервере — от корня) ### 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` | все неудалённые карты группы, без пагинации | | `GET /api/cards` | все неудалённые карты группы, без пагинации |
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID | | `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID |
| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий, пустые `tags` — теги; опционально файл `logo`, опционально `removeLogo=true`) | | `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 и карты скачиваются автоматически. - **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
- **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает. - **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне. - **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить». - **Группы**: приложение хранит список групп пользователя и текущую группу; карты, даты использования и версии картинок хранятся отдельно для каждой группы. Если групп больше одной, заголовок экрана карт — название текущей группы, нажатие открывает выбор группы; группа, карты которой ещё не скачивались на устройство, при выборе сразу обновляется. «Обновить» заодно обновляет список групп (данные групп, в которых пользователь больше не состоит, удаляются). Сессии старых версий приложения (без групп) при первом обновлении получают список групп, а их локальные карты переходят в первую группу. (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы. - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
- **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). - **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
- **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
+89 -24
View File
@@ -1,10 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { parseLoginDeepLink, type Card } from '@cardy/shared'; 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 { useNavigation, type LoginPrefill } from './navigation.js';
import { onBackButton, onDeepLink, onResumeAfter, runOverlayBack } from './platform.js'; import { onBackButton, onDeepLink, onResumeAfter, runOverlayBack } from './platform.js';
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js'; import {
import { refreshCardList, syncImages } from './sync.js'; 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 { import {
applyPendingUpdate, applyPendingUpdate,
checkForApkUpdate, checkForApkUpdate,
@@ -32,9 +42,12 @@ interface Boot {
export function App() { export function App() {
const [boot, setBoot] = useState<Boot>(); const [boot, setBoot] = useState<Boot>();
useEffect(() => { useEffect(() => {
void Promise.all([storage.getSession(), storage.getPin(), storage.getCards(), storage.getUsage()]).then( void (async () => {
([session, pin, cards, usage]) => setBoot({ session, pin, cards, usage }), 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 <div className="splash" />; if (!boot) return <div className="splash" />;
return <Main boot={boot} justUpdated={JUST_UPDATED} />; return <Main boot={boot} justUpdated={JUST_UPDATED} />;
@@ -64,25 +77,52 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
const route = stack[stack.length - 1]; const route = stack[stack.length - 1];
const api = useMemo(() => (session ? createApi(session) : undefined), [session]); 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 ---- // ---- data ----
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!api) return; const current = sessionRef.current;
if (!current) return;
setSync({ busy: true }); setSync({ busy: true });
let list: Card[]; let list: Card[];
let target: string;
let targetApi: GroupApi;
try { 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) { } catch (e) {
setSync({ busy: false, error: (e as Error).message }); setSync({ busy: false, error: (e as Error).message });
return; return;
} }
if (groupIdRef.current !== target) return setSync({ busy: false });
setCards(list); setCards(list);
setUsage(await storage.getUsage()); setUsage(await storage.getUsage(target));
setSync({ busy: true, progress: { done: 0, total: 0 } }); setSync({ busy: true, progress: { done: 0, total: 0 } });
try { try {
const result = await syncImages( const result = await syncImages(
api, targetApi,
target,
list, list,
() => setImageEpoch((e) => e + 1), () => setImageEpoch((e) => e + 1),
(done, total) => setSync({ busy: true, progress: { done, total } }), (done, total) => setSync({ busy: true, progress: { done, total } }),
@@ -95,33 +135,54 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
} catch (e) { } catch (e) {
setSync({ busy: false, error: (e as Error).message }); 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 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); const index = list.findIndex((c) => c.id === card.id);
if (index >= 0) list[index] = card; if (index >= 0) list[index] = card;
else list.push(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. // Images uploaded from this device are stored already; record the version only if nothing is missing.
const stored = await images.storedKeys(); const stored = await images.storedKeys();
if (cardImageIds(card).every((id) => stored.has(images.key(card.id, id)))) { if (cardImageIds(card).every((id) => stored.has(images.key(card.id, id)))) {
await images.prune(card.id, cardImageIds(card)); await images.prune(card.id, cardImageIds(card));
const versions = await storage.getVersions(); const versions = await storage.getVersions(gid);
versions[card.id] = card.imagesVersion; versions[card.id] = card.imagesVersion;
await storage.setVersions(versions); await storage.setVersions(gid, versions);
} }
setCards(list); setCards(list);
setImageEpoch((e) => e + 1); setImageEpoch((e) => e + 1);
}, []); }, []);
const removeCardLocally = useCallback(async (id: string) => { 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); const next = list.filter((c) => c.id !== id);
delete u[id]; delete u[id];
delete versions[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); setCards(next);
setUsage({ ...u }); setUsage({ ...u });
}, []); }, []);
@@ -130,11 +191,12 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
const shownCardId = !locked && route?.name === 'card' ? route.id : undefined; const shownCardId = !locked && route?.name === 'card' ? route.id : undefined;
useEffect(() => { useEffect(() => {
if (!shownCardId) return; if (!shownCardId) return;
const gid = groupIdRef.current;
void (async () => { void (async () => {
const u = await storage.getUsage(); const u = await storage.getUsage(gid);
u[shownCardId] = new Date().toISOString(); u[shownCardId] = new Date().toISOString();
await storage.setUsage(u); await storage.setUsage(gid, u);
setUsage({ ...u }); if (groupIdRef.current === gid) setUsage({ ...u });
})(); })();
}, [shownCardId, stack.length]); }, [shownCardId, stack.length]);
@@ -245,7 +307,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
// ---- screens ---- // ---- screens ----
if (!session || !api) { if (!session || !api || !groupApi) {
const prefill = route?.name === 'login' ? route.prefill : undefined; const prefill = route?.name === 'login' ? route.prefill : undefined;
return <LoginScreen key={JSON.stringify(prefill ?? {})} prefill={prefill} onSignedIn={signedIn} />; return <LoginScreen key={JSON.stringify(prefill ?? {})} prefill={prefill} onSignedIn={signedIn} />;
} }
@@ -272,7 +334,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
onEdit={() => push({ name: 'edit', id: card.id })} onEdit={() => push({ name: 'edit', id: card.id })}
onDelete={async () => { onDelete={async () => {
try { try {
await api.deleteCard(card.id); await groupApi.deleteCard(card.id);
} catch (e) { } catch (e) {
// Already deleted elsewhere: drop it locally as well. // Already deleted elsewhere: drop it locally as well.
if ((e as { status?: number }).status !== 404) throw e; if ((e as { status?: number }).status !== 404) throw e;
@@ -289,7 +351,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
<EditScreen <EditScreen
key={route.id ?? 'new'} key={route.id ?? 'new'}
card={card} card={card}
api={api} api={groupApi}
onCancel={goBack} onCancel={goBack}
saveCardLocally={saveCardLocally} saveCardLocally={saveCardLocally}
onSaved={(saved) => (route.id ? goBack() : replaceTop({ name: 'card', id: saved.id }))} onSaved={(saved) => (route.id ? goBack() : replaceTop({ name: 'card', id: saved.id }))}
@@ -305,6 +367,9 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
imageEpoch={imageEpoch} imageEpoch={imageEpoch}
sync={sync} sync={sync}
session={session} session={session}
groups={sessionGroups(session)}
currentGroupId={groupId}
onSwitchGroup={switchGroup}
apkUpdate={apkUpdate} apkUpdate={apkUpdate}
onDismissSync={() => setSync({ busy: false })} onDismissSync={() => setSync({ busy: false })}
onRefresh={refresh} onRefresh={refresh}
+23 -12
View File
@@ -1,5 +1,5 @@
import type { ApiAuthResponse, Card } from '@cardy/shared'; 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 { export class ApiError extends Error {
constructor( constructor(
@@ -62,18 +62,29 @@ export function createApi(session: Session) {
const enc = encodeURIComponent; 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<Card[]>('GET', `${g}/cards`),
createCard: (form: FormData) => json<Card>('POST', `${g}/cards`, form, UPLOAD_TIMEOUT),
updateCard: (id: string, form: FormData) => json<Card>('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<Card>('POST', `${g}/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT),
removeImage: (id: string, imageId: string) => json<Card>('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 { return {
auth: () => json<ApiAuthResponse>('GET', '/auth'), auth: () => json<ApiAuthResponse>('GET', '/auth'),
cards: () => json<Card[]>('GET', '/cards'), forGroup,
createCard: (form: FormData) => json<Card>('POST', '/cards', form, UPLOAD_TIMEOUT),
updateCard: (id: string, form: FormData) => json<Card>('PUT', `/cards/${enc(id)}`, form, UPLOAD_TIMEOUT),
deleteCard: async (id: string) => void (await send('DELETE', `/cards/${enc(id)}`)),
addImages: (id: string, form: FormData) => json<Card>('POST', `/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT),
removeImage: (id: string, imageId: string) => json<Card>('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();
},
}; };
} }
export type GroupApi = ReturnType<Api['forGroup']>;
+47 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react'; 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 { ApkUpdateBanner } from '../ApkUpdateBanner.js';
import type { Session, UsageMap } from '../storage.js'; import type { Session, UsageMap } from '../storage.js';
import type { PublishedApk } from '../updater.js'; import type { PublishedApk } from '../updater.js';
@@ -19,6 +19,9 @@ export function CardsScreen({
imageEpoch, imageEpoch,
sync, sync,
session, session,
groups,
currentGroupId,
onSwitchGroup,
apkUpdate, apkUpdate,
onDismissSync, onDismissSync,
onRefresh, onRefresh,
@@ -31,6 +34,10 @@ export function CardsScreen({
imageEpoch: number; imageEpoch: number;
sync: SyncState; sync: SyncState;
session: Session; session: Session;
/** The user's groups (one or more). */
groups: Group[];
currentGroupId: string;
onSwitchGroup: (groupId: string) => void;
apkUpdate?: PublishedApk; apkUpdate?: PublishedApk;
onDismissSync: () => void; onDismissSync: () => void;
onRefresh: () => void; onRefresh: () => void;
@@ -40,15 +47,28 @@ export function CardsScreen({
}) { }) {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [confirmKey, setConfirmKey] = useState(false); const [confirmKey, setConfirmKey] = useState(false);
const [pickingGroup, setPickingGroup] = useState(false);
const filterInput = useRef<HTMLInputElement>(null); const filterInput = useRef<HTMLInputElement>(null);
const group = groups.find((g) => g.id === currentGroupId) ?? groups[0]!;
// Search by name, tags and comment. // Search by name, tags and comment.
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]); 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 ? (
<button className="group-title" onClick={() => setPickingGroup(true)} disabled={sync.busy} aria-label={`Group ${group.name}, switch group`}>
<span>{group.name}</span>
<Icon name="chevron" size={18} />
</button>
) : (
'Cards'
);
return ( return (
<Screen <Screen
className="cards-screen" className="cards-screen"
title="Cards" title={title}
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />} left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />} right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
> >
@@ -97,7 +117,7 @@ export function CardsScreen({
{cards.length === 0 ? ( {cards.length === 0 ? (
<div className="empty"> <div className="empty">
<p>No cards on this device yet.</p> <p>No cards on this device yet.</p>
<p className="muted">Tap refresh to download the cards of {session.groupName ? `“${session.groupName}”` : 'your group'}, or add a new one.</p> <p className="muted">Tap refresh to download the cards of “{group.name}”, or add a new one.</p>
</div> </div>
) : visible.length === 0 ? ( ) : visible.length === 0 ? (
<div className="empty muted">No cards match “{filter}”.</div> <div className="empty muted">No cards match “{filter}”.</div>
@@ -118,6 +138,30 @@ export function CardsScreen({
<Icon name="plus" size={28} /> <Icon name="plus" size={28} />
</button> </button>
{pickingGroup && (
<div className="dialog-backdrop" onClick={(e) => e.target === e.currentTarget && setPickingGroup(false)}>
<div className="dialog" role="dialog" aria-modal="true" aria-label="Choose a group">
<h2>Group</h2>
<ul className="group-list">
{groups.map((g) => (
<li key={g.id}>
<button
className={g.id === group.id ? 'selected' : ''}
onClick={() => {
setPickingGroup(false);
setFilter('');
onSwitchGroup(g.id);
}}
>
{g.name}
</button>
</li>
))}
</ul>
</div>
</div>
)}
{confirmKey && ( {confirmKey && (
<ConfirmDialog <ConfirmDialog
title="Change access key?" title="Change access key?"
+3 -2
View File
@@ -12,7 +12,7 @@ import {
type CodeType, type CodeType,
type ImageRef, type ImageRef,
} from '@cardy/shared'; } from '@cardy/shared';
import { ApiError, type Api } from '../api.js'; import { ApiError, type GroupApi } from '../api.js';
import { CropScreen } from '../CropScreen.js'; import { CropScreen } from '../CropScreen.js';
import { fileNameFor } from '../imageTools.js'; import { fileNameFor } from '../imageTools.js';
import { ScannerView, scanImageFile } from '../ScannerView.js'; import { ScannerView, scanImageFile } from '../ScannerView.js';
@@ -42,7 +42,8 @@ export function EditScreen({
saveCardLocally, saveCardLocally,
}: { }: {
card?: Card; card?: Card;
api: Api; /** Card endpoints of the current group. */
api: GroupApi;
onCancel: () => void; onCancel: () => void;
onSaved: (card: Card) => void; onSaved: (card: Card) => void;
onMissing: (id: string) => void; onMissing: (id: string) => void;
+3 -1
View File
@@ -38,7 +38,9 @@ export function LoginScreen({
setError(undefined); setError(undefined);
try { try {
const me = await createApi({ server: normalized, key: trimmedKey }).auth(); 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) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
setBusy(false); setBusy(false);
+50 -7
View File
@@ -1,6 +1,6 @@
import { Preferences } from '@capacitor/preferences'; import { Preferences } from '@capacitor/preferences';
import { clear, createStore, del, delMany, get, keys, set } from 'idb-keyval'; 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 { export interface Session {
/** Base URL of the client API, e.g. https://dev.aggtaa.com/api */ /** Base URL of the client API, e.g. https://dev.aggtaa.com/api */
@@ -8,9 +8,30 @@ export interface Session {
key: string; key: string;
/** From the server at sign-in, for display. */ /** From the server at sign-in, for display. */
email?: string; 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; 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 { export interface PinRecord {
salt: string; salt: string;
hash: string; hash: string;
@@ -44,17 +65,39 @@ async function setJson(key: string, value: unknown): Promise<void> {
else await Preferences.set({ key, value: JSON.stringify(value) }); 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 = { export const storage = {
getSession: () => getJson<Session | null>(K.session, null), getSession: () => getJson<Session | null>(K.session, null),
setSession: (s: Session | null) => setJson(K.session, s), setSession: (s: Session | null) => setJson(K.session, s),
getPin: () => getJson<PinRecord | null>(K.pin, null), getPin: () => getJson<PinRecord | null>(K.pin, null),
setPin: (p: PinRecord | null) => setJson(K.pin, p), setPin: (p: PinRecord | null) => setJson(K.pin, p),
getCards: () => getJson<Card[]>(K.cards, []),
setCards: (cards: Card[]) => setJson(K.cards, cards), // Cards, usage and image versions are stored per group.
getUsage: () => getJson<UsageMap>(K.usage, {}), getCards: (groupId: string) => getJson<Card[]>(groupKey(K.cards, groupId), []),
setUsage: (u: UsageMap) => setJson(K.usage, u), /** Whether cards of the group were ever downloaded to this device. */
getVersions: () => getJson<VersionMap>(K.versions, {}), hasCards: async (groupId: string) => (await Preferences.get({ key: groupKey(K.cards, groupId) })).value !== null,
setVersions: (v: VersionMap) => setJson(K.versions, v), setCards: (groupId: string, cards: Card[]) => setJson(groupKey(K.cards, groupId), cards),
getUsage: (groupId: string) => getJson<UsageMap>(groupKey(K.usage, groupId), {}),
setUsage: (groupId: string, u: UsageMap) => setJson(groupKey(K.usage, groupId), u),
getVersions: (groupId: string) => getJson<VersionMap>(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<void> {
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<void> {
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. */ /** Forgets everything: used when signing in with a new key. */
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
+49
View File
@@ -997,6 +997,55 @@ textarea:focus {
color: var(--muted); 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 { .dialog-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+34 -11
View File
@@ -1,6 +1,6 @@
import type { Card } from '@cardy/shared'; import type { Card } from '@cardy/shared';
import type { Api } from './api.js'; import type { Api, GroupApi } from './api.js';
import { cardImageIds, images, storage, type UsageMap, type VersionMap } from './storage.js'; import { LEGACY_GROUP_ID, cardImageIds, images, storage, type Session, type UsageMap, type VersionMap } from './storage.js';
export const MAX_PARALLEL_DOWNLOADS = 5; 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<Session> {
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. * Cards gone from the server are removed locally with their images and usage.
*/ */
export async function refreshCardList(api: Api): Promise<Card[]> { export async function refreshCardList(api: GroupApi, groupId: string): Promise<Card[]> {
const fresh = await api.cards(); const fresh = await api.cards();
const [old, usage, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]); const [old, usage, versions] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId), storage.getVersions(groupId)]);
await storage.setCards(fresh); await storage.setCards(groupId, fresh);
const freshIds = new Set(fresh.map((c) => c.id)); const freshIds = new Set(fresh.map((c) => c.id));
const gone = old.filter((c) => !freshIds.has(c.id)); const gone = old.filter((c) => !freshIds.has(c.id));
@@ -47,7 +69,7 @@ export async function refreshCardList(api: Api): Promise<Card[]> {
delete versions[c.id]; delete versions[c.id];
await images.prune(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; 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. * A card's version is recorded only after all its files are stored, so failures are retried next time.
*/ */
export async function syncImages( export async function syncImages(
api: Api, api: GroupApi,
groupId: string,
cards: Card[], cards: Card[],
onCardDone?: (card: Card) => void, onCardDone?: (card: Card) => void,
onProgress?: (done: number, total: number) => void, onProgress?: (done: number, total: number) => void,
): Promise<ImageSyncResult> { ): Promise<ImageSyncResult> {
const stored = await images.storedKeys(); 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) => const tasks = changed.flatMap((card) =>
cardImageIds(card) cardImageIds(card)
.filter((id) => !stored.has(images.key(card.id, id))) .filter((id) => !stored.has(images.key(card.id, id)))
@@ -85,9 +108,9 @@ export async function syncImages(
if (failedCards.has(card.id)) return versionWrites; if (failedCards.has(card.id)) return versionWrites;
versionWrites = versionWrites.then(async () => { versionWrites = versionWrites.then(async () => {
await images.prune(card.id, cardImageIds(card)); await images.prune(card.id, cardImageIds(card));
const versions = await storage.getVersions(); const versions = await storage.getVersions(groupId);
versions[card.id] = card.imagesVersion; versions[card.id] = card.imagesVersion;
await storage.setVersions(versions); await storage.setVersions(groupId, versions);
onCardDone?.(card); onCardDone?.(card);
}); });
return versionWrites; return versionWrites;
+1
View File
@@ -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', 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', 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', rotate: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7',
chevron: 'M6 9l6 6 6-6',
} as const; } as const;
export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) { export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {
+40 -22
View File
@@ -9,6 +9,7 @@ import {
userCreateSchema, userCreateSchema,
userPatchSchema, userPatchSchema,
type Group, type Group,
type GroupMembership,
type MeResponse, type MeResponse,
} from '@cardy/shared'; } from '@cardy/shared';
import type { CardsService } from '../cards.js'; import type { CardsService } from '../cards.js';
@@ -16,7 +17,7 @@ import { ADMIN_API_PREFIX } from '../config.js';
import { forwardedPrefix, publicBaseUrl } from '../forwarded.js'; import { forwardedPrefix, publicBaseUrl } from '../forwarded.js';
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js'; import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.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 GROUP_COOKIE = 'cardy_group';
export const CSRF_HEADER = 'x-cardy-admin'; export const CSRF_HEADER = 'x-cardy-admin';
@@ -24,7 +25,8 @@ export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
export interface AdminContext { export interface AdminContext {
user: StoredUser; user: StoredUser;
ownGroup: Group; /** The user's groups with their role in each. */
memberships: GroupMembership[];
currentGroup: Group; currentGroup: Group;
} }
@@ -75,16 +77,22 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy'); if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy');
const user = await users.findUser(email); const user = await users.findUser(email);
if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`); 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); const memberships = await users.membershipsOf(user);
if (!ownGroup) throw unauthorized('User group does not exist'); if (!memberships.length) throw unauthorized('None of your groups exists');
let currentGroup = ownGroup; request.admin = { user, memberships, currentGroup: await resolveCurrentGroup(user, memberships, request.cookies[GROUP_COOKIE]) };
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 };
}); });
/** 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<Group> {
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. // Cookie path as the browser sees it, e.g. /api/admin.
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) => const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
reply.setCookie(GROUP_COOKIE, groupId, { reply.setCookie(GROUP_COOKIE, groupId, {
@@ -104,9 +112,15 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
// ---- current user ---- // ---- current user ----
app.get('/me', async (request, reply): Promise<MeResponse> => { app.get('/me', async (request, reply): Promise<MeResponse> => {
const { user, ownGroup, currentGroup } = request.admin; const { user, memberships, currentGroup } = request.admin;
if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id); 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))); app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
@@ -134,38 +148,42 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
return reply.code(204).send(); return reply.code(204).send();
}); });
/** Any user may switch between their groups; superadmins between all groups. */
app.put('/current-group', async (request, reply) => { app.put('/current-group', async (request, reply) => {
requireSuperadmin(request.admin.user); const { user, memberships } = request.admin;
const { groupId } = parseBody(currentGroupSchema, request.body); const { groupId } = parseBody(currentGroupSchema, request.body);
const group = await users.getGroup(groupId); const group = await users.getGroup(groupId);
if (!group) throw notFound('Group not found'); 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); setGroupCookie(request, reply, group.id);
return group; return group;
}); });
// ---- users of the current group ---- // ---- members of the current group ----
app.get('/users', async (request) => { app.get('/users', async (request) => {
const { user, groupId } = groupAdmin(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) => { app.post('/users', async (request, reply) => {
const { user, groupId } = groupAdmin(request); const { user, groupId } = groupAdmin(request);
const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body)); const added = await users.addMember(user, groupId, parseBody(userCreateSchema, request.body));
return reply.code(201).send(users.userView(created, apiUrl(request))); return reply.code(201).send(await users.userView(added, groupId, apiUrl(request)));
}); });
app.patch<{ Params: EmailParams }>('/users/:email', async (request) => { app.patch<{ Params: EmailParams }>('/users/:email', async (request) => {
const { user, groupId } = groupAdmin(request); const { user, groupId } = groupAdmin(request);
const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body)); const updated = await users.updateMember(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
return users.userView(updated, apiUrl(request)); 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); const { user, groupId } = groupAdmin(request);
await users.deleteUser(user, groupId, request.params.email); return users.removeMember(user, groupId, request.params.email);
return reply.code(204).send();
}); });
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => { app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
+40 -27
View File
@@ -1,8 +1,8 @@
import cors, { type FastifyCorsOptions } from '@fastify/cors'; 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 { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
import type { CardsService } from '../cards.js'; 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 { parseBody, readMultipart, sendImage } from '../http.js';
import type { KeyOwner, UsersService } from '../users.js'; import type { KeyOwner, UsersService } from '../users.js';
@@ -19,8 +19,7 @@ interface Options {
const CLIENT_ACCESS = { includeDeleted: false }; const CLIENT_ACCESS = { includeDeleted: false };
type IdParams = { id: string }; type Params = Record<string, string>;
type ImageParams = { id: string; imageId: string };
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => { export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies. // Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
@@ -46,50 +45,64 @@ export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards
request.keyOwner = owner; request.keyOwner = owner;
}); });
const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id;
app.get('/auth', async (request): Promise<ApiAuthResponse> => { app.get('/auth', async (request): Promise<ApiAuthResponse> => {
const { user, group } = request.keyOwner; const { user, groups } = request.keyOwner;
return { email: user.email, group }; 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 { 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); 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 { fields, files } = await readMultipart(request);
const { removeLogo, ...cardFields } = fields; const { removeLogo, ...cardFields } = fields;
const patch = parseBody(cardFieldsSchema.partial(), cardFields); const patch = parseBody(cardFieldsSchema.partial(), cardFields);
const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined); 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) => { app.delete(`${prefix}/cards/:id`, async (request, reply) => {
const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS); const groupId = groupOf(request);
await cards.setDeleted(groupId(request), card.id, true); const card = await cards.get(groupId, param(request, 'id'), CLIENT_ACCESS);
await cards.setDeleted(groupId, card.id, true);
return reply.code(204).send(); return reply.code(204).send();
}); });
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => app.get(`${prefix}/cards/:id/logo`, async (request, reply) =>
sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)), sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), 'logo', CLIENT_ACCESS)),
); );
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => { app.get(`${prefix}/cards/:id/images/:imageId`, async (request, reply) =>
const { id, imageId } = request.params; sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS)),
return sendImage(reply, await cards.imagePath(groupId(request), id, 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); 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) => app.delete(`${prefix}/cards/:id/images/:imageId`, async (request) =>
cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS), cards.removeImage(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS),
); );
}; }
+135 -72
View File
@@ -1,7 +1,16 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { z } from 'zod'; 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 { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
import { badRequest, conflict, forbidden, notFound } from './errors.js'; import { badRequest, conflict, forbidden, notFound } from './errors.js';
import { YamlFile } from './yamlFile.js'; import { YamlFile } from './yamlFile.js';
@@ -15,12 +24,29 @@ const storedKeySchema = z.object({
encrypted: z.string(), encrypted: z.string(),
}); });
const storedUserSchema = z.object({ const membershipSchema = z.object({ groupId: idSchema, role: z.enum(GROUP_ROLES) });
email: z.string().transform((s) => s.trim().toLowerCase()),
groupId: idSchema, /** Users from before multi-group ({ groupId, role }) are read as { superadmin, memberships }. */
role: z.enum(ROLES), function upgradeLegacyUser(raw: unknown): unknown {
keys: z.array(storedKeySchema).default([]), 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({ const usersFileSchema = z.object({
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]), groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
@@ -33,7 +59,8 @@ type UsersFile = z.infer<typeof usersFileSchema>;
export interface KeyOwner { export interface KeyOwner {
user: StoredUser; 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 { 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<void> { async ensureSuperadmin(email: string, groupName: string): Promise<void> {
const data = await this.file.read(); const data = await this.file.read();
const existing = data.users.find((u) => u.email === email); if (data.users.find((u) => u.email === email)?.superadmin) return;
if (existing?.role === 'superadmin') return;
await this.file.update((d) => { await this.file.update((d) => {
const user = d.users.find((u) => u.email === email); const user = d.users.find((u) => u.email === email);
if (user) { if (user) {
user.role = 'superadmin'; user.superadmin = true;
return; return;
} }
let group = d.groups.find((g) => sameName(g.name, groupName)); let group = d.groups.find((g) => sameName(g.name, groupName));
@@ -68,7 +94,7 @@ export class UsersService {
group = { id: randomUUID(), name: groupName }; group = { id: randomUUID(), name: groupName };
d.groups.push(group); 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)); 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<GroupMembership[]> {
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<KeyOwner | undefined> { async authenticateKey(key: string): Promise<KeyOwner | undefined> {
const data = await this.file.read(); const data = await this.file.read();
if (this.keyIndex?.source !== data) { if (this.keyIndex?.source !== data) {
@@ -98,8 +133,9 @@ export class UsersService {
} }
const email = this.keyIndex.map.get(key); const email = this.keyIndex.map.get(key);
const user = email ? data.users.find((u) => u.email === email) : undefined; const user = email ? data.users.find((u) => u.email === email) : undefined;
const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined; if (!user) return undefined;
return user && group ? { user, group } : undefined; const groups = (await this.membershipsOf(user)).map(({ id, name }) => ({ id, name }));
return groups.length ? { user, groups } : undefined;
} }
// ---- views ---- // ---- views ----
@@ -112,8 +148,15 @@ export class UsersService {
}); });
} }
userView(user: StoredUser, apiUrl: string): UserView { /** A member of `groupId`, as shown to the admins of that group. */
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) }; async userView(user: StoredUser, groupId: string, apiUrl: string): Promise<UserView> {
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) ---- // ---- 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<void> { async deleteGroup(actor: StoredUser, id: string): Promise<void> {
requireSuperadmin(actor); requireSuperadmin(actor);
await this.file.update((d) => { await this.file.update((d) => {
if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found'); if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found');
if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) { const onlyGroupOf = d.users.filter((u) => u.memberships.length === 1 && u.memberships[0]!.groupId === id);
throw conflict('A group that contains a superadmin cannot be deleted'); 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.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<StoredUser[]> { async listMembers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
requireGroupAdmin(actor, groupId); requireGroupAdmin(actor, groupId);
const data = await this.file.read(); 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( /** Adds a user to the group; creates the user when the email is new. */
actor: StoredUser, async addMember(actor: StoredUser, groupId: string, input: { email: string; role: GroupRole }): Promise<StoredUser> {
currentGroupId: string,
input: { email: string; role: Role; groupId?: string },
): Promise<StoredUser> {
const groupId = input.groupId ?? currentGroupId;
if (groupId !== currentGroupId) requireSuperadmin(actor);
requireGroupAdmin(actor, groupId); requireGroupAdmin(actor, groupId);
if (input.role === 'superadmin') requireSuperadmin(actor);
return this.file.update((d) => { return this.file.update((d) => {
if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found'); 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 existing = d.users.find((u) => u.email === input.email);
const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] }; 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); d.users.push(user);
return user; return user;
}); });
} }
async updateUser( /** Changes the role in the group and/or (superadmins only) the global superadmin flag. */
async updateMember(
actor: StoredUser, actor: StoredUser,
currentGroupId: string, groupId: string,
email: string, email: string,
patch: { role?: Role; groupId?: string }, patch: { role?: GroupRole; superadmin?: boolean },
): Promise<StoredUser> { ): Promise<StoredUser> {
if (patch.role === 'superadmin') requireSuperadmin(actor); if (patch.superadmin !== undefined) requireSuperadmin(actor);
if (patch.groupId !== undefined) requireSuperadmin(actor);
return this.file.update((d) => { return this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email); const user = findManagedMember(d, actor, groupId, email);
if (patch.role !== undefined && patch.role !== user.role) { const self = user.email === actor.email;
if (user.email === actor.email) throw badRequest('You cannot change your own role'); const m = membership(user, groupId)!;
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin'); if (patch.role !== undefined && patch.role !== m.role) {
user.role = patch.role; if (self) throw badRequest('You cannot change your own role');
m.role = patch.role;
} }
if (patch.groupId !== undefined && patch.groupId !== user.groupId) { if (patch.superadmin !== undefined && patch.superadmin !== user.superadmin) {
if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found'); if (self) throw badRequest('You cannot change your own superadmin status');
user.groupId = patch.groupId; if (!patch.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
user.superadmin = patch.superadmin;
} }
return user; return user;
}); });
} }
async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise<void> { /**
await this.file.update((d) => { * Removes the user from the group. For a user whose only group it is, this deletes the user with
const user = findManagedUser(d, actor, currentGroupId, email); * all keys (users always belong to at least one group). Returns whether the user was deleted.
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'); 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); 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( async createKey(actor: StoredUser, groupId: string, email: string, label: string | undefined, apiUrl: string): Promise<AccessKeyView> {
actor: StoredUser,
currentGroupId: string,
email: string,
label: string | undefined,
apiUrl: string,
): Promise<AccessKeyView> {
const plain = generateAccessKey(); const plain = generateAccessKey();
const stored: StoredKey = { const stored: StoredKey = {
id: randomUUID(), id: randomUUID(),
@@ -227,7 +279,7 @@ export class UsersService {
encrypted: encryptSecret(plain, this.secret), encrypted: encryptSecret(plain, this.secret),
}; };
await this.file.update((d) => { await this.file.update((d) => {
findManagedUser(d, actor, currentGroupId, email).keys.push(stored); findManagedMember(d, actor, groupId, email).keys.push(stored);
}); });
return { return {
id: stored.id, id: stored.id,
@@ -238,9 +290,9 @@ export class UsersService {
}; };
} }
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> { async deleteKey(actor: StoredUser, groupId: string, email: string, keyId: string): Promise<void> {
await this.file.update((d) => { await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email); const user = findManagedMember(d, actor, groupId, email);
const before = user.keys.length; const before = user.keys.length;
user.keys = user.keys.filter((k) => k.id !== keyId); user.keys = user.keys.filter((k) => k.id !== keyId);
if (user.keys.length === before) throw notFound('Key not found'); 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 { 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 { 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 { 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 { 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). */ /** A member of the group that the actor may manage (only superadmins manage superadmins). */
function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser { function findManagedMember(d: UsersFile, actor: StoredUser, groupId: string, email: string): StoredUser {
requireGroupAdmin(actor, currentGroupId); requireGroupAdmin(actor, groupId);
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId); const user = d.users.find((u) => u.email === normalized && membership(u, groupId));
if (!user) throw notFound('User not found'); if (!user) throw notFound('User not found in this group');
if (user.role === 'superadmin') requireSuperadmin(actor); if (user.superadmin) requireSuperadmin(actor);
return user; return user;
} }
+88 -7
View File
@@ -24,7 +24,7 @@ async function setup() {
const { app, config } = await testApp(); const { app, config } = await testApp();
const root = admin(app, SUPERADMIN); const root = admin(app, SUPERADMIN);
const me = (await root('GET', '/me')).body as MeResponse; 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', () => { describe('admin API authentication', () => {
@@ -32,7 +32,13 @@ describe('admin API authentication', () => {
const { root } = await setup(); const { root } = await setup();
const { status, body } = await root('GET', '/me'); const { status, body } = await root('GET', '/me');
expect(status).toBe(200); 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 () => { 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); 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 { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' }); await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`); 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('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('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
expect((await adm('GET', '/groups')).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(); const { app, config, root, defaultGroup } = await setup();
// "default" is the superadmin's only group.
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409); expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; 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' }); 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); 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((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false); 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', '/users')).status).toBe(403);
expect((await user('GET', '/cards')).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('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).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); 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('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: 'user@example.com' })).status).toBe(409);
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400); 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 () => { it('admin generates keys; users see their own keys with deep links', async () => {
+37 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest'; 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'; import { SUPERADMIN, multipart, testApp } from './helpers.js';
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' }; 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']); 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 () => { it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup(); const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope'); const res = await client(app, key)('GET', '/nope');
+6 -3
View File
@@ -28,8 +28,10 @@ export function App() {
function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise<void> }) { function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise<void> }) {
const path = usePath(); const path = usePath();
// Rights in the current group (admin role is per group; superadmin is global).
const isAdmin = me.role !== 'user'; const isAdmin = me.role !== 'user';
const isSuper = me.role === 'superadmin'; const isSuper = me.superadmin;
const canSwitch = isSuper || me.groups.length > 1;
const tabs = [ const tabs = [
...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []), ...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []),
@@ -61,7 +63,7 @@ function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch:
))} ))}
</nav> </nav>
<div className="topbar-right"> <div className="topbar-right">
{isSuper ? <GroupSwitcher me={me} onChanged={onGroupChanged} epoch={groupEpoch} /> : <span className="pill">{me.currentGroup.name}</span>} {canSwitch ? <GroupSwitcher me={me} onChanged={onGroupChanged} epoch={groupEpoch} /> : <span className="pill">{me.currentGroup.name}</span>}
<span className="muted small" title={me.role}> <span className="muted small" title={me.role}>
{me.email} · {me.role} {me.email} · {me.role}
</span> </span>
@@ -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<void>; epoch: number }) { function GroupSwitcher({ me, onChanged, epoch }: { me: MeResponse; onChanged: () => Promise<void>; 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<string>(); const [error, setError] = useState<string>();
return ( return (
<label className="group-switcher"> <label className="group-switcher">
+6 -4
View File
@@ -1,4 +1,4 @@
import type { AccessKeyView, Card, CardFields, CardPatch, Group, MeResponse, Role, UserView } from '@cardy/shared'; import type { AccessKeyView, Card, CardFields, CardPatch, Group, GroupRole, MeResponse, UserView } from '@cardy/shared';
// Reverse proxy: /api/admin/* goes through Authelia to server-be. // Reverse proxy: /api/admin/* goes through Authelia to server-be.
const BASE = '/api/admin'; const BASE = '/api/admin';
@@ -75,10 +75,12 @@ export const api = {
setCurrentGroup: (groupId: string) => request<Group>('PUT', '/current-group', { groupId }), setCurrentGroup: (groupId: string) => request<Group>('PUT', '/current-group', { groupId }),
users: () => request<UserView[]>('GET', '/users'), users: () => request<UserView[]>('GET', '/users'),
createUser: (email: string, role: Role) => request<UserView>('POST', '/users', { email, role }), /** Adds a user to the current group (creates the user when the email is new). */
updateUser: (email: string, patch: { role?: Role; groupId?: string }) => addUser: (email: string, role: GroupRole) => request<UserView>('POST', '/users', { email, role }),
updateUser: (email: string, patch: { role?: GroupRole; superadmin?: boolean }) =>
request<UserView>('PATCH', `/users/${enc(email)}`, patch), request<UserView>('PATCH', `/users/${enc(email)}`, patch),
deleteUser: (email: string) => request<void>('DELETE', `/users/${enc(email)}`), /** Removes the user from the current group; a user without other groups is deleted. */
removeUser: (email: string) => request<{ deleted: boolean }>('DELETE', `/users/${enc(email)}`),
createKey: (email: string, label: string) => request<AccessKeyView>('POST', `/users/${enc(email)}/keys`, { label }), createKey: (email: string, label: string) => request<AccessKeyView>('POST', `/users/${enc(email)}/keys`, { label }),
deleteKey: (email: string, keyId: string) => request<void>('DELETE', `/users/${enc(email)}/keys/${enc(keyId)}`), deleteKey: (email: string, keyId: string) => request<void>('DELETE', `/users/${enc(email)}/keys/${enc(keyId)}`),
+6 -3
View File
@@ -34,7 +34,7 @@ export function GroupsPage({ me, onGroupsChanged }: { me: MeResponse; onGroupsCh
<ErrorText error={action.error ?? groups.error} /> <ErrorText error={action.error ?? groups.error} />
<ul className="list"> <ul className="list">
{groups.data?.map((g) => ( {groups.data?.map((g) => (
<GroupRow key={g.id} group={g} isCurrent={g.id === me.currentGroup.id} isOwn={g.id === me.group.id} onChanged={changed} /> <GroupRow key={g.id} group={g} isCurrent={g.id === me.currentGroup.id} isOwn={me.groups.some((m) => m.id === g.id)} onChanged={changed} />
))} ))}
</ul> </ul>
</section> </section>
@@ -72,7 +72,7 @@ function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurr
<div className="grow"> <div className="grow">
{group.name} {group.name}
{isCurrent && <span className="pill accent">current</span>} {isCurrent && <span className="pill accent">current</span>}
{isOwn && <span className="pill">yours</span>} {isOwn && <span className="pill">member</span>}
</div> </div>
<button className="secondary" onClick={() => api.setCurrentGroup(group.id).then(onChanged)} disabled={isCurrent}> <button className="secondary" onClick={() => api.setCurrentGroup(group.id).then(onChanged)} disabled={isCurrent}>
Select Select
@@ -84,7 +84,10 @@ function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurr
className="danger" className="danger"
disabled={action.busy} disabled={action.busy}
onClick={async () => { onClick={async () => {
const ok = confirm(`Delete group "${group.name}"?\n\nAll its users, their keys and all cards with images will be deleted permanently.`); const ok = confirm(
`Delete group "${group.name}"?\n\nAll its cards with images are deleted permanently; members keep their other groups. ` +
'Not possible while it is the only group of some user.',
);
if (ok && (await action.run(async () => (await api.deleteGroup(group.id), true)))) await onChanged(); if (ok && (await action.run(async () => (await api.deleteGroup(group.id), true)))) await onChanged();
}} }}
> >
+50 -56
View File
@@ -1,33 +1,33 @@
import { useState } from 'react'; import { useState } from 'react';
import { ROLES, type AccessKeyView, type Group, type MeResponse, type Role, type UserView } from '@cardy/shared'; import { GROUP_ROLES, type AccessKeyView, type GroupRole, type MeResponse, type UserView } from '@cardy/shared';
import { api } from '../api.js'; import { api } from '../api.js';
import { ErrorText, KeyQrModal, formatDate, useAction, useLoad } from '../components.js'; import { ErrorText, KeyQrModal, formatDate, useAction, useLoad } from '../components.js';
export function UsersPage({ me }: { me: MeResponse }) { export function UsersPage({ me }: { me: MeResponse }) {
const users = useLoad(api.users); const users = useLoad(api.users);
const isSuper = me.role === 'superadmin';
const groups = useLoad(() => (isSuper ? api.groups() : Promise.resolve([] as Group[])));
const assignableRoles: Role[] = isSuper ? [...ROLES] : ['user', 'admin'];
return ( return (
<section> <section>
<h1>Users</h1> <h1>Users</h1>
<p className="muted">Users of group “{me.currentGroup.name}”. Users sign in to this site through Authelia with the same email.</p> <p className="muted">
<AddUser roles={assignableRoles} onAdded={users.reload} /> Members of group “{me.currentGroup.name}”. A user can belong to several groups with a separate role in each; users sign in to
this site through Authelia with the same email.
</p>
<AddUser onAdded={users.reload} />
<ErrorText error={users.error} /> <ErrorText error={users.error} />
{users.data?.length === 0 && <p className="empty">No users in this group.</p>} {users.data?.length === 0 && <p className="empty">No users in this group.</p>}
<ul className="list"> <ul className="list">
{users.data?.map((u) => ( {users.data?.map((u) => (
<UserRow key={u.email} user={u} me={me} roles={assignableRoles} groups={groups.data ?? []} onChanged={users.reload} /> <UserRow key={u.email} user={u} me={me} onChanged={users.reload} />
))} ))}
</ul> </ul>
</section> </section>
); );
} }
function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<void> }) { function AddUser({ onAdded }: { onAdded: () => Promise<void> }) {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [role, setRole] = useState<Role>('user'); const [role, setRole] = useState<GroupRole>('user');
const action = useAction(); const action = useAction();
return ( return (
<> <>
@@ -35,7 +35,7 @@ function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<voi
className="row" className="row"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
if (await action.run(() => api.createUser(email.trim(), role))) { if (await action.run(() => api.addUser(email.trim(), role))) {
setEmail(''); setEmail('');
setRole('user'); setRole('user');
await onAdded(); await onAdded();
@@ -43,42 +43,40 @@ function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<voi
}} }}
> >
<input type="email" placeholder="email@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required /> <input type="email" placeholder="email@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
<select value={role} onChange={(e) => setRole(e.target.value as Role)}> <select value={role} onChange={(e) => setRole(e.target.value as GroupRole)} aria-label="Role in this group">
{roles.map((r) => ( {GROUP_ROLES.map((r) => (
<option key={r}>{r}</option> <option key={r}>{r}</option>
))} ))}
</select> </select>
<button type="submit" disabled={action.busy}> <button type="submit" disabled={action.busy}>
Add user Add to group
</button> </button>
</form> </form>
<p className="muted small">An email that already exists in another group is added to this group as well.</p>
<ErrorText error={action.error} /> <ErrorText error={action.error} />
</> </>
); );
} }
function UserRow({ function UserRow({ user, me, onChanged }: { user: UserView; me: MeResponse; onChanged: () => Promise<void> }) {
user,
me,
roles,
groups,
onChanged,
}: {
user: UserView;
me: MeResponse;
roles: Role[];
groups: Group[];
onChanged: () => Promise<void>;
}) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const action = useAction(); const action = useAction();
const isSelf = user.email === me.email; const isSelf = user.email === me.email;
const canManage = me.role === 'superadmin' || user.role !== 'superadmin'; const canManage = me.superadmin || !user.superadmin;
const otherGroups = user.groups.filter((g) => g.id !== me.currentGroup.id);
const update = async (patch: { role?: Role; groupId?: string }) => { const update = async (patch: { role?: GroupRole; superadmin?: boolean }) => {
if (await action.run(() => api.updateUser(user.email, patch))) await onChanged(); if (await action.run(() => api.updateUser(user.email, patch))) await onChanged();
}; };
const remove = async () => {
const question = otherGroups.length
? `Remove ${user.email} from “${me.currentGroup.name}”? They stay in ${otherGroups.map((g) => `“${g.name}”`).join(', ')}.`
: `Delete ${user.email}? “${me.currentGroup.name}” is their only group, so the user and all their keys are deleted.`;
if (!confirm(question)) return;
if (await action.run(() => api.removeUser(user.email))) await onChanged();
};
return ( return (
<li className="list-item column"> <li className="list-item column">
<div className="row"> <div className="row">
@@ -86,50 +84,45 @@ function UserRow({
<div> <div>
{user.email} {user.email}
{isSelf && <span className="pill">you</span>} {isSelf && <span className="pill">you</span>}
{user.superadmin && <span className="pill accent">superadmin</span>}
</div> </div>
<div className="muted small"> <div className="muted small">
{user.keys.length} key{user.keys.length === 1 ? '' : 's'} {user.keys.length} key{user.keys.length === 1 ? '' : 's'}
{otherGroups.length > 0 && ` · also in ${otherGroups.map((g) => `${g.name} (${g.role})`).join(', ')}`}
</div> </div>
</div> </div>
<select <select
value={user.role} value={user.role}
disabled={!canManage || isSelf || action.busy} disabled={!canManage || isSelf || action.busy}
onChange={(e) => update({ role: e.target.value as Role })} onChange={(e) => update({ role: e.target.value as GroupRole })}
aria-label="Role" aria-label="Role in this group"
title="Role in this group"
> >
{(roles.includes(user.role) ? roles : [...roles, user.role]).map((r) => ( {GROUP_ROLES.map((r) => (
<option key={r}>{r}</option> <option key={r}>{r}</option>
))} ))}
</select> </select>
{me.role === 'superadmin' && groups.length > 0 && ( {me.superadmin && (
<select <label className="checkbox" title="Superadmins manage all groups">
value={user.groupId} <input
disabled={action.busy} type="checkbox"
onChange={(e) => { checked={user.superadmin}
const target = groups.find((g) => g.id === e.target.value); disabled={isSelf || action.busy}
if (confirm(`Move ${user.email} to group "${target?.name}"?`)) void update({ groupId: e.target.value }); onChange={(e) => {
}} const on = e.target.checked;
aria-label="Group" if (confirm(on ? `Make ${user.email} a superadmin of all groups?` : `Remove superadmin rights from ${user.email}?`)) {
> void update({ superadmin: on });
{groups.map((g) => ( }
<option key={g.id} value={g.id}> }}
{g.name} />
</option> superadmin
))} </label>
</select>
)} )}
<button className="secondary" onClick={() => setExpanded((x) => !x)} disabled={!canManage}> <button className="secondary" onClick={() => setExpanded((x) => !x)} disabled={!canManage}>
{expanded ? 'Hide keys' : 'Keys'} {expanded ? 'Hide keys' : 'Keys'}
</button> </button>
<button <button className="danger" disabled={!canManage || isSelf || action.busy} onClick={remove}>
className="danger" {otherGroups.length ? 'Remove from group' : 'Delete user'}
disabled={!canManage || isSelf || action.busy}
onClick={async () => {
if (!confirm(`Delete user ${user.email} and all their keys?`)) return;
if (await action.run(async () => (await api.deleteUser(user.email), true))) await onChanged();
}}
>
Delete
</button> </button>
</div> </div>
<ErrorText error={action.error} /> <ErrorText error={action.error} />
@@ -145,6 +138,7 @@ function UserKeys({ user, onChanged }: { user: UserView; onChanged: () => Promis
return ( return (
<div className="subpanel"> <div className="subpanel">
<p className="muted small">A key signs the app in to all groups of the user.</p>
{user.keys.length === 0 && <p className="muted small">No keys.</p>} {user.keys.length === 0 && <p className="muted small">No keys.</p>}
<ul className="list compact"> <ul className="list compact">
{user.keys.map((k) => ( {user.keys.map((k) => (
+21 -3
View File
@@ -1,13 +1,22 @@
import type { CodeType } from './codeTypes.js'; 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 const ROLES = ['user', 'admin', 'superadmin'] as const;
export type Role = (typeof ROLES)[number]; 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 { export interface Group {
id: string; id: string;
name: string; name: string;
} }
export interface GroupMembership extends Group {
role: GroupRole;
}
export interface ImageRef { export interface ImageRef {
id: string; id: string;
file: string; file: string;
@@ -41,22 +50,31 @@ export interface AccessKeyView {
deepLink: string; deepLink: string;
} }
/** A member of the current group, as shown to its admins. */
export interface UserView { export interface UserView {
email: string; email: string;
groupId: string; superadmin: boolean;
role: Role; /** Role in the current group. */
role: GroupRole;
/** All groups of the user. */
groups: GroupMembership[];
keys: AccessKeyView[]; keys: AccessKeyView[];
} }
export interface MeResponse { export interface MeResponse {
email: string; email: string;
superadmin: boolean;
/** Effective role in the current group. */
role: Role; role: Role;
group: Group; groups: GroupMembership[];
currentGroup: Group; currentGroup: Group;
} }
export interface ApiAuthResponse { export interface ApiAuthResponse {
email: string; 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; group: Group;
} }
+6 -5
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { CODE_TYPES } from './codeTypes.js'; 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'); 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), 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({ export const userCreateSchema = z.object({
email: emailSchema, email: emailSchema,
role: z.enum(ROLES).default('user'), role: z.enum(GROUP_ROLES).default('user'),
groupId: z.string().optional(),
}); });
/** Role in the current group; `superadmin` is the global flag (superadmins only). */
export const userPatchSchema = z.object({ export const userPatchSchema = z.object({
role: z.enum(ROLES).optional(), role: z.enum(GROUP_ROLES).optional(),
groupId: z.string().optional(), superadmin: z.boolean().optional(),
}); });
export const keyCreateSchema = z.object({ export const keyCreateSchema = z.object({