multiple groups per user
This commit is contained in:
@@ -83,8 +83,9 @@ groups:
|
||||
name: Family
|
||||
users:
|
||||
- email: alice@example.com # уникален глобально, хранится в нижнем регистре
|
||||
groupId: 3f1c…
|
||||
role: superadmin # user | admin | superadmin
|
||||
superadmin: true # глобальный флаг
|
||||
memberships: # одна или несколько групп, в каждой своя роль
|
||||
- { groupId: 3f1c…, role: admin } # user | admin
|
||||
keys:
|
||||
- id: 9a0b… # UUID
|
||||
label: Alice's phone # опционально
|
||||
@@ -121,13 +122,13 @@ cards:
|
||||
|
||||
### 3.3. Пользователи, группы, роли
|
||||
|
||||
- Пользователь состоит ровно в одной группе.
|
||||
- Пользователь состоит в одной или нескольких группах (но не в нуле), в каждой — со своей ролью `user` или `admin`. `superadmin` — глобальный флаг: суперадмин — администратор всех групп. Записи старого формата (`groupId` + `role`) читаются как членство в одной группе и сохраняются в новом формате при первой записи.
|
||||
- `admin` — администратор только своей группы. `superadmin` — администратор всех групп.
|
||||
- Карты общие для всех пользователей группы.
|
||||
- Группу, в которой есть хотя бы один суперадмин, удалить нельзя. При удалении группы удаляются все её пользователи, их ключи и все карты с картинками.
|
||||
- Нельзя удалить самого себя и нельзя понизить свою роль. Нельзя удалить или понизить последнего суперадмина.
|
||||
- `admin` может создавать, удалять и менять роль (`user`/`admin`) пользователей своей группы. Назначать `superadmin`, изменять суперадминов и переносить пользователей между группами может только `superadmin`.
|
||||
- Новые ключи генерирует только админ (для пользователей своей группы) или суперадмин.
|
||||
- Группу нельзя удалить, пока она единственная для какого-либо пользователя (сервер перечисляет таких пользователей). При удалении группы удаляются её карты с картинками и членства в ней; пользователи остаются в своих других группах.
|
||||
- Нельзя удалить себя из группы, менять свою роль и свой флаг superadmin. Нельзя снять флаг с последнего суперадмина или удалить его.
|
||||
- Админ группы добавляет в неё пользователей (новый email создаёт пользователя, существующий — добавляет ему членство), меняет их роль в этой группе и убирает их из неё; если группа была у пользователя единственной, пользователь удаляется вместе с ключами. Назначать и снимать superadmin и изменять суперадминов может только суперадмин.
|
||||
- Новые ключи генерирует только админ (для участников своей группы) или суперадмин. Ключ принадлежит пользователю и даёт приложению доступ ко всем его группам.
|
||||
|
||||
### 3.4. Ключи доступа
|
||||
|
||||
@@ -139,7 +140,7 @@ cards:
|
||||
### 3.5. Аутентификация административного UI
|
||||
|
||||
- Пользователь определяется по заголовку `Remote-Email` (без учёта регистра). Нет заголовка или пользователь не найден в `users.yaml` — `401`. Недостаточно прав — `403`.
|
||||
- Текущая группа: для `user` и `admin` всегда своя группа. Для `superadmin` — значение cookie `cardy_group` (если его нет или группа не существует — своя группа); UI суперадмина позволяет выбрать другую группу, что записывает cookie.
|
||||
- Текущая группа — значение cookie `cardy_group`, если пользователь в ней состоит (суперадмин — любая существующая группа); иначе первая группа пользователя. Переключатель в шапке есть у всех, у кого больше одной группы (у суперадмина — все группы). Роль и права в UI определяются ролью в текущей группе.
|
||||
- Если задан `ADMIN_PROXY_SECRET`, без правильного заголовка `X-Cardy-Proxy-Secret` — `401`.
|
||||
- Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF).
|
||||
|
||||
@@ -163,9 +164,9 @@ cards:
|
||||
| `GET /my-keys` | все | свои ключи (расшифрованные) и deep link |
|
||||
| `GET /groups` | superadmin | список групп |
|
||||
| `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id` | superadmin | CRUD групп |
|
||||
| `PUT /current-group` | superadmin | выбор текущей группы (cookie) |
|
||||
| `GET /users` | admin | пользователи текущей группы с ключами |
|
||||
| `POST /users`, `PATCH /users/:email`, `DELETE /users/:email` | admin | CRUD пользователей |
|
||||
| `PUT /current-group` | все (своя группа), superadmin (любая) | выбор текущей группы (cookie) |
|
||||
| `GET /users` | admin | участники текущей группы: роль в ней, флаг superadmin, все их группы, ключи |
|
||||
| `POST /users` `{email, role}` | admin | добавить в текущую группу (новый email создаёт пользователя) |
|
||||
| `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей |
|
||||
| `GET /cards` | admin | все карты текущей группы, включая удалённые |
|
||||
| `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) |
|
||||
@@ -176,11 +177,11 @@ cards:
|
||||
|
||||
### 3.8. Клиентский API (публично `/api`, на сервере — от корня)
|
||||
|
||||
Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Группа определяется по владельцу ключа. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются.
|
||||
Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Ключ даёт доступ ко всем группам владельца: маршруты карт принимают группу — `/api/groups/:groupId/cards…` (чужая группа — `404`); прежние маршруты без группы (`/api/cards…`) работают с первой группой владельца — для приложений, выпущенных до поддержки нескольких групп. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются.
|
||||
|
||||
| Метод и путь | Описание |
|
||||
|---|---|
|
||||
| `GET /api/auth` | проверка ключа; возвращает email и группу |
|
||||
| `GET /api/auth` | проверка ключа; возвращает email, `groups` (все группы владельца) и `group` (первая) |
|
||||
| `GET /api/cards` | все неудалённые карты группы, без пагинации |
|
||||
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID |
|
||||
| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий, пустые `tags` — теги; опционально файл `logo`, опционально `removeLogo=true`) |
|
||||
@@ -203,7 +204,7 @@ React + Vite + TS; PWA и Android-приложение через Capacitor. PWA
|
||||
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
|
||||
- **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
|
||||
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
|
||||
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
|
||||
- **Группы**: приложение хранит список групп пользователя и текущую группу; карты, даты использования и версии картинок хранятся отдельно для каждой группы. Если групп больше одной, заголовок экрана карт — название текущей группы, нажатие открывает выбор группы; группа, карты которой ещё не скачивались на устройство, при выборе сразу обновляется. «Обновить» заодно обновляет список групп (данные групп, в которых пользователь больше не состоит, удаляются). Сессии старых версий приложения (без групп) при первом обновлении получают список групп, а их локальные карты переходят в первую группу. (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
|
||||
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
|
||||
- **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
|
||||
- **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
|
||||
|
||||
+89
-24
@@ -1,10 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { parseLoginDeepLink, type Card } from '@cardy/shared';
|
||||
import { createApi } from './api.js';
|
||||
import { createApi, type GroupApi } from './api.js';
|
||||
import { useNavigation, type LoginPrefill } from './navigation.js';
|
||||
import { onBackButton, onDeepLink, onResumeAfter, runOverlayBack } from './platform.js';
|
||||
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
|
||||
import { refreshCardList, syncImages } from './sync.js';
|
||||
import {
|
||||
LEGACY_GROUP_ID,
|
||||
cardImageIds,
|
||||
currentGroup,
|
||||
images,
|
||||
sessionGroups,
|
||||
storage,
|
||||
type PinRecord,
|
||||
type Session,
|
||||
type UsageMap,
|
||||
} from './storage.js';
|
||||
import { refreshCardList, refreshGroups, syncImages } from './sync.js';
|
||||
import {
|
||||
applyPendingUpdate,
|
||||
checkForApkUpdate,
|
||||
@@ -32,9 +42,12 @@ interface Boot {
|
||||
export function App() {
|
||||
const [boot, setBoot] = useState<Boot>();
|
||||
useEffect(() => {
|
||||
void Promise.all([storage.getSession(), storage.getPin(), storage.getCards(), storage.getUsage()]).then(
|
||||
([session, pin, cards, usage]) => setBoot({ session, pin, cards, usage }),
|
||||
);
|
||||
void (async () => {
|
||||
const [session, pin] = await Promise.all([storage.getSession(), storage.getPin()]);
|
||||
const groupId = session ? currentGroup(session).id : LEGACY_GROUP_ID;
|
||||
const [cards, usage] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId)]);
|
||||
setBoot({ session, pin, cards, usage });
|
||||
})();
|
||||
}, []);
|
||||
if (!boot) return <div className="splash" />;
|
||||
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 api = useMemo(() => (session ? createApi(session) : undefined), [session]);
|
||||
// Cards shown are those of the current group (switchable when the user has several).
|
||||
const groupId = session ? currentGroup(session).id : LEGACY_GROUP_ID;
|
||||
const groupApi = useMemo(() => api?.forGroup(groupId), [api, groupId]);
|
||||
const groupIdRef = useRef(groupId);
|
||||
groupIdRef.current = groupId;
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
/** Shows the stored cards of a group. */
|
||||
const loadGroup = useCallback(async (id: string) => {
|
||||
const [list, u] = await Promise.all([storage.getCards(id), storage.getUsage(id)]);
|
||||
if (groupIdRef.current !== id) return;
|
||||
setCards(list);
|
||||
setUsage(u);
|
||||
}, []);
|
||||
|
||||
// ---- data ----
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!api) return;
|
||||
const current = sessionRef.current;
|
||||
if (!current) return;
|
||||
setSync({ busy: true });
|
||||
let list: Card[];
|
||||
let target: string;
|
||||
let targetApi: GroupApi;
|
||||
try {
|
||||
list = await refreshCardList(api);
|
||||
// Groups first: memberships may have changed (and legacy sessions get their groups here).
|
||||
const updated = await refreshGroups(createApi(current), current);
|
||||
target = currentGroup(updated).id;
|
||||
sessionRef.current = updated;
|
||||
groupIdRef.current = target;
|
||||
setSession(updated);
|
||||
targetApi = createApi(updated).forGroup(target);
|
||||
list = await refreshCardList(targetApi, target);
|
||||
} catch (e) {
|
||||
setSync({ busy: false, error: (e as Error).message });
|
||||
return;
|
||||
}
|
||||
if (groupIdRef.current !== target) return setSync({ busy: false });
|
||||
setCards(list);
|
||||
setUsage(await storage.getUsage());
|
||||
setUsage(await storage.getUsage(target));
|
||||
setSync({ busy: true, progress: { done: 0, total: 0 } });
|
||||
try {
|
||||
const result = await syncImages(
|
||||
api,
|
||||
targetApi,
|
||||
target,
|
||||
list,
|
||||
() => setImageEpoch((e) => e + 1),
|
||||
(done, total) => setSync({ busy: true, progress: { done, total } }),
|
||||
@@ -95,33 +135,54 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
} catch (e) {
|
||||
setSync({ busy: false, error: (e as Error).message });
|
||||
}
|
||||
}, [api]);
|
||||
}, []);
|
||||
|
||||
/** Applies a card returned by the server after add/edit. */
|
||||
/** Switches to another group of the user; a group never downloaded to this device is refreshed. */
|
||||
const switchGroup = useCallback(
|
||||
async (id: string) => {
|
||||
const current = sessionRef.current;
|
||||
if (!current || id === groupIdRef.current) return;
|
||||
const next = { ...current, currentGroupId: id };
|
||||
await storage.setSession(next);
|
||||
// Refs first: the refresh below must already see the new group (state updates land on the next render).
|
||||
sessionRef.current = next;
|
||||
groupIdRef.current = id;
|
||||
setSession(next);
|
||||
setSync({ busy: false });
|
||||
await loadGroup(id);
|
||||
reset([{ name: 'cards' }]);
|
||||
if (!(await storage.hasCards(id))) void refresh();
|
||||
},
|
||||
[loadGroup, reset, refresh],
|
||||
);
|
||||
|
||||
/** Applies a card returned by the server after add/edit (in the current group). */
|
||||
const saveCardLocally = useCallback(async (card: Card) => {
|
||||
const list = await storage.getCards();
|
||||
const gid = groupIdRef.current;
|
||||
const list = await storage.getCards(gid);
|
||||
const index = list.findIndex((c) => c.id === card.id);
|
||||
if (index >= 0) list[index] = card;
|
||||
else list.push(card);
|
||||
await storage.setCards(list);
|
||||
await storage.setCards(gid, list);
|
||||
// Images uploaded from this device are stored already; record the version only if nothing is missing.
|
||||
const stored = await images.storedKeys();
|
||||
if (cardImageIds(card).every((id) => stored.has(images.key(card.id, id)))) {
|
||||
await images.prune(card.id, cardImageIds(card));
|
||||
const versions = await storage.getVersions();
|
||||
const versions = await storage.getVersions(gid);
|
||||
versions[card.id] = card.imagesVersion;
|
||||
await storage.setVersions(versions);
|
||||
await storage.setVersions(gid, versions);
|
||||
}
|
||||
setCards(list);
|
||||
setImageEpoch((e) => e + 1);
|
||||
}, []);
|
||||
|
||||
const removeCardLocally = useCallback(async (id: string) => {
|
||||
const [list, u, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
|
||||
const gid = groupIdRef.current;
|
||||
const [list, u, versions] = await Promise.all([storage.getCards(gid), storage.getUsage(gid), storage.getVersions(gid)]);
|
||||
const next = list.filter((c) => c.id !== id);
|
||||
delete u[id];
|
||||
delete versions[id];
|
||||
await Promise.all([storage.setCards(next), storage.setUsage(u), storage.setVersions(versions), images.prune(id)]);
|
||||
await Promise.all([storage.setCards(gid, next), storage.setUsage(gid, u), storage.setVersions(gid, versions), images.prune(id)]);
|
||||
setCards(next);
|
||||
setUsage({ ...u });
|
||||
}, []);
|
||||
@@ -130,11 +191,12 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
const shownCardId = !locked && route?.name === 'card' ? route.id : undefined;
|
||||
useEffect(() => {
|
||||
if (!shownCardId) return;
|
||||
const gid = groupIdRef.current;
|
||||
void (async () => {
|
||||
const u = await storage.getUsage();
|
||||
const u = await storage.getUsage(gid);
|
||||
u[shownCardId] = new Date().toISOString();
|
||||
await storage.setUsage(u);
|
||||
setUsage({ ...u });
|
||||
await storage.setUsage(gid, u);
|
||||
if (groupIdRef.current === gid) setUsage({ ...u });
|
||||
})();
|
||||
}, [shownCardId, stack.length]);
|
||||
|
||||
@@ -245,7 +307,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
|
||||
// ---- screens ----
|
||||
|
||||
if (!session || !api) {
|
||||
if (!session || !api || !groupApi) {
|
||||
const prefill = route?.name === 'login' ? route.prefill : undefined;
|
||||
return <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 })}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await api.deleteCard(card.id);
|
||||
await groupApi.deleteCard(card.id);
|
||||
} catch (e) {
|
||||
// Already deleted elsewhere: drop it locally as well.
|
||||
if ((e as { status?: number }).status !== 404) throw e;
|
||||
@@ -289,7 +351,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
<EditScreen
|
||||
key={route.id ?? 'new'}
|
||||
card={card}
|
||||
api={api}
|
||||
api={groupApi}
|
||||
onCancel={goBack}
|
||||
saveCardLocally={saveCardLocally}
|
||||
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}
|
||||
sync={sync}
|
||||
session={session}
|
||||
groups={sessionGroups(session)}
|
||||
currentGroupId={groupId}
|
||||
onSwitchGroup={switchGroup}
|
||||
apkUpdate={apkUpdate}
|
||||
onDismissSync={() => setSync({ busy: false })}
|
||||
onRefresh={refresh}
|
||||
|
||||
+23
-12
@@ -1,5 +1,5 @@
|
||||
import type { ApiAuthResponse, Card } from '@cardy/shared';
|
||||
import type { Session } from './storage.js';
|
||||
import { LEGACY_GROUP_ID, type Session } from './storage.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -62,18 +62,29 @@ export function createApi(session: Session) {
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/** Card endpoints of one group; the legacy pseudo group uses the group-less routes (first group). */
|
||||
const forGroup = (groupId: string) => {
|
||||
const g = groupId === LEGACY_GROUP_ID ? '' : `/groups/${enc(groupId)}`;
|
||||
return {
|
||||
cards: () => json<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 {
|
||||
auth: () => json<ApiAuthResponse>('GET', '/auth'),
|
||||
cards: () => json<Card[]>('GET', '/cards'),
|
||||
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();
|
||||
},
|
||||
forGroup,
|
||||
};
|
||||
}
|
||||
|
||||
export type GroupApi = ReturnType<Api['forGroup']>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
|
||||
import { cardMatchesQuery, textColorOn, type Card, type Group } from '@cardy/shared';
|
||||
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
|
||||
import type { Session, UsageMap } from '../storage.js';
|
||||
import type { PublishedApk } from '../updater.js';
|
||||
@@ -19,6 +19,9 @@ export function CardsScreen({
|
||||
imageEpoch,
|
||||
sync,
|
||||
session,
|
||||
groups,
|
||||
currentGroupId,
|
||||
onSwitchGroup,
|
||||
apkUpdate,
|
||||
onDismissSync,
|
||||
onRefresh,
|
||||
@@ -31,6 +34,10 @@ export function CardsScreen({
|
||||
imageEpoch: number;
|
||||
sync: SyncState;
|
||||
session: Session;
|
||||
/** The user's groups (one or more). */
|
||||
groups: Group[];
|
||||
currentGroupId: string;
|
||||
onSwitchGroup: (groupId: string) => void;
|
||||
apkUpdate?: PublishedApk;
|
||||
onDismissSync: () => void;
|
||||
onRefresh: () => void;
|
||||
@@ -40,15 +47,28 @@ export function CardsScreen({
|
||||
}) {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [confirmKey, setConfirmKey] = useState(false);
|
||||
const [pickingGroup, setPickingGroup] = useState(false);
|
||||
const filterInput = useRef<HTMLInputElement>(null);
|
||||
const group = groups.find((g) => g.id === currentGroupId) ?? groups[0]!;
|
||||
|
||||
// Search by name, tags and comment.
|
||||
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
|
||||
|
||||
// With several groups the title is the current group and switches between them.
|
||||
const title =
|
||||
groups.length > 1 ? (
|
||||
<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 (
|
||||
<Screen
|
||||
className="cards-screen"
|
||||
title="Cards"
|
||||
title={title}
|
||||
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
|
||||
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
|
||||
>
|
||||
@@ -97,7 +117,7 @@ export function CardsScreen({
|
||||
{cards.length === 0 ? (
|
||||
<div className="empty">
|
||||
<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>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty muted">No cards match “{filter}”.</div>
|
||||
@@ -118,6 +138,30 @@ export function CardsScreen({
|
||||
<Icon name="plus" size={28} />
|
||||
</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 && (
|
||||
<ConfirmDialog
|
||||
title="Change access key?"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type CodeType,
|
||||
type ImageRef,
|
||||
} from '@cardy/shared';
|
||||
import { ApiError, type Api } from '../api.js';
|
||||
import { ApiError, type GroupApi } from '../api.js';
|
||||
import { CropScreen } from '../CropScreen.js';
|
||||
import { fileNameFor } from '../imageTools.js';
|
||||
import { ScannerView, scanImageFile } from '../ScannerView.js';
|
||||
@@ -42,7 +42,8 @@ export function EditScreen({
|
||||
saveCardLocally,
|
||||
}: {
|
||||
card?: Card;
|
||||
api: Api;
|
||||
/** Card endpoints of the current group. */
|
||||
api: GroupApi;
|
||||
onCancel: () => void;
|
||||
onSaved: (card: Card) => void;
|
||||
onMissing: (id: string) => void;
|
||||
|
||||
@@ -38,7 +38,9 @@ export function LoginScreen({
|
||||
setError(undefined);
|
||||
try {
|
||||
const me = await createApi({ server: normalized, key: trimmedKey }).auth();
|
||||
onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groupName: me.group.name });
|
||||
// `groups` comes from multi-group servers; older ones only report the single `group`.
|
||||
const groups = me.groups?.length ? me.groups : [me.group];
|
||||
onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groups, currentGroupId: groups[0]!.id });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { clear, createStore, del, delMany, get, keys, set } from 'idb-keyval';
|
||||
import type { Card } from '@cardy/shared';
|
||||
import type { Card, Group } from '@cardy/shared';
|
||||
|
||||
export interface Session {
|
||||
/** Base URL of the client API, e.g. https://dev.aggtaa.com/api */
|
||||
@@ -8,9 +8,30 @@ export interface Session {
|
||||
key: string;
|
||||
/** From the server at sign-in, for display. */
|
||||
email?: string;
|
||||
/** The key owner's groups (one or more); missing in sessions from before multi-group. */
|
||||
groups?: Group[];
|
||||
/** Group whose cards are shown. */
|
||||
currentGroupId?: string;
|
||||
/** Sessions from before multi-group: name of their only group. */
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pseudo group of sessions from before multi-group: its data lives under the old storage keys and is
|
||||
* served by the group-less API routes (the owner's first group) until the first sync moves it.
|
||||
*/
|
||||
export const LEGACY_GROUP_ID = '';
|
||||
|
||||
/** Groups of the session; a legacy session has the single pseudo group. */
|
||||
export function sessionGroups(session: Session): Group[] {
|
||||
return session.groups?.length ? session.groups : [{ id: LEGACY_GROUP_ID, name: session.groupName ?? 'Cards' }];
|
||||
}
|
||||
|
||||
export function currentGroup(session: Session): Group {
|
||||
const groups = sessionGroups(session);
|
||||
return groups.find((g) => g.id === session.currentGroupId) ?? groups[0]!;
|
||||
}
|
||||
|
||||
export interface PinRecord {
|
||||
salt: string;
|
||||
hash: string;
|
||||
@@ -44,17 +65,39 @@ async function setJson(key: string, value: unknown): Promise<void> {
|
||||
else await Preferences.set({ key, value: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
/** Per-group key; the legacy pseudo group keeps the old unsuffixed keys. */
|
||||
const groupKey = (base: string, groupId: string) => (groupId === LEGACY_GROUP_ID ? base : `${base}:${groupId}`);
|
||||
|
||||
export const storage = {
|
||||
getSession: () => getJson<Session | null>(K.session, null),
|
||||
setSession: (s: Session | null) => setJson(K.session, s),
|
||||
getPin: () => getJson<PinRecord | null>(K.pin, null),
|
||||
setPin: (p: PinRecord | null) => setJson(K.pin, p),
|
||||
getCards: () => getJson<Card[]>(K.cards, []),
|
||||
setCards: (cards: Card[]) => setJson(K.cards, cards),
|
||||
getUsage: () => getJson<UsageMap>(K.usage, {}),
|
||||
setUsage: (u: UsageMap) => setJson(K.usage, u),
|
||||
getVersions: () => getJson<VersionMap>(K.versions, {}),
|
||||
setVersions: (v: VersionMap) => setJson(K.versions, v),
|
||||
|
||||
// Cards, usage and image versions are stored per group.
|
||||
getCards: (groupId: string) => getJson<Card[]>(groupKey(K.cards, groupId), []),
|
||||
/** Whether cards of the group were ever downloaded to this device. */
|
||||
hasCards: async (groupId: string) => (await Preferences.get({ key: groupKey(K.cards, groupId) })).value !== null,
|
||||
setCards: (groupId: string, cards: Card[]) => setJson(groupKey(K.cards, groupId), cards),
|
||||
getUsage: (groupId: string) => getJson<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. */
|
||||
async clearAll(): Promise<void> {
|
||||
|
||||
@@ -997,6 +997,55 @@ textarea:focus {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Group switcher in the card list title. */
|
||||
.group-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-title span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-title:active:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.group-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-list button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-list button.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
+34
-11
@@ -1,6 +1,6 @@
|
||||
import type { Card } from '@cardy/shared';
|
||||
import type { Api } from './api.js';
|
||||
import { cardImageIds, images, storage, type UsageMap, type VersionMap } from './storage.js';
|
||||
import type { Api, GroupApi } from './api.js';
|
||||
import { LEGACY_GROUP_ID, cardImageIds, images, storage, type Session, type UsageMap, type VersionMap } from './storage.js';
|
||||
|
||||
export const MAX_PARALLEL_DOWNLOADS = 5;
|
||||
|
||||
@@ -31,13 +31,35 @@ export function cardsNeedingImages(cards: Card[], versions: VersionMap, stored:
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the list of cards and replaces the local list (only when the download succeeded).
|
||||
* Re-reads the key owner's groups from the server and returns the updated session:
|
||||
* - a legacy session (from before multi-group) gets its groups, and its local data moves to the first
|
||||
* group (the one the group-less routes served);
|
||||
* - local data of groups the user no longer belongs to is removed;
|
||||
* - the current group falls back to the first group when it is gone.
|
||||
*/
|
||||
export async function refreshGroups(api: Api, session: Session): Promise<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.
|
||||
*/
|
||||
export async function refreshCardList(api: Api): Promise<Card[]> {
|
||||
export async function refreshCardList(api: GroupApi, groupId: string): Promise<Card[]> {
|
||||
const fresh = await api.cards();
|
||||
const [old, usage, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
|
||||
await storage.setCards(fresh);
|
||||
const [old, usage, versions] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId), storage.getVersions(groupId)]);
|
||||
await storage.setCards(groupId, fresh);
|
||||
|
||||
const freshIds = new Set(fresh.map((c) => c.id));
|
||||
const gone = old.filter((c) => !freshIds.has(c.id));
|
||||
@@ -47,7 +69,7 @@ export async function refreshCardList(api: Api): Promise<Card[]> {
|
||||
delete versions[c.id];
|
||||
await images.prune(c.id);
|
||||
}
|
||||
await Promise.all([storage.setUsage(usage), storage.setVersions(versions)]);
|
||||
await Promise.all([storage.setUsage(groupId, usage), storage.setVersions(groupId, versions)]);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
@@ -62,13 +84,14 @@ export interface ImageSyncResult {
|
||||
* A card's version is recorded only after all its files are stored, so failures are retried next time.
|
||||
*/
|
||||
export async function syncImages(
|
||||
api: Api,
|
||||
api: GroupApi,
|
||||
groupId: string,
|
||||
cards: Card[],
|
||||
onCardDone?: (card: Card) => void,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<ImageSyncResult> {
|
||||
const stored = await images.storedKeys();
|
||||
const changed = cardsNeedingImages(cards, await storage.getVersions(), stored);
|
||||
const changed = cardsNeedingImages(cards, await storage.getVersions(groupId), stored);
|
||||
const tasks = changed.flatMap((card) =>
|
||||
cardImageIds(card)
|
||||
.filter((id) => !stored.has(images.key(card.id, id)))
|
||||
@@ -85,9 +108,9 @@ export async function syncImages(
|
||||
if (failedCards.has(card.id)) return versionWrites;
|
||||
versionWrites = versionWrites.then(async () => {
|
||||
await images.prune(card.id, cardImageIds(card));
|
||||
const versions = await storage.getVersions();
|
||||
const versions = await storage.getVersions(groupId);
|
||||
versions[card.id] = card.imagesVersion;
|
||||
await storage.setVersions(versions);
|
||||
await storage.setVersions(groupId, versions);
|
||||
onCardDone?.(card);
|
||||
});
|
||||
return versionWrites;
|
||||
|
||||
@@ -24,6 +24,7 @@ const paths = {
|
||||
key: 'M14 10a4 4 0 1 0-3.5 3.97L10 14v3H8v2H5v-3l5.03-5.03A4 4 0 0 0 14 10zM15.5 8.5h.01',
|
||||
backspace: 'M21 5H8l-6 7 6 7h13zM11 9l6 6M17 9l-6 6',
|
||||
rotate: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7',
|
||||
chevron: 'M6 9l6 6 6-6',
|
||||
} as const;
|
||||
|
||||
export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
userCreateSchema,
|
||||
userPatchSchema,
|
||||
type Group,
|
||||
type GroupMembership,
|
||||
type MeResponse,
|
||||
} from '@cardy/shared';
|
||||
import type { CardsService } from '../cards.js';
|
||||
@@ -16,7 +17,7 @@ import { ADMIN_API_PREFIX } from '../config.js';
|
||||
import { forwardedPrefix, publicBaseUrl } from '../forwarded.js';
|
||||
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
|
||||
import { parseBody, readMultipart, sendImage } from '../http.js';
|
||||
import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js';
|
||||
import { requireGroupAdmin, requireSuperadmin, roleIn, type StoredUser, type UsersService } from '../users.js';
|
||||
|
||||
export const GROUP_COOKIE = 'cardy_group';
|
||||
export const CSRF_HEADER = 'x-cardy-admin';
|
||||
@@ -24,7 +25,8 @@ export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
|
||||
|
||||
export interface AdminContext {
|
||||
user: StoredUser;
|
||||
ownGroup: Group;
|
||||
/** The user's groups with their role in each. */
|
||||
memberships: GroupMembership[];
|
||||
currentGroup: Group;
|
||||
}
|
||||
|
||||
@@ -75,16 +77,22 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
|
||||
if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy');
|
||||
const user = await users.findUser(email);
|
||||
if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`);
|
||||
const ownGroup = await users.getGroup(user.groupId);
|
||||
if (!ownGroup) throw unauthorized('User group does not exist');
|
||||
let currentGroup = ownGroup;
|
||||
const cookieGroupId = request.cookies[GROUP_COOKIE];
|
||||
if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) {
|
||||
currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup;
|
||||
}
|
||||
request.admin = { user, ownGroup, currentGroup };
|
||||
const memberships = await users.membershipsOf(user);
|
||||
if (!memberships.length) throw unauthorized('None of your groups exists');
|
||||
request.admin = { user, memberships, currentGroup: await resolveCurrentGroup(user, memberships, request.cookies[GROUP_COOKIE]) };
|
||||
});
|
||||
|
||||
/** The group from the cookie if the user may use it (member, or any group for superadmins), else the first own group. */
|
||||
async function resolveCurrentGroup(user: StoredUser, memberships: GroupMembership[], cookieGroupId: string | undefined): Promise<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.
|
||||
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
|
||||
reply.setCookie(GROUP_COOKIE, groupId, {
|
||||
@@ -104,9 +112,15 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
|
||||
// ---- current user ----
|
||||
|
||||
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);
|
||||
return { email: user.email, role: user.role, group: ownGroup, currentGroup };
|
||||
return {
|
||||
email: user.email,
|
||||
superadmin: user.superadmin,
|
||||
role: roleIn(user, currentGroup.id) ?? 'user',
|
||||
groups: memberships,
|
||||
currentGroup,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
|
||||
@@ -134,38 +148,42 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
/** Any user may switch between their groups; superadmins between all groups. */
|
||||
app.put('/current-group', async (request, reply) => {
|
||||
requireSuperadmin(request.admin.user);
|
||||
const { user, memberships } = request.admin;
|
||||
const { groupId } = parseBody(currentGroupSchema, request.body);
|
||||
const group = await users.getGroup(groupId);
|
||||
if (!group) throw notFound('Group not found');
|
||||
if (!user.superadmin && !memberships.some((m) => m.id === group.id)) throw forbidden('You are not a member of this group');
|
||||
setGroupCookie(request, reply, group.id);
|
||||
return group;
|
||||
});
|
||||
|
||||
// ---- users of the current group ----
|
||||
// ---- members of the current group ----
|
||||
|
||||
app.get('/users', async (request) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request)));
|
||||
const members = await users.listMembers(user, groupId);
|
||||
return Promise.all(members.map((u) => users.userView(u, groupId, apiUrl(request))));
|
||||
});
|
||||
|
||||
/** Adds a member (an existing user from another group, or a new user). */
|
||||
app.post('/users', async (request, reply) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body));
|
||||
return reply.code(201).send(users.userView(created, apiUrl(request)));
|
||||
const added = await users.addMember(user, groupId, parseBody(userCreateSchema, request.body));
|
||||
return reply.code(201).send(await users.userView(added, groupId, apiUrl(request)));
|
||||
});
|
||||
|
||||
app.patch<{ Params: EmailParams }>('/users/:email', async (request) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
|
||||
return users.userView(updated, apiUrl(request));
|
||||
const updated = await users.updateMember(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
|
||||
return users.userView(updated, groupId, apiUrl(request));
|
||||
});
|
||||
|
||||
app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => {
|
||||
/** Removes the member from the current group; a user without other groups is deleted. */
|
||||
app.delete<{ Params: EmailParams }>('/users/:email', async (request) => {
|
||||
const { user, groupId } = groupAdmin(request);
|
||||
await users.deleteUser(user, groupId, request.params.email);
|
||||
return reply.code(204).send();
|
||||
return users.removeMember(user, groupId, request.params.email);
|
||||
});
|
||||
|
||||
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||
import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
|
||||
import type { CardsService } from '../cards.js';
|
||||
import { unauthorized } from '../errors.js';
|
||||
import { notFound, unauthorized } from '../errors.js';
|
||||
import { parseBody, readMultipart, sendImage } from '../http.js';
|
||||
import type { KeyOwner, UsersService } from '../users.js';
|
||||
|
||||
@@ -19,8 +19,7 @@ interface Options {
|
||||
|
||||
const CLIENT_ACCESS = { includeDeleted: false };
|
||||
|
||||
type IdParams = { id: string };
|
||||
type ImageParams = { id: string; imageId: string };
|
||||
type Params = Record<string, string>;
|
||||
|
||||
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
|
||||
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
|
||||
@@ -46,50 +45,64 @@ export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards
|
||||
request.keyOwner = owner;
|
||||
});
|
||||
|
||||
const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id;
|
||||
|
||||
app.get('/auth', async (request): Promise<ApiAuthResponse> => {
|
||||
const { user, group } = request.keyOwner;
|
||||
return { email: user.email, group };
|
||||
const { user, groups } = request.keyOwner;
|
||||
return { email: user.email, groups, group: groups[0]! };
|
||||
});
|
||||
|
||||
app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS));
|
||||
// Cards of a group the key's owner belongs to.
|
||||
cardRoutes(app, cards, '/groups/:groupId', (request) => {
|
||||
const groupId = (request.params as Params).groupId;
|
||||
if (!request.keyOwner.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
|
||||
return groupId!;
|
||||
});
|
||||
// Apps from before multi-group: the owner's first group.
|
||||
cardRoutes(app, cards, '', (request) => request.keyOwner.groups[0]!.id);
|
||||
};
|
||||
|
||||
app.post('/cards', async (request, reply) => {
|
||||
function cardRoutes(app: FastifyInstance, cards: CardsService, prefix: string, groupOf: (request: FastifyRequest) => string) {
|
||||
const param = (request: FastifyRequest, name: string) => (request.params as Params)[name]!;
|
||||
|
||||
app.get(`${prefix}/cards`, async (request) => cards.list(groupOf(request), CLIENT_ACCESS));
|
||||
|
||||
app.post(`${prefix}/cards`, async (request, reply) => {
|
||||
const groupId = groupOf(request);
|
||||
const { fields, files } = await readMultipart(request);
|
||||
const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
|
||||
const card = await cards.create(groupId, parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
|
||||
return reply.code(201).send(card);
|
||||
});
|
||||
|
||||
app.put<{ Params: IdParams }>('/cards/:id', async (request) => {
|
||||
app.put(`${prefix}/cards/:id`, async (request) => {
|
||||
const groupId = groupOf(request);
|
||||
const { fields, files } = await readMultipart(request);
|
||||
const { removeLogo, ...cardFields } = fields;
|
||||
const patch = parseBody(cardFieldsSchema.partial(), cardFields);
|
||||
const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined);
|
||||
return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo);
|
||||
return cards.update(groupId, param(request, 'id'), patch, CLIENT_ACCESS, logo);
|
||||
});
|
||||
|
||||
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
|
||||
const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS);
|
||||
await cards.setDeleted(groupId(request), card.id, true);
|
||||
app.delete(`${prefix}/cards/:id`, async (request, reply) => {
|
||||
const groupId = groupOf(request);
|
||||
const card = await cards.get(groupId, param(request, 'id'), CLIENT_ACCESS);
|
||||
await cards.setDeleted(groupId, card.id, true);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) =>
|
||||
sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)),
|
||||
app.get(`${prefix}/cards/:id/logo`, async (request, reply) =>
|
||||
sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), 'logo', CLIENT_ACCESS)),
|
||||
);
|
||||
|
||||
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
|
||||
const { id, imageId } = request.params;
|
||||
return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS));
|
||||
});
|
||||
app.get(`${prefix}/cards/:id/images/:imageId`, async (request, reply) =>
|
||||
sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS)),
|
||||
);
|
||||
|
||||
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
|
||||
app.post(`${prefix}/cards/:id/images`, async (request) => {
|
||||
const groupId = groupOf(request);
|
||||
const { files } = await readMultipart(request);
|
||||
return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS);
|
||||
return cards.addImages(groupId, param(request, 'id'), files.get('file') ?? [], CLIENT_ACCESS);
|
||||
});
|
||||
|
||||
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) =>
|
||||
cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS),
|
||||
app.delete(`${prefix}/cards/:id/images/:imageId`, async (request) =>
|
||||
cards.removeImage(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+135
-72
@@ -1,7 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared';
|
||||
import {
|
||||
GROUP_ROLES,
|
||||
buildLoginDeepLink,
|
||||
type AccessKeyView,
|
||||
type Group,
|
||||
type GroupMembership,
|
||||
type GroupRole,
|
||||
type Role,
|
||||
type UserView,
|
||||
} from '@cardy/shared';
|
||||
import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from './errors.js';
|
||||
import { YamlFile } from './yamlFile.js';
|
||||
@@ -15,12 +24,29 @@ const storedKeySchema = z.object({
|
||||
encrypted: z.string(),
|
||||
});
|
||||
|
||||
const storedUserSchema = z.object({
|
||||
email: z.string().transform((s) => s.trim().toLowerCase()),
|
||||
groupId: idSchema,
|
||||
role: z.enum(ROLES),
|
||||
keys: z.array(storedKeySchema).default([]),
|
||||
});
|
||||
const membershipSchema = z.object({ groupId: idSchema, role: z.enum(GROUP_ROLES) });
|
||||
|
||||
/** Users from before multi-group ({ groupId, role }) are read as { superadmin, memberships }. */
|
||||
function upgradeLegacyUser(raw: unknown): unknown {
|
||||
const u = raw as { groupId?: string; role?: string; memberships?: unknown } | null;
|
||||
if (!u || typeof u !== 'object' || u.memberships !== undefined || typeof u.groupId !== 'string') return raw;
|
||||
const { groupId, role, ...rest } = u;
|
||||
return {
|
||||
...rest,
|
||||
superadmin: role === 'superadmin',
|
||||
memberships: [{ groupId, role: role === 'user' ? 'user' : 'admin' }],
|
||||
};
|
||||
}
|
||||
|
||||
const storedUserSchema = z.preprocess(
|
||||
upgradeLegacyUser,
|
||||
z.object({
|
||||
email: z.string().transform((s) => s.trim().toLowerCase()),
|
||||
superadmin: z.boolean().default(false),
|
||||
memberships: z.array(membershipSchema).min(1, 'A user must belong to at least one group'),
|
||||
keys: z.array(storedKeySchema).default([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const usersFileSchema = z.object({
|
||||
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
|
||||
@@ -33,7 +59,8 @@ type UsersFile = z.infer<typeof usersFileSchema>;
|
||||
|
||||
export interface KeyOwner {
|
||||
user: StoredUser;
|
||||
group: Group;
|
||||
/** The user's groups, in membership order (the first one serves the group-less client routes). */
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
export class UsersService {
|
||||
@@ -52,15 +79,14 @@ export class UsersService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */
|
||||
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin flag. */
|
||||
async ensureSuperadmin(email: string, groupName: string): Promise<void> {
|
||||
const data = await this.file.read();
|
||||
const existing = data.users.find((u) => u.email === email);
|
||||
if (existing?.role === 'superadmin') return;
|
||||
if (data.users.find((u) => u.email === email)?.superadmin) return;
|
||||
await this.file.update((d) => {
|
||||
const user = d.users.find((u) => u.email === email);
|
||||
if (user) {
|
||||
user.role = 'superadmin';
|
||||
user.superadmin = true;
|
||||
return;
|
||||
}
|
||||
let group = d.groups.find((g) => sameName(g.name, groupName));
|
||||
@@ -68,7 +94,7 @@ export class UsersService {
|
||||
group = { id: randomUUID(), name: groupName };
|
||||
d.groups.push(group);
|
||||
}
|
||||
d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] });
|
||||
d.users.push({ email, superadmin: true, memberships: [{ groupId: group.id, role: 'admin' }], keys: [] });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +113,15 @@ export class UsersService {
|
||||
return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** The user's groups with their role in each, in membership order. */
|
||||
async membershipsOf(user: StoredUser): Promise<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> {
|
||||
const data = await this.file.read();
|
||||
if (this.keyIndex?.source !== data) {
|
||||
@@ -98,8 +133,9 @@ export class UsersService {
|
||||
}
|
||||
const email = this.keyIndex.map.get(key);
|
||||
const user = email ? data.users.find((u) => u.email === email) : undefined;
|
||||
const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined;
|
||||
return user && group ? { user, group } : undefined;
|
||||
if (!user) return undefined;
|
||||
const groups = (await this.membershipsOf(user)).map(({ id, name }) => ({ id, name }));
|
||||
return groups.length ? { user, groups } : undefined;
|
||||
}
|
||||
|
||||
// ---- views ----
|
||||
@@ -112,8 +148,15 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
userView(user: StoredUser, apiUrl: string): UserView {
|
||||
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) };
|
||||
/** A member of `groupId`, as shown to the admins of that group. */
|
||||
async userView(user: StoredUser, groupId: string, apiUrl: string): Promise<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) ----
|
||||
@@ -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> {
|
||||
requireSuperadmin(actor);
|
||||
await this.file.update((d) => {
|
||||
if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found');
|
||||
if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) {
|
||||
throw conflict('A group that contains a superadmin cannot be deleted');
|
||||
const onlyGroupOf = d.users.filter((u) => u.memberships.length === 1 && u.memberships[0]!.groupId === id);
|
||||
if (onlyGroupOf.length) {
|
||||
const list = onlyGroupOf.map((u) => u.email).join(', ');
|
||||
throw conflict(`The group is the only group of ${list}. Add them to another group or remove them first.`);
|
||||
}
|
||||
d.groups = d.groups.filter((g) => g.id !== id);
|
||||
d.users = d.users.filter((u) => u.groupId !== id);
|
||||
for (const u of d.users) u.memberships = u.memberships.filter((m) => m.groupId !== id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- users (admin of the current group) ----
|
||||
// ---- members of the current group (its admins) ----
|
||||
|
||||
async listUsers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
|
||||
async listMembers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
|
||||
requireGroupAdmin(actor, groupId);
|
||||
const data = await this.file.read();
|
||||
return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email));
|
||||
return data.users.filter((u) => membership(u, groupId)).sort((a, b) => a.email.localeCompare(b.email));
|
||||
}
|
||||
|
||||
async createUser(
|
||||
actor: StoredUser,
|
||||
currentGroupId: string,
|
||||
input: { email: string; role: Role; groupId?: string },
|
||||
): Promise<StoredUser> {
|
||||
const groupId = input.groupId ?? currentGroupId;
|
||||
if (groupId !== currentGroupId) requireSuperadmin(actor);
|
||||
/** Adds a user to the group; creates the user when the email is new. */
|
||||
async addMember(actor: StoredUser, groupId: string, input: { email: string; role: GroupRole }): Promise<StoredUser> {
|
||||
requireGroupAdmin(actor, groupId);
|
||||
if (input.role === 'superadmin') requireSuperadmin(actor);
|
||||
return this.file.update((d) => {
|
||||
if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
|
||||
if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`);
|
||||
const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] };
|
||||
const existing = d.users.find((u) => u.email === input.email);
|
||||
if (existing) {
|
||||
if (membership(existing, groupId)) throw conflict(`${input.email} is already in this group`);
|
||||
existing.memberships.push({ groupId, role: input.role });
|
||||
return existing;
|
||||
}
|
||||
const user: StoredUser = { email: input.email, superadmin: false, memberships: [{ groupId, role: input.role }], keys: [] };
|
||||
d.users.push(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
/** Changes the role in the group and/or (superadmins only) the global superadmin flag. */
|
||||
async updateMember(
|
||||
actor: StoredUser,
|
||||
currentGroupId: string,
|
||||
groupId: string,
|
||||
email: string,
|
||||
patch: { role?: Role; groupId?: string },
|
||||
patch: { role?: GroupRole; superadmin?: boolean },
|
||||
): Promise<StoredUser> {
|
||||
if (patch.role === 'superadmin') requireSuperadmin(actor);
|
||||
if (patch.groupId !== undefined) requireSuperadmin(actor);
|
||||
if (patch.superadmin !== undefined) requireSuperadmin(actor);
|
||||
return this.file.update((d) => {
|
||||
const user = findManagedUser(d, actor, currentGroupId, email);
|
||||
if (patch.role !== undefined && patch.role !== user.role) {
|
||||
if (user.email === actor.email) throw badRequest('You cannot change your own role');
|
||||
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
|
||||
user.role = patch.role;
|
||||
const user = findManagedMember(d, actor, groupId, email);
|
||||
const self = user.email === actor.email;
|
||||
const m = membership(user, groupId)!;
|
||||
if (patch.role !== undefined && patch.role !== m.role) {
|
||||
if (self) throw badRequest('You cannot change your own role');
|
||||
m.role = patch.role;
|
||||
}
|
||||
if (patch.groupId !== undefined && patch.groupId !== user.groupId) {
|
||||
if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found');
|
||||
user.groupId = patch.groupId;
|
||||
if (patch.superadmin !== undefined && patch.superadmin !== user.superadmin) {
|
||||
if (self) throw badRequest('You cannot change your own superadmin status');
|
||||
if (!patch.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
|
||||
user.superadmin = patch.superadmin;
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise<void> {
|
||||
await this.file.update((d) => {
|
||||
const user = findManagedUser(d, actor, currentGroupId, email);
|
||||
if (user.email === actor.email) throw badRequest('You cannot delete yourself');
|
||||
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
|
||||
/**
|
||||
* Removes the user from the group. For a user whose only group it is, this deletes the user with
|
||||
* all keys (users always belong to at least one group). Returns whether the user was deleted.
|
||||
*/
|
||||
async removeMember(actor: StoredUser, groupId: string, email: string): Promise<{ deleted: boolean }> {
|
||||
return this.file.update((d) => {
|
||||
const user = findManagedMember(d, actor, groupId, email);
|
||||
if (user.email === actor.email) throw badRequest('You cannot remove yourself');
|
||||
if (user.memberships.length > 1) {
|
||||
user.memberships = user.memberships.filter((m) => m.groupId !== groupId);
|
||||
return { deleted: false };
|
||||
}
|
||||
if (user.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
|
||||
d.users = d.users.filter((u) => u !== user);
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
// ---- keys ----
|
||||
// ---- keys (a key signs the user in to all their groups) ----
|
||||
|
||||
async createKey(
|
||||
actor: StoredUser,
|
||||
currentGroupId: string,
|
||||
email: string,
|
||||
label: string | undefined,
|
||||
apiUrl: string,
|
||||
): Promise<AccessKeyView> {
|
||||
async createKey(actor: StoredUser, groupId: string, email: string, label: string | undefined, apiUrl: string): Promise<AccessKeyView> {
|
||||
const plain = generateAccessKey();
|
||||
const stored: StoredKey = {
|
||||
id: randomUUID(),
|
||||
@@ -227,7 +279,7 @@ export class UsersService {
|
||||
encrypted: encryptSecret(plain, this.secret),
|
||||
};
|
||||
await this.file.update((d) => {
|
||||
findManagedUser(d, actor, currentGroupId, email).keys.push(stored);
|
||||
findManagedMember(d, actor, groupId, email).keys.push(stored);
|
||||
});
|
||||
return {
|
||||
id: stored.id,
|
||||
@@ -238,9 +290,9 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> {
|
||||
async deleteKey(actor: StoredUser, groupId: string, email: string, keyId: string): Promise<void> {
|
||||
await this.file.update((d) => {
|
||||
const user = findManagedUser(d, actor, currentGroupId, email);
|
||||
const user = findManagedMember(d, actor, groupId, email);
|
||||
const before = user.keys.length;
|
||||
user.keys = user.keys.filter((k) => k.id !== keyId);
|
||||
if (user.keys.length === before) throw notFound('Key not found');
|
||||
@@ -253,11 +305,22 @@ function sameName(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
function countSuperadmins(d: UsersFile): number {
|
||||
return d.users.filter((u) => u.role === 'superadmin').length;
|
||||
return d.users.filter((u) => u.superadmin).length;
|
||||
}
|
||||
|
||||
export function membership(user: StoredUser, groupId: string) {
|
||||
return user.memberships.find((m) => m.groupId === groupId);
|
||||
}
|
||||
|
||||
/** Effective role in a group; undefined when the user is not a member (and not a superadmin). */
|
||||
export function roleIn(user: StoredUser, groupId: string): Role | undefined {
|
||||
if (user.superadmin) return 'superadmin';
|
||||
return membership(user, groupId)?.role;
|
||||
}
|
||||
|
||||
export function isGroupAdmin(actor: StoredUser, groupId: string): boolean {
|
||||
return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId);
|
||||
const role = roleIn(actor, groupId);
|
||||
return role === 'superadmin' || role === 'admin';
|
||||
}
|
||||
|
||||
export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
|
||||
@@ -265,15 +328,15 @@ export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
|
||||
}
|
||||
|
||||
export function requireSuperadmin(actor: StoredUser): void {
|
||||
if (actor.role !== 'superadmin') throw forbidden();
|
||||
if (!actor.superadmin) throw forbidden();
|
||||
}
|
||||
|
||||
/** A user of the current group that the actor may manage (only superadmins manage superadmins). */
|
||||
function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser {
|
||||
requireGroupAdmin(actor, currentGroupId);
|
||||
/** A member of the group that the actor may manage (only superadmins manage superadmins). */
|
||||
function findManagedMember(d: UsersFile, actor: StoredUser, groupId: string, email: string): StoredUser {
|
||||
requireGroupAdmin(actor, groupId);
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId);
|
||||
if (!user) throw notFound('User not found');
|
||||
if (user.role === 'superadmin') requireSuperadmin(actor);
|
||||
const user = d.users.find((u) => u.email === normalized && membership(u, groupId));
|
||||
if (!user) throw notFound('User not found in this group');
|
||||
if (user.superadmin) requireSuperadmin(actor);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ async function setup() {
|
||||
const { app, config } = await testApp();
|
||||
const root = admin(app, SUPERADMIN);
|
||||
const me = (await root('GET', '/me')).body as MeResponse;
|
||||
return { app, config, root, defaultGroup: me.group };
|
||||
return { app, config, root, defaultGroup: me.currentGroup };
|
||||
}
|
||||
|
||||
describe('admin API authentication', () => {
|
||||
@@ -32,7 +32,13 @@ describe('admin API authentication', () => {
|
||||
const { root } = await setup();
|
||||
const { status, body } = await root('GET', '/me');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } });
|
||||
expect(body).toMatchObject({
|
||||
email: SUPERADMIN,
|
||||
superadmin: true,
|
||||
role: 'superadmin',
|
||||
groups: [{ name: 'default', role: 'admin' }],
|
||||
currentGroup: { name: 'default' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 without header or for unknown users', async () => {
|
||||
@@ -77,18 +83,33 @@ describe('groups and current group', () => {
|
||||
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
|
||||
});
|
||||
|
||||
it('ignores the group cookie for non-superadmins', async () => {
|
||||
it('lets members switch only between their own groups', async () => {
|
||||
const { app, root, defaultGroup } = await setup();
|
||||
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
|
||||
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`);
|
||||
// Not a member of "Other" yet: the cookie is ignored and switching is refused.
|
||||
expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
|
||||
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
|
||||
expect((await adm('GET', '/groups')).status).toBe(403);
|
||||
|
||||
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||
expect((await inOther('POST', '/users', { email: 'adm@example.com' })).status).toBe(201);
|
||||
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(200);
|
||||
const me = (await adm('GET', '/me')).body as MeResponse;
|
||||
expect(me.currentGroup.id).toBe(other.id);
|
||||
expect(me.role).toBe('user');
|
||||
expect(me.groups.map((g) => [g.name, g.role])).toEqual([
|
||||
['default', 'admin'],
|
||||
['Other', 'user'],
|
||||
]);
|
||||
// Admin in "default" only: no admin rights in "Other".
|
||||
expect((await adm('GET', '/users')).status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
|
||||
it('refuses to delete a group that is the only group of some user', async () => {
|
||||
const { app, config, root, defaultGroup } = await setup();
|
||||
// "default" is the superadmin's only group.
|
||||
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
|
||||
|
||||
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||
@@ -97,9 +118,16 @@ describe('groups and current group', () => {
|
||||
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
|
||||
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
|
||||
|
||||
const refused = await root('DELETE', `/groups/${other.id}`);
|
||||
expect(refused.status).toBe(409);
|
||||
expect((refused.body as { error: string }).error).toContain('u@example.com');
|
||||
|
||||
// Once the user has another group, the group can go: its cards are removed, the user stays.
|
||||
await root('POST', '/users', { email: 'u@example.com' });
|
||||
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
|
||||
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
|
||||
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
|
||||
const me = (await admin(app, 'u@example.com')('GET', '/me')).body as MeResponse;
|
||||
expect(me.groups.map((g) => g.id)).toEqual([defaultGroup.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +141,8 @@ describe('users and keys', () => {
|
||||
|
||||
expect((await user('GET', '/users')).status).toBe(403);
|
||||
expect((await user('GET', '/cards')).status).toBe(403);
|
||||
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403);
|
||||
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(400);
|
||||
expect((await adm('PATCH', '/users/user@example.com', { superadmin: true })).status).toBe(403);
|
||||
expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
|
||||
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403);
|
||||
expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400);
|
||||
@@ -121,7 +150,59 @@ describe('users and keys', () => {
|
||||
expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200);
|
||||
expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409);
|
||||
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400);
|
||||
expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400);
|
||||
expect((await root('PATCH', `/users/${SUPERADMIN}`, { superadmin: false })).status).toBe(400);
|
||||
|
||||
const promoted = await root('PATCH', '/users/user@example.com', { superadmin: true });
|
||||
expect((promoted.body as UserView).superadmin).toBe(true);
|
||||
// Now a superadmin: plain admins can no longer manage them.
|
||||
expect((await adm('PATCH', '/users/user@example.com', { role: 'user' })).status).toBe(403);
|
||||
});
|
||||
|
||||
it('keeps roles per group and removes users from one group or entirely', async () => {
|
||||
const { app, root } = await setup();
|
||||
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||
await root('POST', '/users', { email: 'multi@example.com', role: 'admin' });
|
||||
const added = await inOther('POST', '/users', { email: 'multi@example.com', role: 'user' });
|
||||
expect(added.status).toBe(201);
|
||||
expect((added.body as UserView).role).toBe('user');
|
||||
expect((added.body as UserView).groups.map((g) => [g.name, g.role])).toEqual([
|
||||
['default', 'admin'],
|
||||
['Other', 'user'],
|
||||
]);
|
||||
|
||||
// Leaving one group keeps the user.
|
||||
expect((await inOther('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: false });
|
||||
const me = (await admin(app, 'multi@example.com')('GET', '/me')).body as MeResponse;
|
||||
expect(me.groups.map((g) => g.name)).toEqual(['default']);
|
||||
|
||||
// Removing the user from their only group deletes the user.
|
||||
expect((await root('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: true });
|
||||
expect((await admin(app, 'multi@example.com')('GET', '/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('reads users.yaml from before multi-group', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises');
|
||||
const { testConfig } = await import('./helpers.js');
|
||||
const { buildApp } = await import('../src/app.js');
|
||||
const config = await testConfig();
|
||||
await mkdir(config.dataDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(config.dataDir, 'users.yaml'),
|
||||
[
|
||||
'groups:',
|
||||
' - { id: g1, name: Home }',
|
||||
'users:',
|
||||
` - { email: ${SUPERADMIN}, groupId: g1, role: superadmin, keys: [] }`,
|
||||
' - { email: adm@example.com, groupId: g1, role: admin, keys: [] }',
|
||||
' - { email: kid@example.com, groupId: g1, role: user, keys: [] }',
|
||||
].join('\n'),
|
||||
);
|
||||
const app = await buildApp(config);
|
||||
const view = (email: string) => admin(app, email)('GET', '/me').then((r) => r.body as MeResponse);
|
||||
expect(await view(SUPERADMIN)).toMatchObject({ superadmin: true, role: 'superadmin', groups: [{ id: 'g1', role: 'admin' }] });
|
||||
expect(await view('adm@example.com')).toMatchObject({ superadmin: false, role: 'admin', groups: [{ id: 'g1', role: 'admin' }] });
|
||||
expect(await view('kid@example.com')).toMatchObject({ superadmin: false, role: 'user', groups: [{ id: 'g1', role: 'user' }] });
|
||||
});
|
||||
|
||||
it('admin generates keys; users see their own keys with deep links', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AccessKeyView, Card, Group } from '@cardy/shared';
|
||||
import type { AccessKeyView, ApiAuthResponse, Card, Group } from '@cardy/shared';
|
||||
import { SUPERADMIN, multipart, testApp } from './helpers.js';
|
||||
|
||||
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
|
||||
@@ -179,6 +179,42 @@ describe('client API', () => {
|
||||
expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('serves each of the owner\'s groups and keeps /cards on the first one', async () => {
|
||||
const { app, key } = await setup();
|
||||
const api = client(app, key);
|
||||
const other = (
|
||||
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
|
||||
).json() as Group;
|
||||
const stranger = (
|
||||
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Stranger' } })
|
||||
).json() as Group;
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/users',
|
||||
headers: { ...adminHeaders, cookie: `cardy_group=${other.id}` },
|
||||
payload: { email: SUPERADMIN },
|
||||
});
|
||||
|
||||
const auth = (await api('GET', '/auth')).json() as ApiAuthResponse;
|
||||
expect(auth.groups.map((g) => g.name)).toEqual(['default', 'Other']);
|
||||
expect(auth.group.name).toBe('default');
|
||||
|
||||
const inOther = (await api('POST', `/groups/${other.id}/cards`, cardParts({ name: 'In other' }))).json() as Card;
|
||||
const inDefault = (await api('POST', `/groups/${auth.groups[0]!.id}/cards`, cardParts({ name: 'In default' }))).json() as Card;
|
||||
expect(((await api('GET', `/groups/${other.id}/cards`)).json() as Card[]).map((c) => c.name)).toEqual(['In other']);
|
||||
// The group-less routes (apps from before multi-group) use the first group.
|
||||
expect(((await api('GET', '/cards')).json() as Card[]).map((c) => c.name)).toEqual(['In default']);
|
||||
|
||||
// Cards are addressed within their group only.
|
||||
expect((await api('DELETE', `/groups/${other.id}/cards/${inDefault.id}`)).statusCode).toBe(404);
|
||||
expect((await api('PUT', `/groups/${other.id}/cards/${inOther.id}`, [{ name: 'name', value: 'Renamed' }])).json()).toMatchObject({
|
||||
name: 'Renamed',
|
||||
});
|
||||
// Groups the owner is not a member of do not exist for the key.
|
||||
expect((await api('GET', `/groups/${stranger.id}/cards`)).statusCode).toBe(404);
|
||||
expect((await api('POST', `/groups/${stranger.id}/cards`, cardParts())).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns JSON 404 for unknown API routes', async () => {
|
||||
const { app, key } = await setup();
|
||||
const res = await client(app, key)('GET', '/nope');
|
||||
|
||||
@@ -28,8 +28,10 @@ export function App() {
|
||||
|
||||
function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise<void> }) {
|
||||
const path = usePath();
|
||||
// Rights in the current group (admin role is per group; superadmin is global).
|
||||
const isAdmin = me.role !== 'user';
|
||||
const isSuper = me.role === 'superadmin';
|
||||
const isSuper = me.superadmin;
|
||||
const canSwitch = isSuper || me.groups.length > 1;
|
||||
|
||||
const tabs = [
|
||||
...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []),
|
||||
@@ -61,7 +63,7 @@ function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch:
|
||||
))}
|
||||
</nav>
|
||||
<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}>
|
||||
{me.email} · {me.role}
|
||||
</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 }) {
|
||||
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>();
|
||||
return (
|
||||
<label className="group-switcher">
|
||||
|
||||
@@ -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.
|
||||
const BASE = '/api/admin';
|
||||
@@ -75,10 +75,12 @@ export const api = {
|
||||
setCurrentGroup: (groupId: string) => request<Group>('PUT', '/current-group', { groupId }),
|
||||
|
||||
users: () => request<UserView[]>('GET', '/users'),
|
||||
createUser: (email: string, role: Role) => request<UserView>('POST', '/users', { email, role }),
|
||||
updateUser: (email: string, patch: { role?: Role; groupId?: string }) =>
|
||||
/** Adds a user to the current group (creates the user when the email is new). */
|
||||
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),
|
||||
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 }),
|
||||
deleteKey: (email: string, keyId: string) => request<void>('DELETE', `/users/${enc(email)}/keys/${enc(keyId)}`),
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export function GroupsPage({ me, onGroupsChanged }: { me: MeResponse; onGroupsCh
|
||||
<ErrorText error={action.error ?? groups.error} />
|
||||
<ul className="list">
|
||||
{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>
|
||||
</section>
|
||||
@@ -72,7 +72,7 @@ function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurr
|
||||
<div className="grow">
|
||||
{group.name}
|
||||
{isCurrent && <span className="pill accent">current</span>}
|
||||
{isOwn && <span className="pill">yours</span>}
|
||||
{isOwn && <span className="pill">member</span>}
|
||||
</div>
|
||||
<button className="secondary" onClick={() => api.setCurrentGroup(group.id).then(onChanged)} disabled={isCurrent}>
|
||||
Select
|
||||
@@ -84,7 +84,10 @@ function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurr
|
||||
className="danger"
|
||||
disabled={action.busy}
|
||||
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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
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 { ErrorText, KeyQrModal, formatDate, useAction, useLoad } from '../components.js';
|
||||
|
||||
export function UsersPage({ me }: { me: MeResponse }) {
|
||||
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 (
|
||||
<section>
|
||||
<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>
|
||||
<AddUser roles={assignableRoles} onAdded={users.reload} />
|
||||
<p className="muted">
|
||||
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} />
|
||||
{users.data?.length === 0 && <p className="empty">No users in this group.</p>}
|
||||
<ul className="list">
|
||||
{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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<void> }) {
|
||||
function AddUser({ onAdded }: { onAdded: () => Promise<void> }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<Role>('user');
|
||||
const [role, setRole] = useState<GroupRole>('user');
|
||||
const action = useAction();
|
||||
return (
|
||||
<>
|
||||
@@ -35,7 +35,7 @@ function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<voi
|
||||
className="row"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await action.run(() => api.createUser(email.trim(), role))) {
|
||||
if (await action.run(() => api.addUser(email.trim(), role))) {
|
||||
setEmail('');
|
||||
setRole('user');
|
||||
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 />
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||
{roles.map((r) => (
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as GroupRole)} aria-label="Role in this group">
|
||||
{GROUP_ROLES.map((r) => (
|
||||
<option key={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={action.busy}>
|
||||
Add user
|
||||
Add to group
|
||||
</button>
|
||||
</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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
me,
|
||||
roles,
|
||||
groups,
|
||||
onChanged,
|
||||
}: {
|
||||
user: UserView;
|
||||
me: MeResponse;
|
||||
roles: Role[];
|
||||
groups: Group[];
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
function UserRow({ user, me, onChanged }: { user: UserView; me: MeResponse; onChanged: () => Promise<void> }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const action = useAction();
|
||||
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();
|
||||
};
|
||||
|
||||
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 (
|
||||
<li className="list-item column">
|
||||
<div className="row">
|
||||
@@ -86,50 +84,45 @@ function UserRow({
|
||||
<div>
|
||||
{user.email}
|
||||
{isSelf && <span className="pill">you</span>}
|
||||
{user.superadmin && <span className="pill accent">superadmin</span>}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{user.keys.length} key{user.keys.length === 1 ? '' : 's'}
|
||||
{otherGroups.length > 0 && ` · also in ${otherGroups.map((g) => `${g.name} (${g.role})`).join(', ')}`}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={user.role}
|
||||
disabled={!canManage || isSelf || action.busy}
|
||||
onChange={(e) => update({ role: e.target.value as Role })}
|
||||
aria-label="Role"
|
||||
onChange={(e) => update({ role: e.target.value as GroupRole })}
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
{me.role === 'superadmin' && groups.length > 0 && (
|
||||
<select
|
||||
value={user.groupId}
|
||||
disabled={action.busy}
|
||||
onChange={(e) => {
|
||||
const target = groups.find((g) => g.id === e.target.value);
|
||||
if (confirm(`Move ${user.email} to group "${target?.name}"?`)) void update({ groupId: e.target.value });
|
||||
}}
|
||||
aria-label="Group"
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{me.superadmin && (
|
||||
<label className="checkbox" title="Superadmins manage all groups">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={user.superadmin}
|
||||
disabled={isSelf || action.busy}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
if (confirm(on ? `Make ${user.email} a superadmin of all groups?` : `Remove superadmin rights from ${user.email}?`)) {
|
||||
void update({ superadmin: on });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
superadmin
|
||||
</label>
|
||||
)}
|
||||
<button className="secondary" onClick={() => setExpanded((x) => !x)} disabled={!canManage}>
|
||||
{expanded ? 'Hide keys' : 'Keys'}
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
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 className="danger" disabled={!canManage || isSelf || action.busy} onClick={remove}>
|
||||
{otherGroups.length ? 'Remove from group' : 'Delete user'}
|
||||
</button>
|
||||
</div>
|
||||
<ErrorText error={action.error} />
|
||||
@@ -145,6 +138,7 @@ function UserKeys({ user, onChanged }: { user: UserView; onChanged: () => Promis
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<ul className="list compact">
|
||||
{user.keys.map((k) => (
|
||||
|
||||
+21
-3
@@ -1,13 +1,22 @@
|
||||
import type { CodeType } from './codeTypes.js';
|
||||
|
||||
/** Effective role of a user in a group: superadmin (global flag) > group admin > member. */
|
||||
export const ROLES = ['user', 'admin', 'superadmin'] as const;
|
||||
export type Role = (typeof ROLES)[number];
|
||||
|
||||
/** Role of a membership in one group. A user belongs to one or more groups. */
|
||||
export const GROUP_ROLES = ['user', 'admin'] as const;
|
||||
export type GroupRole = (typeof GROUP_ROLES)[number];
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GroupMembership extends Group {
|
||||
role: GroupRole;
|
||||
}
|
||||
|
||||
export interface ImageRef {
|
||||
id: string;
|
||||
file: string;
|
||||
@@ -41,22 +50,31 @@ export interface AccessKeyView {
|
||||
deepLink: string;
|
||||
}
|
||||
|
||||
/** A member of the current group, as shown to its admins. */
|
||||
export interface UserView {
|
||||
email: string;
|
||||
groupId: string;
|
||||
role: Role;
|
||||
superadmin: boolean;
|
||||
/** Role in the current group. */
|
||||
role: GroupRole;
|
||||
/** All groups of the user. */
|
||||
groups: GroupMembership[];
|
||||
keys: AccessKeyView[];
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
email: string;
|
||||
superadmin: boolean;
|
||||
/** Effective role in the current group. */
|
||||
role: Role;
|
||||
group: Group;
|
||||
groups: GroupMembership[];
|
||||
currentGroup: Group;
|
||||
}
|
||||
|
||||
export interface ApiAuthResponse {
|
||||
email: string;
|
||||
/** All groups of the key's owner (one or more). */
|
||||
groups: Group[];
|
||||
/** First group: the one served by the group-less /cards routes (apps from before multi-group). */
|
||||
group: Group;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { CODE_TYPES } from './codeTypes.js';
|
||||
import { ROLES } from './models.js';
|
||||
import { GROUP_ROLES } from './models.js';
|
||||
|
||||
export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB');
|
||||
|
||||
@@ -63,15 +63,16 @@ export const groupInputSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(100),
|
||||
});
|
||||
|
||||
/** Adds a user to the current group (creates the user when the email is new). */
|
||||
export const userCreateSchema = z.object({
|
||||
email: emailSchema,
|
||||
role: z.enum(ROLES).default('user'),
|
||||
groupId: z.string().optional(),
|
||||
role: z.enum(GROUP_ROLES).default('user'),
|
||||
});
|
||||
|
||||
/** Role in the current group; `superadmin` is the global flag (superadmins only). */
|
||||
export const userPatchSchema = z.object({
|
||||
role: z.enum(ROLES).optional(),
|
||||
groupId: z.string().optional(),
|
||||
role: z.enum(GROUP_ROLES).optional(),
|
||||
superadmin: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const keyCreateSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user