server be & fe
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "server-be (local test)",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["--filter", "@cardy/server-be", "exec", "tsx", "src/index.ts"],
|
||||||
|
"port": 10002,
|
||||||
|
"env": {
|
||||||
|
"KEY_ENCRYPTION_SECRET": "cHrUrVMk3KgaAw066+CPj7iYXuBfBXbH5lPLFwoBSLA=",
|
||||||
|
"SUPERADMIN_EMAIL": "admin@local.test",
|
||||||
|
"ADMIN_PROXY_SECRET": "local-proxy-secret",
|
||||||
|
"DATA_DIR": "C:/Users/User/AppData/Local/Temp/claude/D--Documents-ProjectsGit-Cardy/bf03f49e-0768-4ffa-b6d2-c9a06a1562aa/scratchpad/data"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "server-fe (local test)",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["--filter", "@cardy/server-fe", "exec", "vite"],
|
||||||
|
"port": 10001,
|
||||||
|
"env": {
|
||||||
|
"ADMIN_PROXY_SECRET": "local-proxy-secret",
|
||||||
|
"DEV_REMOTE_EMAIL": "admin@local.test",
|
||||||
|
"ALLOWED_HOSTS": "localhost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Cardy
|
||||||
|
|
||||||
|
Discount card storage. Specification: [development specification-v2.md](development%20specification-v2.md).
|
||||||
|
|
||||||
|
| Package | Port | Public URL | Proxy auth |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `server-fe` — admin UI (Vite) | 10001 | `https://dev.aggtaa.com/` | Authelia |
|
||||||
|
| `server-be` — admin API | 10002 | `https://dev.aggtaa.com/api/admin/*` → `/admin/*` | Authelia, passes `Remote-Email` |
|
||||||
|
| `server-be` — client API | 10002 | `https://dev.aggtaa.com/api/*` → `/*` | none |
|
||||||
|
| `mobile-client` — PWA | 10003 | `https://dev.aggtaa.com/m/*` → `/*` | none |
|
||||||
|
|
||||||
|
The proxy strips `/api` and `/m` and passes them in `X-Forwarded-Prefix`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp server-be/.env.example server-be/.env # fill in KEY_ENCRYPTION_SECRET, SUPERADMIN_EMAIL
|
||||||
|
cp server-fe/.env.example server-fe/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @cardy/server-be dev # API on 127.0.0.1:10002 (tsx watch)
|
||||||
|
pnpm --filter @cardy/server-fe dev # admin UI on :10001
|
||||||
|
```
|
||||||
|
|
||||||
|
Production-like: `pnpm --filter @cardy/server-be build && pnpm --filter @cardy/server-be start`,
|
||||||
|
`pnpm --filter @cardy/server-fe start` (build + `vite preview` on :10001).
|
||||||
|
|
||||||
|
`server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`.
|
||||||
|
If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add
|
||||||
|
`X-Cardy-Proxy-Secret` on the `/api/admin` route.
|
||||||
|
|
||||||
|
Without the proxy (local development), `server-fe` emulates it: forwards `/api/*` to `server-be`
|
||||||
|
with `/api` stripped; `DEV_REMOTE_EMAIL` in `server-fe/.env` fakes the Authelia user.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
pnpm typecheck
|
||||||
|
```
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Cardy — техническое задание, версия 2
|
||||||
|
|
||||||
|
Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональное лого + опциональные картинки (фото/скриншоты).
|
||||||
|
|
||||||
|
Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались).
|
||||||
|
Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят.
|
||||||
|
|
||||||
|
## 1. Структура репозитория
|
||||||
|
|
||||||
|
pnpm-монорепо, TypeScript, ESM.
|
||||||
|
|
||||||
|
| Каталог | Пакет | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `shared/` | `@cardy/shared` | Общие типы, схемы валидации (zod), список типов кодов, соответствие типов кодов `bwip-js`, формат deep link |
|
||||||
|
| `server-be/` | `@cardy/server-be` | Бэкенд: Fastify + TS |
|
||||||
|
| `server-fe/` | `@cardy/server-fe` | Административный веб-UI: React + Vite + TS |
|
||||||
|
| `mobile-client/` | `@cardy/mobile-client` | Клиент: React + Vite + TS, PWA + Capacitor (Android) |
|
||||||
|
|
||||||
|
## 2. Процессы, порты и прокси
|
||||||
|
|
||||||
|
Три отдельных процесса за одним реверс-прокси (домен для разработки — `https://dev.aggtaa.com`):
|
||||||
|
|
||||||
|
| Приложение | Порт | Публичный адрес | Аутентификация на прокси |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `server-fe` (Vite dev/preview-сервер) | 10001 | `https://dev.aggtaa.com/` | forward-auth (Authelia) |
|
||||||
|
| `server-be` (Fastify), админский API | 10002 | `https://dev.aggtaa.com/api/admin/*` | forward-auth (Authelia), прокси передаёт `Remote-Email` |
|
||||||
|
| `server-be` (Fastify), клиентский API | 10002 | `https://dev.aggtaa.com/api/*` | нет |
|
||||||
|
| `mobile-client` (PWA) | 10003 | `https://dev.aggtaa.com/m` | нет |
|
||||||
|
|
||||||
|
- Прокси срезает префиксы `/api` и `/m` и передаёт их в заголовке `X-Forwarded-Prefix`. На `server-be` админский API обслуживается по `/admin/*`, клиентский — от корня (`/auth`, `/cards`, …); на `mobile-client` запросы приходят от корня.
|
||||||
|
- Админский UI обращается к `/api/admin/*` напрямую (тот же origin, та же cookie сессии Authelia). Запросы отправляются с `Accept: application/json` и `X-Requested-With: XMLHttpRequest`, чтобы при истёкшей сессии Authelia отвечала `401`, а не редиректом; на такой `401` (ответ не от Cardy) UI перезагружает страницу, и Authelia показывает форму входа.
|
||||||
|
- `server-be` по умолчанию слушает только `127.0.0.1`: админский API доверяет заголовку `Remote-Email`. Дополнительная защита — `ADMIN_PROXY_SECRET`: прокси добавляет заголовок `X-Cardy-Proxy-Secret` на маршруте `/api/admin`, без него `/admin/*` отвечает `401`.
|
||||||
|
- PWA на `/m` и API на `/api` — один origin, CORS для PWA не нужен; для Capacitor-приложения CORS включён (кроме `/admin/*`).
|
||||||
|
- Для локальной разработки без прокси Vite (`server-fe`) эмулирует прокси: пересылает `/api/*` на `server-be` со срезанным `/api` и `X-Forwarded-Prefix: /api`.
|
||||||
|
|
||||||
|
## 3. Сервер
|
||||||
|
|
||||||
|
### 3.1. Конфигурация (переменные окружения)
|
||||||
|
|
||||||
|
Задаются в окружении или в файле `.env` в каталоге приложения (образцы — `.env.example`).
|
||||||
|
|
||||||
|
`server-be`:
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `KEY_ENCRYPTION_SECRET` | да | 32 байта в base64 — ключ AES-256-GCM для шифрования ключей доступа |
|
||||||
|
| `SUPERADMIN_EMAIL` | да | email первого суперадмина |
|
||||||
|
| `PUBLIC_API_URL` | нет | публичный адрес API (`https://dev.aggtaa.com/api`) для QR-кода ключа; если не задан — вычисляется из `X-Forwarded-Proto`/`X-Forwarded-Host`/`X-Forwarded-Prefix` |
|
||||||
|
| `ADMIN_PROXY_SECRET` | нет | если задан, `/admin/*` требует заголовок `X-Cardy-Proxy-Secret` с этим значением |
|
||||||
|
| `SUPERADMIN_GROUP` | нет, `default` | имя группы, в которую создаётся суперадмин из `SUPERADMIN_EMAIL` |
|
||||||
|
| `DATA_DIR` | нет, `./data` | каталог данных |
|
||||||
|
| `HOST` / `PORT` | нет, `127.0.0.1` / `10002` | адрес прослушивания |
|
||||||
|
|
||||||
|
`server-fe`:
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `ALLOWED_HOSTS` | публичные имена хоста для Vite (по умолчанию `dev.aggtaa.com`) |
|
||||||
|
| `BACKEND_URL` | только локально: адрес `server-be` (по умолчанию `http://127.0.0.1:10002`) |
|
||||||
|
| `ADMIN_PROXY_SECRET` | только локально: тот же секрет, что у `server-be` |
|
||||||
|
| `DEV_REMOTE_EMAIL` | только локально, без Authelia: подставляет `Remote-Email` |
|
||||||
|
|
||||||
|
При старте: если пользователя `SUPERADMIN_EMAIL` нет — он создаётся в группе `SUPERADMIN_GROUP` (группа создаётся, если её нет); если есть — ему гарантированно выставляется роль суперадмина.
|
||||||
|
|
||||||
|
### 3.2. Хранилище
|
||||||
|
|
||||||
|
```
|
||||||
|
DATA_DIR/
|
||||||
|
users.yaml # группы, пользователи, зашифрованные ключи
|
||||||
|
groups/<groupId>/cards.yaml # карты группы
|
||||||
|
groups/<groupId>/images/<cardId>/<imageId>.<ext>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Папки групп называются по неизменяемому ID группы, а не по имени — переименование группы не трогает файлы.
|
||||||
|
- Запись файлов атомарная (временный файл + rename), записи в один файл сериализуются.
|
||||||
|
- Если yaml-файл изменён вручную, сервер перечитывает его при следующем обращении (по времени изменения).
|
||||||
|
|
||||||
|
`users.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
groups:
|
||||||
|
- id: 3f1c… # UUID
|
||||||
|
name: Family
|
||||||
|
users:
|
||||||
|
- email: alice@example.com # уникален глобально, хранится в нижнем регистре
|
||||||
|
groupId: 3f1c…
|
||||||
|
role: superadmin # user | admin | superadmin
|
||||||
|
keys:
|
||||||
|
- id: 9a0b… # UUID
|
||||||
|
label: Alice's phone # опционально
|
||||||
|
createdAt: 2026-09-29T10:00:00.000Z
|
||||||
|
encrypted: <base64(iv | authTag | ciphertext)>
|
||||||
|
```
|
||||||
|
|
||||||
|
`cards.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cards:
|
||||||
|
- id: 7c9e… # UUID, генерируется сервером
|
||||||
|
name: Magnit
|
||||||
|
color: "#E53935" # #RRGGBB
|
||||||
|
code: "4600000000000"
|
||||||
|
codeType: EAN13
|
||||||
|
logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null
|
||||||
|
images: # порядок = порядок добавления
|
||||||
|
- { id: 5f6a…, file: 5f6a….webp, mime: image/webp }
|
||||||
|
imagesVersion: 3
|
||||||
|
deleted: false
|
||||||
|
createdAt: …
|
||||||
|
updatedAt: …
|
||||||
|
```
|
||||||
|
|
||||||
|
Типы кодов: `EAN13`, `EAN8`, `UPCA`, `UPCE`, `CODE128`, `CODE39`, `ITF`, `CODABAR`, `QR`, `DATAMATRIX`, `PDF417`, `AZTEC`.
|
||||||
|
|
||||||
|
`imagesVersion` начинается с 0 и увеличивается на 1 при любом добавлении, замене или удалении лого или картинки — и из админского UI, и через клиентский API.
|
||||||
|
|
||||||
|
Картинки сохраняются как есть, без валидации типа и размера. Лимит размера запроса задаёт реверс-прокси; собственный лимит сервера на размер тела запроса отключён.
|
||||||
|
При отдаче картинок сервер выставляет `X-Content-Type-Options: nosniff` и `Content-Security-Policy: sandbox`, а для типов не `image/*` — `Content-Type: application/octet-stream`.
|
||||||
|
|
||||||
|
### 3.3. Пользователи, группы, роли
|
||||||
|
|
||||||
|
- Пользователь состоит ровно в одной группе.
|
||||||
|
- `admin` — администратор только своей группы. `superadmin` — администратор всех групп.
|
||||||
|
- Карты общие для всех пользователей группы.
|
||||||
|
- Группу, в которой есть хотя бы один суперадмин, удалить нельзя. При удалении группы удаляются все её пользователи, их ключи и все карты с картинками.
|
||||||
|
- Нельзя удалить самого себя и нельзя понизить свою роль. Нельзя удалить или понизить последнего суперадмина.
|
||||||
|
- `admin` может создавать, удалять и менять роль (`user`/`admin`) пользователей своей группы. Назначать `superadmin`, изменять суперадминов и переносить пользователей между группами может только `superadmin`.
|
||||||
|
- Новые ключи генерирует только админ (для пользователей своей группы) или суперадмин.
|
||||||
|
|
||||||
|
### 3.4. Ключи доступа
|
||||||
|
|
||||||
|
- 32 случайных символа из `[A-Za-z0-9]` (криптостойкий генератор).
|
||||||
|
- Хранятся зашифрованными AES-256-GCM (случайный 12-байтный IV на каждый ключ).
|
||||||
|
- Сессий нет: клиент передаёт ключ в каждом запросе (`Authorization: Bearer <ключ>`). Удалённый ключ перестаёт работать сразу.
|
||||||
|
- QR-код ключа содержит deep link: `cardy://login?server=<публичный адрес API, URL-encoded>&key=<ключ>`. Клиент обращается к `<server>/auth`, `<server>/cards` и т.д.
|
||||||
|
|
||||||
|
### 3.5. Аутентификация административного UI
|
||||||
|
|
||||||
|
- Пользователь определяется по заголовку `Remote-Email` (без учёта регистра). Нет заголовка или пользователь не найден в `users.yaml` — `401`. Недостаточно прав — `403`.
|
||||||
|
- Текущая группа: для `user` и `admin` всегда своя группа. Для `superadmin` — значение cookie `cardy_group` (если его нет или группа не существует — своя группа); UI суперадмина позволяет выбрать другую группу, что записывает cookie.
|
||||||
|
- Если задан `ADMIN_PROXY_SECRET`, без правильного заголовка `X-Cardy-Proxy-Secret` — `401`.
|
||||||
|
- Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF).
|
||||||
|
|
||||||
|
### 3.6. Административный UI (`server-fe`, `https://dev.aggtaa.com/`)
|
||||||
|
|
||||||
|
Страницы: `/cards`, `/cards/:id`, `/cards/new`, `/users`, `/groups`, `/keys`.
|
||||||
|
|
||||||
|
- Шапка: email, роль, текущая группа; у суперадмина — выбор текущей группы.
|
||||||
|
- **My keys** (все пользователи): список своих ключей; для выбранного ключа — QR-код с deep link, сам ключ текстом, кнопка копирования.
|
||||||
|
- **Users** (admin, superadmin): CRUD пользователей текущей группы (email, роль; у суперадмина — ещё группа), по каждому пользователю — список ключей, генерация ключа (с опциональной меткой), удаление ключа, показ QR.
|
||||||
|
- **Groups** (superadmin): CRUD групп (имя, уникально).
|
||||||
|
- **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками).
|
||||||
|
|
||||||
|
### 3.7. API административного UI (публично `/api/admin`, на сервере `/admin`)
|
||||||
|
|
||||||
|
Пути ниже — относительно этого префикса.
|
||||||
|
|
||||||
|
| Метод и путь | Доступ | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /me` | все | текущий пользователь, текущая группа |
|
||||||
|
| `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 пользователей |
|
||||||
|
| `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей |
|
||||||
|
| `GET /cards` | admin | все карты текущей группы, включая удалённые |
|
||||||
|
| `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) |
|
||||||
|
| `DELETE /cards/:id` | admin | пометить удалённой; для уже удалённой — удалить окончательно |
|
||||||
|
| `PUT /cards/:id/logo`, `DELETE /cards/:id/logo` | admin | лого (multipart, поле `file`) |
|
||||||
|
| `POST /cards/:id/images`, `DELETE /cards/:id/images/:imageId` | admin | картинки (multipart, одно или несколько полей `file`) |
|
||||||
|
| `GET /cards/:id/logo`, `GET /cards/:id/images/:imageId` | admin | получить файл |
|
||||||
|
|
||||||
|
### 3.8. Клиентский API (публично `/api`, на сервере — от корня)
|
||||||
|
|
||||||
|
Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Группа определяется по владельцу ключа. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются.
|
||||||
|
|
||||||
|
| Метод и путь | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/auth` | проверка ключа; возвращает email и группу |
|
||||||
|
| `GET /api/cards` | все неудалённые карты группы, без пагинации |
|
||||||
|
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально файл `logo`); возвращает карту с ID |
|
||||||
|
| `PUT /api/cards/:id` | изменить карту (multipart: те же поля, опционально файл `logo`, опционально `removeLogo=true`) |
|
||||||
|
| `DELETE /api/cards/:id` | пометить карту удалённой |
|
||||||
|
| `GET /api/cards/:id/logo` | лого |
|
||||||
|
| `GET /api/cards/:id/images/:imageId` | картинка |
|
||||||
|
| `POST /api/cards/:id/images` | добавить картинки (multipart, одно или несколько полей `file`) |
|
||||||
|
| `DELETE /api/cards/:id/images/:imageId` | удалить картинку |
|
||||||
|
|
||||||
|
Все изменяющие запросы возвращают карту целиком (включая новый `imagesVersion`). При конкурентных правках побеждает последняя запись.
|
||||||
|
|
||||||
|
## 4. Клиентское мобильное приложение
|
||||||
|
|
||||||
|
React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 с базовым путём `/m` (`https://dev.aggtaa.com/m`).
|
||||||
|
|
||||||
|
- Хранит один ключ, адрес сервера и хэш PIN-кода (Capacitor Preferences / localStorage). Картинки — в Filesystem (Capacitor) или IndexedDB (PWA).
|
||||||
|
- **Deep link `cardy://login?server=…&key=…`** зарегистрирован в Android-приложении (intent filter для схемы `cardy`, хост `login`). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями.
|
||||||
|
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний.
|
||||||
|
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход.
|
||||||
|
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить».
|
||||||
|
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно.
|
||||||
|
- **Экран карты**: название, код (отрисовка `bwip-js`), локальные картинки. На Android яркость поднимается до максимума. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
|
||||||
|
- **Экран добавления и редактирования**: название, цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (Android — ML Kit, PWA — `zxing-wasm`). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
|
||||||
|
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера.
|
||||||
|
- Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).
|
||||||
|
|
||||||
|
## 5. Тесты
|
||||||
|
|
||||||
|
Unit- и интеграционные тесты сервера на Vitest: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`). E2E-тестов нет.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# pnpm монорепо приложение для хранения скидочных карт (штрих/qr-код + тип кода, EAN-13, EAN-8, Code128, QR, PDF417, Aztec и т.д. + название + цвет + опционально лого + опционально скриншоты).
|
||||||
|
|
||||||
|
# серверное приложение (fastify+react+vite+ts):
|
||||||
|
* хранение пользователей в файле users.yaml (по email) и их групп (по названиям). для пользователя может быть храниться признак того, что это - администратор или суперадминистратор.
|
||||||
|
* пользователь является администратором только для своей группы. суперадминистратор - администратор всех групп.
|
||||||
|
* аутентификация пользователя для доступа к UI (не API) через заголовок Remote-Email
|
||||||
|
* для каждого пользователя может храниться несколько строковых ключей доступа. ключи хранятся в зашифрованном обратимым шифрованием AES-256-GCM виде, ключ расшифровки (32-байта в base64) в переменной окружении.
|
||||||
|
* административный (для суперадмина) CRUD UI для управления группами пользователей и выбора одной группы, как текущей. группу, к которой принадлежит хотя бы один суперадмин, невозможно удалить.
|
||||||
|
* административный UI CRUD пользователей текущей группы и приязанных к ним ключей доступа. ключи генерируются случайно (32 символа)
|
||||||
|
* хранилище списка карт в yaml файле в папке соответственной группы пользователей, картинки рядом с ним на диске в виде файлов. количество картинок на карту не ограничено. карты общие для всех пользователей одной группы. для каждой карты указывается числовой номер версии картинок.
|
||||||
|
* административный UI для CRUD всех хранящихся, включая удаленные, карт текущей группы пользователей, загрузки картинок к ним. изменение/добавление/удаление любой картинки увеличивает номер версии в карте. загруженные картинки хранятся как есть.
|
||||||
|
* UI для показа списка ключей текущего пользователя. Для одного выбранного ключа генерирутся QR код, содержащий адрес сервера и сам ключ.
|
||||||
|
* API для клиентского приложения:
|
||||||
|
* аутентификация пользователя по ключу (Authorization: Bearer)
|
||||||
|
* получение списка всех карт (без пагинации). удаленные карты получить невозможно
|
||||||
|
* получение картинок по выбранной карте
|
||||||
|
* редактирование, удаление и добавление карт с их лого. удалённые карты помечаются, как удаленные и не удаляются
|
||||||
|
* удаление и добавление картинок карт
|
||||||
|
* весь UI на английском
|
||||||
|
|
||||||
|
# клиентское мобильное приложение (react/vite/ts pwa + capacitor андроид):
|
||||||
|
* хранение ключа и пинкода для входа
|
||||||
|
* экран нового входа
|
||||||
|
* поле для ввода адреса серверного API
|
||||||
|
* показывает камеру, даёт отсканировать ключ из QR кода
|
||||||
|
* позволяет ввести ключ текстом в открытом виде.
|
||||||
|
обязательна аутентификация на сервере для подтверждения правильности ключа. экран можно вызвать даже если ключ уже хранится в приложении, в этом случае старый ключ удаляется. хранится всегда только один последний ключ
|
||||||
|
* экран цифрового кода (= "пинкода"). показывается для ввода пинкода (4 цифры) при смене ключа (пинкод сохраняется) и при каждом открытии приложения (пинкод сверяется с хранящимся). защиты от перебора нет.
|
||||||
|
* экран карт. список всех хранящихся локально карт (цветными названия с логотипами или, если их нет, с аббревиатурами на цветном фоне) с возможностью выбрать одну из них во весь экран. сортировка по давности использования
|
||||||
|
* экран (одной) карты: просмотр её названия, штрих/qr-кода и её хранящиеся локально картинки, если есть. яркость экрана на андроиде поднимается на максимум. открывание экрана считается использованием карты и сохраняет дату и время
|
||||||
|
* на экране карт кнопка "фильтр по названию" (по подстроке длиной от 1 символа)
|
||||||
|
* на экране карт кнопка "обновить". перекачивает заново список всех карт с сервера и обновляет сохраненный список (только если скачалось успешно). для изменившихся карт асинхронно по очереди (макс 5 запросов одновременно) скачиваются их картинки.
|
||||||
|
* экран редактирования/добавления новой карты: поле для ввода названия, выбора цвета (по умолчанию случайный), загрузки опционального лого, загрузки опциональных фото, ввода кода и выбора типа код карты текстом или распознаванием QR/штрих-кода и его типа с карты или из загруженной картинки. лого и картинки перед отправкой, пережимаются в webp до размера 1000px по наибольшей стороне, EXIF удаляется
|
||||||
|
* на экране карт кнопка "добавить", вызывает экран редактирования/добавления новой карты
|
||||||
|
* на экране карты кнопка "удалить" с подтверждением.
|
||||||
|
* на экране карты кнопка "редактировать", вызывает экран редактирования/добавления новой карты
|
||||||
|
* весь UI на английском
|
||||||
|
|
||||||
|
клиентское приложение должно работать без интернета за исключением функций аутентификации, скачивания, редактирования, удаления и добавления карт и картинок. функции, требующие наличия сети, показывают ошибки сообщениями на экране и отваливаются.
|
||||||
|
|
||||||
|
вопросы деплоя в этом задании не рассматриваются
|
||||||
|
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
|
||||||
|
Пользователи и группы
|
||||||
|
* Пользователь состоит ровно в одной группе
|
||||||
|
* Первый суперадмин создаётся из переменной окружения SUPERADMIN_EMAIL
|
||||||
|
* При удалении группы все пользователи, ключи и карты удаляются.
|
||||||
|
* Текущая группа пользователя читается из users.yaml и сохраняется в cookie. UI суперадмина позволяет записать в эту cookie другое значение.
|
||||||
|
* При ошибка аутентификации отвечать ошибкой 401.
|
||||||
|
* Новые ключи генерируются только админом или суперадмином
|
||||||
|
|
||||||
|
Сессии и ключи
|
||||||
|
* Шифрование ключей: AES-256-GCM с 32-байтным ключом в base64 в env подойдёт?
|
||||||
|
|
||||||
|
Данные
|
||||||
|
* Где генерируется ID карты? На сервере, при добавлении. Возвращается клиенту.
|
||||||
|
* Конкурентные правки одной карты с разных устройств: побеждает последняя запись? Да.
|
||||||
|
* Дата последнего использования карты хранится только локально на устройстве и сохраняется при «обновить» по ID карты? Да.
|
||||||
|
|
||||||
|
Клиент и деплой
|
||||||
|
* Как менять PIN без смены ключа? Или только через повторный вход? Да.
|
||||||
|
* PWA раздаётся с того же домена что и API.
|
||||||
|
* Нужны ли Dockerfile и docker-compose с томом под данные и пример конфига прокси с bypass для /api? Нет.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "cardy",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"dev:server": "pnpm --filter @cardy/server-be dev",
|
||||||
|
"dev:admin": "pnpm --filter @cardy/server-fe dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1926
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- shared
|
||||||
|
- server-be
|
||||||
|
- server-fe
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Copy to .env and fill in.
|
||||||
|
|
||||||
|
# 32 random bytes in base64, e.g.: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# Losing it makes all stored access keys unreadable.
|
||||||
|
KEY_ENCRYPTION_SECRET=
|
||||||
|
|
||||||
|
# Created (or promoted) as superadmin on start.
|
||||||
|
SUPERADMIN_EMAIL=
|
||||||
|
# Group of that superadmin if it has to be created.
|
||||||
|
SUPERADMIN_GROUP=default
|
||||||
|
|
||||||
|
# The reverse proxy strips /api and sends X-Forwarded-Prefix: /api.
|
||||||
|
# public /api/admin/* (Authelia) -> /admin/*
|
||||||
|
# public /api/* (no auth) -> /*
|
||||||
|
# Public API URL for key QR codes. Optional: derived from X-Forwarded-Proto/Host/Prefix when empty.
|
||||||
|
# PUBLIC_API_URL=https://dev.aggtaa.com/api
|
||||||
|
|
||||||
|
# Optional extra protection: when set, /admin/* requires the header X-Cardy-Proxy-Secret with this value.
|
||||||
|
# Configure the reverse proxy to add it on the /api/admin route. Recommended if HOST is not loopback.
|
||||||
|
# ADMIN_PROXY_SECRET=
|
||||||
|
|
||||||
|
DATA_DIR=./data
|
||||||
|
# Loopback by default: /admin/* trusts Remote-Email, so only the local reverse proxy may connect.
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=10002
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { build } from 'esbuild';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Bundle workspace packages (they ship TypeScript sources), keep npm dependencies external.
|
||||||
|
const externalNpmPackages = {
|
||||||
|
name: 'external-npm-packages',
|
||||||
|
setup(b) {
|
||||||
|
b.onResolve({ filter: /.*/ }, (args) => {
|
||||||
|
if (args.kind === 'entry-point' || args.path.startsWith('.') || path.isAbsolute(args.path)) return undefined;
|
||||||
|
if (args.path.startsWith('@cardy/')) return undefined;
|
||||||
|
return { path: args.path, external: true };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/index.ts'],
|
||||||
|
outfile: 'dist/index.js',
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'node22',
|
||||||
|
sourcemap: true,
|
||||||
|
plugins: [externalNpmPackages],
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@cardy/server-be",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cardy/shared": "workspace:*",
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/cors": "^11.3.0",
|
||||||
|
"@fastify/multipart": "^10.1.2",
|
||||||
|
"fastify": "^5.12.5",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.4",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vitest": "^5.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import Fastify, { type FastifyServerOptions } from 'fastify';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { CardsService } from './cards.js';
|
||||||
|
import { ADMIN_API_PREFIX, type Config } from './config.js';
|
||||||
|
import { HttpError } from './errors.js';
|
||||||
|
import { formatZodError } from './http.js';
|
||||||
|
import { adminApi } from './routes/adminApi.js';
|
||||||
|
import { clientApi } from './routes/clientApi.js';
|
||||||
|
import { UsersService } from './users.js';
|
||||||
|
|
||||||
|
// Upload size is limited by the reverse proxy, not by the server.
|
||||||
|
const NO_LIMIT = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
export async function buildApp(config: Config, options: FastifyServerOptions = {}) {
|
||||||
|
// trustProxy: protocol and host come from X-Forwarded-* set by the reverse proxy.
|
||||||
|
const app = Fastify({ bodyLimit: NO_LIMIT, trustProxy: true, ...options });
|
||||||
|
|
||||||
|
const users = new UsersService(config.dataDir, config.keySecret);
|
||||||
|
const cards = new CardsService(config.dataDir);
|
||||||
|
await users.ensureSuperadmin(config.superadminEmail, config.superadminGroup);
|
||||||
|
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof HttpError) return reply.code(error.statusCode).send({ error: error.message });
|
||||||
|
if (error instanceof ZodError) return reply.code(400).send({ error: formatZodError(error) });
|
||||||
|
const statusCode = (error as { statusCode?: number }).statusCode;
|
||||||
|
if (statusCode && statusCode >= 400 && statusCode < 500) {
|
||||||
|
return reply.code(statusCode).send({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
request.log.error(error);
|
||||||
|
return reply.code(500).send({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'Not found' }));
|
||||||
|
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(multipart, { limits: { fileSize: NO_LIMIT, files: NO_LIMIT, fieldSize: 1024 * 1024 } });
|
||||||
|
|
||||||
|
await app.register(adminApi, {
|
||||||
|
prefix: ADMIN_API_PREFIX,
|
||||||
|
users,
|
||||||
|
cards,
|
||||||
|
proxySecret: config.adminProxySecret,
|
||||||
|
publicApiUrl: config.publicApiUrl,
|
||||||
|
});
|
||||||
|
await app.register(clientApi, { users, cards });
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, rm, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CODE_TYPES, type Card, type CardFields, type ImageRef } from '@cardy/shared';
|
||||||
|
import { badRequest, notFound } from './errors.js';
|
||||||
|
import { YamlFile } from './yamlFile.js';
|
||||||
|
|
||||||
|
const safeId = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id');
|
||||||
|
const safeFileName = z.string().regex(/^[A-Za-z0-9-]+\.[A-Za-z0-9]{1,10}$/, 'Invalid file name');
|
||||||
|
|
||||||
|
const imageRefSchema = z.object({ id: safeId, file: safeFileName, mime: z.string() });
|
||||||
|
|
||||||
|
const cardSchema = z.object({
|
||||||
|
id: safeId,
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
code: z.coerce.string(),
|
||||||
|
codeType: z.enum(CODE_TYPES),
|
||||||
|
logo: imageRefSchema.nullable().default(null),
|
||||||
|
images: z.array(imageRefSchema).default([]),
|
||||||
|
imagesVersion: z.number().int().default(0),
|
||||||
|
deleted: z.boolean().default(false),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardsFileSchema = z.object({ cards: z.array(cardSchema).default([]) });
|
||||||
|
type CardsFile = z.infer<typeof cardsFileSchema>;
|
||||||
|
|
||||||
|
export interface Upload {
|
||||||
|
data: Buffer;
|
||||||
|
mime: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardAccess {
|
||||||
|
/** Admin UI sees deleted cards; the client API does not. */
|
||||||
|
includeDeleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_EXTENSIONS: Record<string, string> = {
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/avif': 'avif',
|
||||||
|
'image/heic': 'heic',
|
||||||
|
'image/heif': 'heif',
|
||||||
|
'image/bmp': 'bmp',
|
||||||
|
'image/svg+xml': 'svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
function extensionFor(upload: Upload): string {
|
||||||
|
const byMime = MIME_EXTENSIONS[upload.mime.toLowerCase()];
|
||||||
|
if (byMime) return byMime;
|
||||||
|
const byName = path.extname(upload.filename ?? '').slice(1).toLowerCase();
|
||||||
|
return /^[a-z0-9]{1,10}$/.test(byName) ? byName : 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CardsService {
|
||||||
|
private readonly files = new Map<string, YamlFile<CardsFile>>();
|
||||||
|
|
||||||
|
constructor(private readonly dataDir: string) {}
|
||||||
|
|
||||||
|
private groupDir(groupId: string): string {
|
||||||
|
return path.join(this.dataDir, 'groups', safeId.parse(groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private imageDir(groupId: string, cardId: string): string {
|
||||||
|
return path.join(this.groupDir(groupId), 'images', safeId.parse(cardId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private file(groupId: string): YamlFile<CardsFile> {
|
||||||
|
let file = this.files.get(groupId);
|
||||||
|
if (!file) {
|
||||||
|
file = new YamlFile(
|
||||||
|
path.join(this.groupDir(groupId), 'cards.yaml'),
|
||||||
|
(raw) => cardsFileSchema.parse(raw ?? {}),
|
||||||
|
() => ({ cards: [] }),
|
||||||
|
);
|
||||||
|
this.files.set(groupId, file);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(groupId: string, access: CardAccess): Promise<Card[]> {
|
||||||
|
const { cards } = await this.file(groupId).read();
|
||||||
|
return access.includeDeleted ? cards : cards.filter((c) => !c.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(groupId: string, cardId: string, access: CardAccess): Promise<Card> {
|
||||||
|
const card = (await this.file(groupId).read()).cards.find((c) => c.id === cardId);
|
||||||
|
if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found');
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path of the card's logo or image file on disk. */
|
||||||
|
async imagePath(groupId: string, cardId: string, imageId: 'logo' | string, access: CardAccess) {
|
||||||
|
const card = await this.get(groupId, cardId, access);
|
||||||
|
const ref = imageId === 'logo' ? card.logo : card.images.find((i) => i.id === imageId);
|
||||||
|
if (!ref) throw notFound('Image not found');
|
||||||
|
return { path: path.join(this.imageDir(groupId, card.id), ref.file), mime: ref.mime };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(groupId: string, fields: CardFields, logo?: Upload): Promise<Card> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const logoRef = logo ? await this.saveFile(groupId, id, logo) : null;
|
||||||
|
const card: Card = {
|
||||||
|
id,
|
||||||
|
...fields,
|
||||||
|
logo: logoRef,
|
||||||
|
images: [],
|
||||||
|
imagesVersion: logoRef ? 1 : 0,
|
||||||
|
deleted: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await this.file(groupId).update((d) => {
|
||||||
|
d.cards.push(card);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await rm(this.imageDir(groupId, id), { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates fields and optionally the logo (`logo: null` removes it).
|
||||||
|
* The client API cannot touch deleted cards nor change the `deleted` flag.
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
groupId: string,
|
||||||
|
cardId: string,
|
||||||
|
patch: Partial<CardFields> & { deleted?: boolean },
|
||||||
|
access: CardAccess,
|
||||||
|
logo?: Upload | null,
|
||||||
|
): Promise<Card> {
|
||||||
|
if (patch.deleted !== undefined && !access.includeDeleted) throw badRequest('Cannot change deleted flag');
|
||||||
|
await this.get(groupId, cardId, access);
|
||||||
|
const newLogo = logo ? await this.saveFile(groupId, cardId, logo) : undefined;
|
||||||
|
let oldLogo = null as ImageRef | null;
|
||||||
|
let card: Card;
|
||||||
|
try {
|
||||||
|
card = await this.mutate(groupId, cardId, access, (c) => {
|
||||||
|
Object.assign(c, stripUndefined(patch));
|
||||||
|
if (logo !== undefined) {
|
||||||
|
oldLogo = c.logo;
|
||||||
|
c.logo = newLogo ?? null;
|
||||||
|
c.imagesVersion++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (newLogo) await this.removeFiles(groupId, cardId, [newLogo]);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (oldLogo) await this.removeFiles(groupId, cardId, [oldLogo]);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDeleted(groupId: string, cardId: string, deleted: boolean): Promise<Card> {
|
||||||
|
return this.mutate(groupId, cardId, { includeDeleted: true }, (c) => {
|
||||||
|
c.deleted = deleted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently removes the card and its files. */
|
||||||
|
async purge(groupId: string, cardId: string): Promise<void> {
|
||||||
|
await this.file(groupId).update((d) => {
|
||||||
|
const before = d.cards.length;
|
||||||
|
d.cards = d.cards.filter((c) => c.id !== cardId);
|
||||||
|
if (d.cards.length === before) throw notFound('Card not found');
|
||||||
|
});
|
||||||
|
await rm(this.imageDir(groupId, cardId), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addImages(groupId: string, cardId: string, uploads: Upload[], access: CardAccess): Promise<Card> {
|
||||||
|
if (uploads.length === 0) throw badRequest('No files uploaded');
|
||||||
|
await this.get(groupId, cardId, access);
|
||||||
|
const refs: ImageRef[] = [];
|
||||||
|
try {
|
||||||
|
for (const upload of uploads) refs.push(await this.saveFile(groupId, cardId, upload));
|
||||||
|
return await this.mutate(groupId, cardId, access, (c) => {
|
||||||
|
c.images.push(...refs);
|
||||||
|
c.imagesVersion++;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await this.removeFiles(groupId, cardId, refs);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeImage(groupId: string, cardId: string, imageId: string, access: CardAccess): Promise<Card> {
|
||||||
|
let removed: ImageRef | undefined;
|
||||||
|
const card = await this.mutate(groupId, cardId, access, (c) => {
|
||||||
|
removed = c.images.find((i) => i.id === imageId);
|
||||||
|
if (!removed) throw notFound('Image not found');
|
||||||
|
c.images = c.images.filter((i) => i !== removed);
|
||||||
|
c.imagesVersion++;
|
||||||
|
});
|
||||||
|
if (removed) await this.removeFiles(groupId, cardId, [removed]);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeGroup(groupId: string): Promise<void> {
|
||||||
|
this.files.delete(groupId);
|
||||||
|
await rm(this.groupDir(groupId), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutate(groupId: string, cardId: string, access: CardAccess, fn: (card: Card) => void): Promise<Card> {
|
||||||
|
return this.file(groupId).update((d) => {
|
||||||
|
const card = d.cards.find((c) => c.id === cardId);
|
||||||
|
if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found');
|
||||||
|
fn(card);
|
||||||
|
card.updatedAt = new Date().toISOString();
|
||||||
|
return card;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveFile(groupId: string, cardId: string, upload: Upload): Promise<ImageRef> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const file = `${id}.${extensionFor(upload)}`;
|
||||||
|
const dir = this.imageDir(groupId, cardId);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, file), upload.data);
|
||||||
|
return { id, file, mime: upload.mime || 'application/octet-stream' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeFiles(groupId: string, cardId: string, refs: ImageRef[]): Promise<void> {
|
||||||
|
const dir = this.imageDir(groupId, cardId);
|
||||||
|
await Promise.all(refs.map((r) => unlink(path.join(dir, r.file)).catch(() => undefined)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUndefined<T extends object>(obj: T): Partial<T> {
|
||||||
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
dataDir: string;
|
||||||
|
keySecret: Buffer;
|
||||||
|
superadminEmail: string;
|
||||||
|
superadminGroup: string;
|
||||||
|
/**
|
||||||
|
* Public base URL of the API for key QR codes, e.g. https://dev.aggtaa.com/api.
|
||||||
|
* When unset it is derived per request from X-Forwarded-Proto/Host/Prefix.
|
||||||
|
*/
|
||||||
|
publicApiUrl?: string;
|
||||||
|
/** When set, admin API requests must carry it in X-Cardy-Proxy-Secret (added by the reverse proxy). */
|
||||||
|
adminProxySecret?: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reverse proxy strips "/api": public /api/admin/* (behind Authelia) arrives here as /admin/*,
|
||||||
|
* the rest of /api/* (client API, no Authelia) arrives at the root.
|
||||||
|
*/
|
||||||
|
export const ADMIN_API_PREFIX = '/admin';
|
||||||
|
|
||||||
|
export function parseKeySecret(value: string | undefined): Buffer {
|
||||||
|
if (!value) throw new Error('KEY_ENCRYPTION_SECRET is required');
|
||||||
|
const buf = Buffer.from(value, 'base64');
|
||||||
|
if (buf.length !== 32 || buf.toString('base64') !== value.trim()) {
|
||||||
|
throw new Error('KEY_ENCRYPTION_SECRET must be exactly 32 bytes encoded in base64');
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||||
|
const superadminEmail = env.SUPERADMIN_EMAIL?.trim().toLowerCase();
|
||||||
|
if (!superadminEmail) throw new Error('SUPERADMIN_EMAIL is required');
|
||||||
|
|
||||||
|
const publicApiUrl = env.PUBLIC_API_URL?.trim().replace(/\/+$/, '') || undefined;
|
||||||
|
if (publicApiUrl) {
|
||||||
|
try {
|
||||||
|
new URL(publicApiUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`PUBLIC_API_URL is not a valid URL: ${publicApiUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(env.PORT ?? 10002);
|
||||||
|
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${env.PORT}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataDir: path.resolve(env.DATA_DIR ?? './data'),
|
||||||
|
keySecret: parseKeySecret(env.KEY_ENCRYPTION_SECRET),
|
||||||
|
superadminEmail,
|
||||||
|
superadminGroup: env.SUPERADMIN_GROUP?.trim() || 'default',
|
||||||
|
publicApiUrl,
|
||||||
|
adminProxySecret: env.ADMIN_PROXY_SECRET?.trim() || undefined,
|
||||||
|
// Loopback by default: the admin API trusts Remote-Email, so only the local reverse proxy may connect.
|
||||||
|
host: env.HOST ?? '127.0.0.1',
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, randomInt } from 'node:crypto';
|
||||||
|
|
||||||
|
const KEY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
export const ACCESS_KEY_LENGTH = 32;
|
||||||
|
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
export function generateAccessKey(): string {
|
||||||
|
let key = '';
|
||||||
|
for (let i = 0; i < ACCESS_KEY_LENGTH; i++) key += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)];
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AES-256-GCM; result is base64(iv | authTag | ciphertext). */
|
||||||
|
export function encryptSecret(plain: string, secret: Buffer): string {
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', secret, iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(encrypted: string, secret: Buffer): string {
|
||||||
|
const buf = Buffer.from(encrypted, 'base64');
|
||||||
|
if (buf.length < IV_LENGTH + TAG_LENGTH) throw new Error('Encrypted value is too short');
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', secret, buf.subarray(0, IV_LENGTH));
|
||||||
|
decipher.setAuthTag(buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
|
||||||
|
return Buffer.concat([decipher.update(buf.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly statusCode: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const badRequest = (message: string) => new HttpError(400, message);
|
||||||
|
export const unauthorized = (message = 'Unauthorized') => new HttpError(401, message);
|
||||||
|
export const forbidden = (message = 'Forbidden') => new HttpError(403, message);
|
||||||
|
export const notFound = (message = 'Not found') => new HttpError(404, message);
|
||||||
|
export const conflict = (message: string) => new HttpError(409, message);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path prefix the reverse proxy stripped before forwarding (X-Forwarded-Prefix), e.g. "/api".
|
||||||
|
* Returns '' when absent or malformed.
|
||||||
|
*/
|
||||||
|
export function forwardedPrefix(request: FastifyRequest): string {
|
||||||
|
const raw = request.headers['x-forwarded-prefix'];
|
||||||
|
const value = (Array.isArray(raw) ? raw[0] : raw)?.split(',')[0]?.trim().replace(/\/+$/, '') ?? '';
|
||||||
|
return /^(\/[A-Za-z0-9._~-]+)*$/.test(value) ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public base URL of this server as seen by clients, e.g. https://dev.aggtaa.com/api */
|
||||||
|
export function publicBaseUrl(request: FastifyRequest, override?: string): string {
|
||||||
|
if (override) return override;
|
||||||
|
// With trustProxy, protocol/host come from X-Forwarded-Proto / X-Forwarded-Host.
|
||||||
|
return `${request.protocol}://${request.host}${forwardedPrefix(request)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { ZodError, type ZodType } from 'zod';
|
||||||
|
import type { Upload } from './cards.js';
|
||||||
|
import { badRequest, notFound } from './errors.js';
|
||||||
|
|
||||||
|
export function parseBody<T>(schema: ZodType<T>, value: unknown): T {
|
||||||
|
const result = schema.safeParse(value ?? {});
|
||||||
|
if (!result.success) throw badRequest(formatZodError(result.error));
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatZodError(error: ZodError): string {
|
||||||
|
return error.issues.map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message)).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipartData {
|
||||||
|
fields: Record<string, string>;
|
||||||
|
files: Map<string, Upload[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMultipart(request: FastifyRequest): Promise<MultipartData> {
|
||||||
|
if (!request.isMultipart()) throw badRequest('Expected multipart/form-data');
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
const files = new Map<string, Upload[]>();
|
||||||
|
for await (const part of request.parts()) {
|
||||||
|
if (part.type === 'file') {
|
||||||
|
const data = await part.toBuffer();
|
||||||
|
if (data.length === 0 && !part.filename) continue; // empty <input type=file>
|
||||||
|
const list = files.get(part.fieldname) ?? [];
|
||||||
|
list.push({ data, mime: part.mimetype, filename: part.filename });
|
||||||
|
files.set(part.fieldname, list);
|
||||||
|
} else {
|
||||||
|
fields[part.fieldname] = String(part.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fields, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a stored user upload; non-images are forced to download and nothing is ever rendered as active content. */
|
||||||
|
export async function sendImage(reply: FastifyReply, file: { path: string; mime: string }) {
|
||||||
|
let size: number;
|
||||||
|
try {
|
||||||
|
size = (await stat(file.path)).size;
|
||||||
|
} catch {
|
||||||
|
throw notFound('Image file is missing');
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
.header('Content-Type', file.mime.toLowerCase().startsWith('image/') ? file.mime : 'application/octet-stream')
|
||||||
|
.header('Content-Length', size)
|
||||||
|
.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
.header('Content-Security-Policy', 'sandbox')
|
||||||
|
.header('Cache-Control', 'private, no-cache')
|
||||||
|
.send(createReadStream(file.path));
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { buildApp } from './app.js';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = await buildApp(config, { logger: true });
|
||||||
|
|
||||||
|
app.log.info(
|
||||||
|
{ dataDir: config.dataDir, publicApiUrl: config.publicApiUrl ?? '(from X-Forwarded-* headers)' },
|
||||||
|
'Cardy server configuration',
|
||||||
|
);
|
||||||
|
if (!config.adminProxySecret && !['127.0.0.1', '::1', 'localhost'].includes(config.host)) {
|
||||||
|
app.log.warn(
|
||||||
|
`Listening on ${config.host} without ADMIN_PROXY_SECRET: anyone who can reach this port can impersonate users via Remote-Email`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.once(signal, () => {
|
||||||
|
app.close().then(() => process.exit(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import {
|
||||||
|
cardFieldsSchema,
|
||||||
|
cardPatchSchema,
|
||||||
|
currentGroupSchema,
|
||||||
|
groupInputSchema,
|
||||||
|
keyCreateSchema,
|
||||||
|
userCreateSchema,
|
||||||
|
userPatchSchema,
|
||||||
|
type Group,
|
||||||
|
type MeResponse,
|
||||||
|
} from '@cardy/shared';
|
||||||
|
import type { CardsService } from '../cards.js';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const GROUP_COOKIE = 'cardy_group';
|
||||||
|
export const CSRF_HEADER = 'x-cardy-admin';
|
||||||
|
export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
|
||||||
|
|
||||||
|
export interface AdminContext {
|
||||||
|
user: StoredUser;
|
||||||
|
ownGroup: Group;
|
||||||
|
currentGroup: Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
admin: AdminContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
users: UsersService;
|
||||||
|
cards: CardsService;
|
||||||
|
proxySecret?: string;
|
||||||
|
publicApiUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_ACCESS = { includeDeleted: true };
|
||||||
|
|
||||||
|
type IdParams = { id: string };
|
||||||
|
type ImageParams = { id: string; imageId: string };
|
||||||
|
type EmailParams = { email: string };
|
||||||
|
type KeyParams = { email: string; keyId: string };
|
||||||
|
|
||||||
|
export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards, proxySecret, publicApiUrl }) => {
|
||||||
|
app.decorateRequest('admin', null as unknown as AdminContext);
|
||||||
|
|
||||||
|
const apiUrl = (request: FastifyRequest) => publicBaseUrl(request, publicApiUrl);
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (request) => {
|
||||||
|
// Only the reverse proxy route that goes through forward-auth may reach the admin API.
|
||||||
|
if (proxySecret) {
|
||||||
|
const given = request.headers[PROXY_SECRET_HEADER];
|
||||||
|
if (typeof given !== 'string' || !safeEqual(given, proxySecret)) {
|
||||||
|
throw unauthorized(`Missing or wrong ${PROXY_SECRET_HEADER} header (ADMIN_PROXY_SECRET is set on the server)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Header-based CSRF guard: cross-site forms cannot set custom headers.
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD' && request.headers[CSRF_HEADER] !== '1') {
|
||||||
|
throw forbidden(`Missing ${CSRF_HEADER} header`);
|
||||||
|
}
|
||||||
|
const email = request.headers['remote-email'];
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cookie path as the browser sees it, e.g. /api/admin.
|
||||||
|
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
|
||||||
|
reply.setCookie(GROUP_COOKIE, groupId, {
|
||||||
|
path: forwardedPrefix(request) + ADMIN_API_PREFIX,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: request.protocol === 'https',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupAdmin = (request: FastifyRequest) => {
|
||||||
|
const { user, currentGroup } = request.admin;
|
||||||
|
requireGroupAdmin(user, currentGroup.id);
|
||||||
|
return { user, groupId: currentGroup.id };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- current user ----
|
||||||
|
|
||||||
|
app.get('/me', async (request, reply): Promise<MeResponse> => {
|
||||||
|
const { user, ownGroup, 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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
|
||||||
|
|
||||||
|
// ---- groups ----
|
||||||
|
|
||||||
|
app.get('/groups', async (request) => {
|
||||||
|
requireSuperadmin(request.admin.user);
|
||||||
|
return users.listGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/groups', async (request) => {
|
||||||
|
const { name } = parseBody(groupInputSchema, request.body);
|
||||||
|
return users.createGroup(request.admin.user, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch<{ Params: IdParams }>('/groups/:id', async (request) => {
|
||||||
|
const { name } = parseBody(groupInputSchema, request.body);
|
||||||
|
return users.renameGroup(request.admin.user, request.params.id, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: IdParams }>('/groups/:id', async (request, reply) => {
|
||||||
|
await users.deleteGroup(request.admin.user, request.params.id);
|
||||||
|
await cards.removeGroup(request.params.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/current-group', async (request, reply) => {
|
||||||
|
requireSuperadmin(request.admin.user);
|
||||||
|
const { groupId } = parseBody(currentGroupSchema, request.body);
|
||||||
|
const group = await users.getGroup(groupId);
|
||||||
|
if (!group) throw notFound('Group not found');
|
||||||
|
setGroupCookie(request, reply, group.id);
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- users 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)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => {
|
||||||
|
const { user, groupId } = groupAdmin(request);
|
||||||
|
await users.deleteUser(user, groupId, request.params.email);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
|
||||||
|
const { user, groupId } = groupAdmin(request);
|
||||||
|
const { label } = parseBody(keyCreateSchema, request.body);
|
||||||
|
const key = await users.createKey(user, groupId, request.params.email, label || undefined, apiUrl(request));
|
||||||
|
return reply.code(201).send(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: KeyParams }>('/users/:email/keys/:keyId', async (request, reply) => {
|
||||||
|
const { user, groupId } = groupAdmin(request);
|
||||||
|
await users.deleteKey(user, groupId, request.params.email, request.params.keyId);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- cards of the current group ----
|
||||||
|
|
||||||
|
app.get('/cards', async (request) => cards.list(groupAdmin(request).groupId, ADMIN_ACCESS));
|
||||||
|
|
||||||
|
app.post('/cards', async (request, reply) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
return reply.code(201).send(await cards.create(groupId, parseBody(cardFieldsSchema, request.body)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch<{ Params: IdParams }>('/cards/:id', async (request) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
return cards.update(groupId, request.params.id, parseBody(cardPatchSchema, request.body), ADMIN_ACCESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** First delete marks the card as deleted, deleting an already deleted card removes it permanently. */
|
||||||
|
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
const card = await cards.get(groupId, request.params.id, ADMIN_ACCESS);
|
||||||
|
if (!card.deleted) return cards.setDeleted(groupId, card.id, true);
|
||||||
|
await cards.purge(groupId, card.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
return sendImage(reply, await cards.imagePath(groupId, request.params.id, 'logo', ADMIN_ACCESS));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: IdParams }>('/cards/:id/logo', async (request) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
const { files } = await readMultipart(request);
|
||||||
|
const logo = files.get('file')?.[0];
|
||||||
|
if (!logo) throw badRequest('No file uploaded');
|
||||||
|
return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, logo);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: IdParams }>('/cards/:id/logo', async (request) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
const { id, imageId } = request.params;
|
||||||
|
return sendImage(reply, await cards.imagePath(groupId, id, imageId, ADMIN_ACCESS));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
const { files } = await readMultipart(request);
|
||||||
|
return cards.addImages(groupId, request.params.id, files.get('file') ?? [], ADMIN_ACCESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) => {
|
||||||
|
const { groupId } = groupAdmin(request);
|
||||||
|
return cards.removeImage(groupId, request.params.id, request.params.imageId, ADMIN_ACCESS);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||||
|
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||||
|
import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
|
||||||
|
import type { CardsService } from '../cards.js';
|
||||||
|
import { unauthorized } from '../errors.js';
|
||||||
|
import { parseBody, readMultipart, sendImage } from '../http.js';
|
||||||
|
import type { KeyOwner, UsersService } from '../users.js';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
keyOwner: KeyOwner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
users: UsersService;
|
||||||
|
cards: CardsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT_ACCESS = { includeDeleted: false };
|
||||||
|
|
||||||
|
type IdParams = { id: string };
|
||||||
|
type ImageParams = { id: string; imageId: string };
|
||||||
|
|
||||||
|
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
|
||||||
|
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
|
||||||
|
// The plugin's catch-all OPTIONS route would also match admin paths, so CORS is off there.
|
||||||
|
const corsOptions: FastifyCorsOptions = {
|
||||||
|
origin: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||||
|
maxAge: 86400,
|
||||||
|
};
|
||||||
|
await app.register(cors, () => (request: FastifyRequest, callback: (err: Error | null, options: FastifyCorsOptions) => void) => {
|
||||||
|
const isAdmin = /^\/admin(\/|\?|$)/.test(request.url ?? '');
|
||||||
|
callback(null, isAdmin ? { origin: false } : corsOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorateRequest('keyOwner', null as unknown as KeyOwner);
|
||||||
|
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
if (request.method === 'OPTIONS') return;
|
||||||
|
const match = /^Bearer\s+(\S+)\s*$/i.exec(request.headers.authorization ?? '');
|
||||||
|
const owner = match?.[1] ? await users.authenticateKey(match[1]) : undefined;
|
||||||
|
if (!owner) throw unauthorized('Invalid access key');
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS));
|
||||||
|
|
||||||
|
app.post('/cards', async (request, reply) => {
|
||||||
|
const { fields, files } = await readMultipart(request);
|
||||||
|
const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
|
||||||
|
return reply.code(201).send(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: IdParams }>('/cards/:id', async (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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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<{ 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.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
|
||||||
|
const { files } = await readMultipart(request);
|
||||||
|
return cards.addImages(groupId(request), request.params.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),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
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 { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
|
||||||
|
import { badRequest, conflict, forbidden, notFound } from './errors.js';
|
||||||
|
import { YamlFile } from './yamlFile.js';
|
||||||
|
|
||||||
|
const idSchema = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id');
|
||||||
|
|
||||||
|
const storedKeySchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
label: z.string().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
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 usersFileSchema = z.object({
|
||||||
|
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
|
||||||
|
users: z.array(storedUserSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StoredKey = z.infer<typeof storedKeySchema>;
|
||||||
|
export type StoredUser = z.infer<typeof storedUserSchema>;
|
||||||
|
type UsersFile = z.infer<typeof usersFileSchema>;
|
||||||
|
|
||||||
|
export interface KeyOwner {
|
||||||
|
user: StoredUser;
|
||||||
|
group: Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsersService {
|
||||||
|
private readonly file: YamlFile<UsersFile>;
|
||||||
|
/** Plaintext key → key owner email; rebuilt when the file data object changes. */
|
||||||
|
private keyIndex: { source: UsersFile; map: Map<string, string> } | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dataDir: string,
|
||||||
|
private readonly secret: Buffer,
|
||||||
|
) {
|
||||||
|
this.file = new YamlFile(
|
||||||
|
path.join(dataDir, 'users.yaml'),
|
||||||
|
(raw) => usersFileSchema.parse(raw ?? {}),
|
||||||
|
() => ({ groups: [], users: [] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */
|
||||||
|
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;
|
||||||
|
await this.file.update((d) => {
|
||||||
|
const user = d.users.find((u) => u.email === email);
|
||||||
|
if (user) {
|
||||||
|
user.role = 'superadmin';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let group = d.groups.find((g) => sameName(g.name, groupName));
|
||||||
|
if (!group) {
|
||||||
|
group = { id: randomUUID(), name: groupName };
|
||||||
|
d.groups.push(group);
|
||||||
|
}
|
||||||
|
d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- lookups ----
|
||||||
|
|
||||||
|
async findUser(email: string): Promise<StoredUser | undefined> {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
return (await this.file.read()).users.find((u) => u.email === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroup(id: string): Promise<Group | undefined> {
|
||||||
|
return (await this.file.read()).groups.find((g) => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroups(): Promise<Group[]> {
|
||||||
|
return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateKey(key: string): Promise<KeyOwner | undefined> {
|
||||||
|
const data = await this.file.read();
|
||||||
|
if (this.keyIndex?.source !== data) {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const user of data.users) {
|
||||||
|
for (const k of user.keys) map.set(decryptSecret(k.encrypted, this.secret), user.email);
|
||||||
|
}
|
||||||
|
this.keyIndex = { source: data, map };
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- views ----
|
||||||
|
|
||||||
|
/** `apiUrl` is the public API base URL that goes into login deep links. */
|
||||||
|
keyViews(user: StoredUser, apiUrl: string): AccessKeyView[] {
|
||||||
|
return user.keys.map((k) => {
|
||||||
|
const key = decryptSecret(k.encrypted, this.secret);
|
||||||
|
return { id: k.id, label: k.label, createdAt: k.createdAt, key, deepLink: buildLoginDeepLink(apiUrl, key) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
userView(user: StoredUser, apiUrl: string): UserView {
|
||||||
|
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- groups (superadmin) ----
|
||||||
|
|
||||||
|
async createGroup(actor: StoredUser, name: string): Promise<Group> {
|
||||||
|
requireSuperadmin(actor);
|
||||||
|
return this.file.update((d) => {
|
||||||
|
if (d.groups.some((g) => sameName(g.name, name))) throw conflict(`Group "${name}" already exists`);
|
||||||
|
const group = { id: randomUUID(), name };
|
||||||
|
d.groups.push(group);
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameGroup(actor: StoredUser, id: string, name: string): Promise<Group> {
|
||||||
|
requireSuperadmin(actor);
|
||||||
|
return this.file.update((d) => {
|
||||||
|
const group = d.groups.find((g) => g.id === id);
|
||||||
|
if (!group) throw notFound('Group not found');
|
||||||
|
if (d.groups.some((g) => g.id !== id && sameName(g.name, name))) throw conflict(`Group "${name}" already exists`);
|
||||||
|
group.name = name;
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the group with its users and keys. 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');
|
||||||
|
}
|
||||||
|
d.groups = d.groups.filter((g) => g.id !== id);
|
||||||
|
d.users = d.users.filter((u) => u.groupId !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- users (admin of the current group) ----
|
||||||
|
|
||||||
|
async listUsers(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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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: [] };
|
||||||
|
d.users.push(user);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(
|
||||||
|
actor: StoredUser,
|
||||||
|
currentGroupId: string,
|
||||||
|
email: string,
|
||||||
|
patch: { role?: Role; groupId?: string },
|
||||||
|
): Promise<StoredUser> {
|
||||||
|
if (patch.role === 'superadmin') requireSuperadmin(actor);
|
||||||
|
if (patch.groupId !== 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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
d.users = d.users.filter((u) => u !== user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keys ----
|
||||||
|
|
||||||
|
async createKey(
|
||||||
|
actor: StoredUser,
|
||||||
|
currentGroupId: string,
|
||||||
|
email: string,
|
||||||
|
label: string | undefined,
|
||||||
|
apiUrl: string,
|
||||||
|
): Promise<AccessKeyView> {
|
||||||
|
const plain = generateAccessKey();
|
||||||
|
const stored: StoredKey = {
|
||||||
|
id: randomUUID(),
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
encrypted: encryptSecret(plain, this.secret),
|
||||||
|
};
|
||||||
|
await this.file.update((d) => {
|
||||||
|
findManagedUser(d, actor, currentGroupId, email).keys.push(stored);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: stored.id,
|
||||||
|
label: stored.label,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
key: plain,
|
||||||
|
deepLink: buildLoginDeepLink(apiUrl, plain),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> {
|
||||||
|
await this.file.update((d) => {
|
||||||
|
const user = findManagedUser(d, actor, currentGroupId, 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameName(a: string, b: string): boolean {
|
||||||
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSuperadmins(d: UsersFile): number {
|
||||||
|
return d.users.filter((u) => u.role === 'superadmin').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGroupAdmin(actor: StoredUser, groupId: string): boolean {
|
||||||
|
return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
|
||||||
|
if (!isGroupAdmin(actor, groupId)) throw forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSuperadmin(actor: StoredUser): void {
|
||||||
|
if (actor.role !== '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);
|
||||||
|
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);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A YAML document on disk with an in-memory cache.
|
||||||
|
* - Reads are served from cache until the file's mtime changes (manual edits are picked up).
|
||||||
|
* - Updates are serialized and written atomically (temp file + rename).
|
||||||
|
* - Callers must treat data returned by read() as immutable.
|
||||||
|
*/
|
||||||
|
export class YamlFile<T> {
|
||||||
|
private cache: { mtimeMs: number; data: T } | undefined;
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly filePath: string,
|
||||||
|
private readonly parse: (raw: unknown) => T,
|
||||||
|
private readonly empty: () => T,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async read(): Promise<T> {
|
||||||
|
let mtimeMs: number;
|
||||||
|
try {
|
||||||
|
mtimeMs = (await stat(this.filePath)).mtimeMs;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.empty();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (this.cache?.mtimeMs === mtimeMs) return this.cache.data;
|
||||||
|
const text = await readFile(this.filePath, 'utf8');
|
||||||
|
let data: T;
|
||||||
|
try {
|
||||||
|
data = this.parse(YAML.parse(text) ?? undefined);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Invalid data in ${this.filePath}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
this.cache = { mtimeMs, data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `fn` on a copy of the data and saves the result; if `fn` throws, nothing is written. */
|
||||||
|
update<R>(fn: (data: T) => R | Promise<R>): Promise<R> {
|
||||||
|
const run = async () => {
|
||||||
|
const data = structuredClone(await this.read());
|
||||||
|
const result = await fn(data);
|
||||||
|
await this.write(data);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const next = this.queue.then(run, run);
|
||||||
|
this.queue = next.catch(() => undefined);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async write(data: T): Promise<void> {
|
||||||
|
await mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
const tmp = `${this.filePath}.${randomBytes(6).toString('hex')}.tmp`;
|
||||||
|
await writeFile(tmp, YAML.stringify(data), 'utf8');
|
||||||
|
try {
|
||||||
|
await renameWithRetry(tmp, this.filePath);
|
||||||
|
} catch (err) {
|
||||||
|
await unlink(tmp).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.cache = { mtimeMs: (await stat(this.filePath)).mtimeMs, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Windows a rename over a file that is momentarily open elsewhere fails with EPERM/EBUSY.
|
||||||
|
async function renameWithRetry(from: string, to: string): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await rename(from, to);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')) throw err;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AccessKeyView, Card, Group, MeResponse, UserView } from '@cardy/shared';
|
||||||
|
import { SUPERADMIN, multipart, testApp } from './helpers.js';
|
||||||
|
|
||||||
|
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||||
|
|
||||||
|
function admin(app: FastifyInstance, email: string, cookie?: string) {
|
||||||
|
return async (method: Method, url: string, body?: unknown) => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `/admin${url}`,
|
||||||
|
headers: { 'remote-email': email, 'x-cardy-admin': '1', ...(cookie ? { cookie } : {}) },
|
||||||
|
...(body !== undefined ? { payload: body as object } : {}),
|
||||||
|
});
|
||||||
|
const isJson = String(res.headers['content-type'] ?? '').startsWith('application/json');
|
||||||
|
return { status: res.statusCode, body: isJson ? res.json() : undefined, res };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin API authentication', () => {
|
||||||
|
it('bootstraps the superadmin from config', async () => {
|
||||||
|
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' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 without header or for unknown users', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
expect((await app.inject({ url: '/admin/me' })).statusCode).toBe(401);
|
||||||
|
expect((await admin(app, 'nobody@example.com')('GET', '/me')).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches Remote-Email case-insensitively', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
expect((await admin(app, SUPERADMIN.toUpperCase())('GET', '/me')).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the CSRF header on mutations', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/groups',
|
||||||
|
headers: { 'remote-email': SUPERADMIN },
|
||||||
|
payload: { name: 'x' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groups and current group', () => {
|
||||||
|
it('lets the superadmin switch groups via cookie', async () => {
|
||||||
|
const { app, root, defaultGroup } = await setup();
|
||||||
|
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||||
|
expect((await root('POST', '/groups', { name: 'other' })).status).toBe(409);
|
||||||
|
|
||||||
|
const switched = await root('PUT', '/current-group', { groupId: other.id });
|
||||||
|
expect(switched.status).toBe(200);
|
||||||
|
const setCookie = String(switched.res.headers['set-cookie']);
|
||||||
|
expect(setCookie).toContain(`cardy_group=${other.id}`);
|
||||||
|
expect(setCookie).toMatch(/HttpOnly/i);
|
||||||
|
|
||||||
|
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||||
|
expect(((await inOther('GET', '/me')).body as MeResponse).currentGroup.id).toBe(other.id);
|
||||||
|
// Unknown group in cookie falls back to own group.
|
||||||
|
const stale = admin(app, SUPERADMIN, 'cardy_group=missing');
|
||||||
|
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the group cookie for non-superadmins', 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}`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
|
||||||
|
const { app, config, root, defaultGroup } = await setup();
|
||||||
|
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
|
||||||
|
|
||||||
|
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||||
|
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||||
|
await inOther('POST', '/users', { email: 'u@example.com' });
|
||||||
|
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
|
||||||
|
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
|
||||||
|
|
||||||
|
expect((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('users and keys', () => {
|
||||||
|
it('enforces role rules', async () => {
|
||||||
|
const { app, root } = await setup();
|
||||||
|
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
|
||||||
|
await root('POST', '/users', { email: 'User@Example.com' });
|
||||||
|
const adm = admin(app, 'adm@example.com');
|
||||||
|
const user = admin(app, 'user@example.com');
|
||||||
|
|
||||||
|
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('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);
|
||||||
|
expect((await adm('DELETE', '/users/adm@example.com')).status).toBe(400);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin generates keys; users see their own keys with deep links', async () => {
|
||||||
|
const { app, root } = await setup();
|
||||||
|
await root('POST', '/users', { email: 'user@example.com' });
|
||||||
|
const created = await root('POST', '/users/user@example.com/keys', { label: 'Phone' });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const key = created.body as AccessKeyView;
|
||||||
|
expect(key.key).toMatch(/^[A-Za-z0-9]{32}$/);
|
||||||
|
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://cards.example.com/api')}&key=${key.key}`);
|
||||||
|
|
||||||
|
const user = admin(app, 'user@example.com');
|
||||||
|
expect((await user('POST', '/users/user@example.com/keys', {})).status).toBe(403);
|
||||||
|
expect((await user('GET', '/my-keys')).body).toEqual([key]);
|
||||||
|
|
||||||
|
const listed = (await root('GET', '/users')).body as UserView[];
|
||||||
|
expect(listed.find((u) => u.email === 'user@example.com')?.keys).toEqual([key]);
|
||||||
|
|
||||||
|
expect((await root('DELETE', `/users/user@example.com/keys/${key.id}`)).status).toBe(204);
|
||||||
|
expect((await user('GET', '/my-keys')).body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores keys encrypted', async () => {
|
||||||
|
const { app, config, root } = await setup();
|
||||||
|
const key = (await root('POST', `/users/${SUPERADMIN}/keys`, {})).body as AccessKeyView;
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
const yaml = await readFile(path.join(config.dataDir, 'users.yaml'), 'utf8');
|
||||||
|
expect(yaml).not.toContain(key.key);
|
||||||
|
expect(yaml).toContain('encrypted:');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cards', () => {
|
||||||
|
it('supports CRUD, soft delete, restore and purge', async () => {
|
||||||
|
const { root } = await setup();
|
||||||
|
const created = await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: '4600000000000', codeType: 'EAN13' });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const card = created.body as Card;
|
||||||
|
expect(card).toMatchObject({ name: 'Shop', imagesVersion: 0, deleted: false, logo: null, images: [] });
|
||||||
|
|
||||||
|
expect((await root('POST', '/cards', { name: 'x', color: 'red', code: '1', codeType: 'EAN13' })).status).toBe(400);
|
||||||
|
expect((await root('POST', '/cards', { name: 'x', color: '#000000', code: '1', codeType: 'NOPE' })).status).toBe(400);
|
||||||
|
|
||||||
|
const patched = await root('PATCH', `/cards/${card.id}`, { name: 'Shop 2' });
|
||||||
|
expect((patched.body as Card).name).toBe('Shop 2');
|
||||||
|
|
||||||
|
const deleted = await root('DELETE', `/cards/${card.id}`);
|
||||||
|
expect((deleted.body as Card).deleted).toBe(true);
|
||||||
|
expect(((await root('GET', '/cards')).body as Card[])[0]?.deleted).toBe(true);
|
||||||
|
|
||||||
|
const restored = await root('PATCH', `/cards/${card.id}`, { deleted: false });
|
||||||
|
expect((restored.body as Card).deleted).toBe(false);
|
||||||
|
|
||||||
|
await root('DELETE', `/cards/${card.id}`);
|
||||||
|
expect((await root('DELETE', `/cards/${card.id}`)).status).toBe(204);
|
||||||
|
expect((await root('GET', '/cards')).body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manages logo and images with version bumps', async () => {
|
||||||
|
const { app, root } = await setup();
|
||||||
|
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
|
||||||
|
const png = Buffer.from('fake-png');
|
||||||
|
const upload = (url: string, method: 'PUT' | 'POST', parts: Parameters<typeof multipart>[0]) => {
|
||||||
|
const { payload, headers } = multipart(parts);
|
||||||
|
return app.inject({ method, url: `/admin${url}`, payload, headers: { ...headers, 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const withLogo = (await upload(`/cards/${card.id}/logo`, 'PUT', [{ name: 'file', filename: 'l.png', mime: 'image/png', data: png }])).json() as Card;
|
||||||
|
expect(withLogo.logo?.mime).toBe('image/png');
|
||||||
|
expect(withLogo.imagesVersion).toBe(1);
|
||||||
|
|
||||||
|
const logoRes = await root('GET', `/cards/${card.id}/logo`);
|
||||||
|
expect(logoRes.res.rawPayload).toEqual(png);
|
||||||
|
expect(logoRes.res.headers['content-type']).toBe('image/png');
|
||||||
|
expect(logoRes.res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
|
||||||
|
const withImages = (
|
||||||
|
await upload(`/cards/${card.id}/images`, 'POST', [
|
||||||
|
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
|
||||||
|
{ name: 'file', filename: 'b.html', mime: 'text/html', data: Buffer.from('<script>') },
|
||||||
|
])
|
||||||
|
).json() as Card;
|
||||||
|
expect(withImages.images).toHaveLength(2);
|
||||||
|
expect(withImages.imagesVersion).toBe(2);
|
||||||
|
|
||||||
|
const html = await root('GET', `/cards/${card.id}/images/${withImages.images[1]!.id}`);
|
||||||
|
expect(html.res.headers['content-type']).toBe('application/octet-stream');
|
||||||
|
|
||||||
|
const removed = (await root('DELETE', `/cards/${card.id}/images/${withImages.images[0]!.id}`)).body as Card;
|
||||||
|
expect(removed.images).toHaveLength(1);
|
||||||
|
expect(removed.imagesVersion).toBe(3);
|
||||||
|
|
||||||
|
const noLogo = (await root('DELETE', `/cards/${card.id}/logo`)).body as Card;
|
||||||
|
expect(noLogo.logo).toBeNull();
|
||||||
|
expect(noLogo.imagesVersion).toBe(4);
|
||||||
|
expect((await root('GET', `/cards/${card.id}/logo`)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates cards between groups', async () => {
|
||||||
|
const { app, root } = await setup();
|
||||||
|
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
|
||||||
|
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
|
||||||
|
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
|
||||||
|
expect((await inOther('GET', '/cards')).body).toEqual([]);
|
||||||
|
expect((await inOther('PATCH', `/cards/${card.id}`, { name: 'hack' })).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AccessKeyView, Card, Group } from '@cardy/shared';
|
||||||
|
import { SUPERADMIN, multipart, testApp } from './helpers.js';
|
||||||
|
|
||||||
|
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const { app } = await testApp();
|
||||||
|
const key = (
|
||||||
|
await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: adminHeaders, payload: {} })
|
||||||
|
).json() as AccessKeyView;
|
||||||
|
return { app, key: key.key, keyId: key.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function client(app: FastifyInstance, key: string) {
|
||||||
|
return (method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, parts?: Parameters<typeof multipart>[0]) => {
|
||||||
|
const body = parts ? multipart(parts) : undefined;
|
||||||
|
return app.inject({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: { authorization: `Bearer ${key}`, ...body?.headers },
|
||||||
|
...(body ? { payload: body.payload } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardParts = (overrides: Record<string, string> = {}) =>
|
||||||
|
Object.entries({ name: 'Shop', color: '#123456', code: '4600000000000', codeType: 'EAN13', ...overrides }).map(
|
||||||
|
([name, value]) => ({ name, value }),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('client API', () => {
|
||||||
|
it('authenticates by bearer key', async () => {
|
||||||
|
const { app, key } = await setup();
|
||||||
|
const ok = await client(app, key)('GET', '/auth');
|
||||||
|
expect(ok.statusCode).toBe(200);
|
||||||
|
expect(ok.json()).toMatchObject({ email: SUPERADMIN, group: { name: 'default' } });
|
||||||
|
expect((await client(app, 'wrong')('GET', '/auth')).statusCode).toBe(401);
|
||||||
|
expect((await app.inject({ url: '/cards' })).statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops accepting a key right after it is deleted', async () => {
|
||||||
|
const { app, key, keyId } = await setup();
|
||||||
|
await app.inject({ method: 'DELETE', url: `/admin/users/${SUPERADMIN}/keys/${keyId}`, headers: adminHeaders });
|
||||||
|
expect((await client(app, key)('GET', '/auth')).statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers CORS preflight without a key', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'OPTIONS',
|
||||||
|
url: '/cards',
|
||||||
|
headers: { origin: 'https://localhost', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBeLessThan(300);
|
||||||
|
expect(res.headers['access-control-allow-origin']).toBe('https://localhost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates, edits and deletes cards with logo and images', async () => {
|
||||||
|
const { app, key } = await setup();
|
||||||
|
const api = client(app, key);
|
||||||
|
|
||||||
|
const createdRes = await api('POST', '/cards', [
|
||||||
|
...cardParts(),
|
||||||
|
{ name: 'logo', filename: 'logo.webp', mime: 'image/webp', data: Buffer.from('logo') },
|
||||||
|
]);
|
||||||
|
expect(createdRes.statusCode).toBe(201);
|
||||||
|
const created = createdRes.json() as Card;
|
||||||
|
expect(created.id).toBeTruthy();
|
||||||
|
expect(created.logo).not.toBeNull();
|
||||||
|
expect(created.imagesVersion).toBe(1);
|
||||||
|
|
||||||
|
expect((await api('POST', '/cards', cardParts({ codeType: 'BAD' }))).statusCode).toBe(400);
|
||||||
|
|
||||||
|
const edited = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Shop 2' }])).json() as Card;
|
||||||
|
expect(edited).toMatchObject({ name: 'Shop 2', code: '4600000000000', imagesVersion: 1 });
|
||||||
|
|
||||||
|
const noLogo = (await api('PUT', `/cards/${created.id}`, [{ name: 'removeLogo', value: 'true' }])).json() as Card;
|
||||||
|
expect(noLogo.logo).toBeNull();
|
||||||
|
expect(noLogo.imagesVersion).toBe(2);
|
||||||
|
|
||||||
|
const withImages = (
|
||||||
|
await api('POST', `/cards/${created.id}/images`, [
|
||||||
|
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
|
||||||
|
{ name: 'file', filename: 'b.webp', mime: 'image/webp', data: Buffer.from('b') },
|
||||||
|
])
|
||||||
|
).json() as Card;
|
||||||
|
expect(withImages.images).toHaveLength(2);
|
||||||
|
expect(withImages.imagesVersion).toBe(3);
|
||||||
|
|
||||||
|
const image = await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`);
|
||||||
|
expect(image.body).toBe('b');
|
||||||
|
|
||||||
|
const afterRemove = (await api('DELETE', `/cards/${created.id}/images/${withImages.images[0]!.id}`)).json() as Card;
|
||||||
|
expect(afterRemove.images.map((i) => i.id)).toEqual([withImages.images[1]!.id]);
|
||||||
|
|
||||||
|
expect((await api('GET', '/cards')).json()).toHaveLength(1);
|
||||||
|
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(204);
|
||||||
|
|
||||||
|
// Deleted cards are invisible to the client API but kept for admins.
|
||||||
|
expect((await api('GET', '/cards')).json()).toEqual([]);
|
||||||
|
expect((await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`)).statusCode).toBe(404);
|
||||||
|
expect((await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'x' }])).statusCode).toBe(404);
|
||||||
|
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(404);
|
||||||
|
const adminCards = (await app.inject({ url: '/admin/cards', headers: adminHeaders })).json() as Card[];
|
||||||
|
expect(adminCards[0]).toMatchObject({ id: created.id, deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes cards to the key owner group', async () => {
|
||||||
|
const { app, key } = await setup();
|
||||||
|
const other = (
|
||||||
|
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
|
||||||
|
).json() as Group;
|
||||||
|
const otherHeaders = { ...adminHeaders, cookie: `cardy_group=${other.id}` };
|
||||||
|
await app.inject({ method: 'POST', url: '/admin/users', headers: otherHeaders, payload: { email: 'o@example.com' } });
|
||||||
|
const otherKey = (
|
||||||
|
await app.inject({ method: 'POST', url: '/admin/users/o@example.com/keys', headers: otherHeaders, payload: {} })
|
||||||
|
).json() as AccessKeyView;
|
||||||
|
|
||||||
|
const mine = (await client(app, key)('POST', '/cards', cardParts())).json() as Card;
|
||||||
|
const theirs = client(app, otherKey.key);
|
||||||
|
expect((await theirs('GET', '/cards')).json()).toEqual([]);
|
||||||
|
expect((await theirs('DELETE', `/cards/${mine.id}`)).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');
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json()).toEqual({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AccessKeyView } from '@cardy/shared';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { SUPERADMIN, testApp } from './helpers.js';
|
||||||
|
|
||||||
|
const baseEnv = {
|
||||||
|
KEY_ENCRYPTION_SECRET: randomBytes(32).toString('base64'),
|
||||||
|
SUPERADMIN_EMAIL: 'Root@Example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('applies defaults', () => {
|
||||||
|
const config = loadConfig(baseEnv);
|
||||||
|
expect(config).toMatchObject({ superadminEmail: 'root@example.com', superadminGroup: 'default', host: '127.0.0.1', port: 10002 });
|
||||||
|
expect(config.publicApiUrl).toBeUndefined();
|
||||||
|
expect(config.adminProxySecret).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates values', () => {
|
||||||
|
expect(loadConfig({ ...baseEnv, PUBLIC_API_URL: 'https://dev.aggtaa.com/api/' }).publicApiUrl).toBe('https://dev.aggtaa.com/api');
|
||||||
|
expect(() => loadConfig({ ...baseEnv, PUBLIC_API_URL: 'nope' })).toThrow();
|
||||||
|
expect(() => loadConfig({ ...baseEnv, SUPERADMIN_EMAIL: '' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reverse proxy integration', () => {
|
||||||
|
const proxied = {
|
||||||
|
'remote-email': SUPERADMIN,
|
||||||
|
'x-cardy-admin': '1',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
'x-forwarded-host': 'dev.aggtaa.com',
|
||||||
|
'x-forwarded-prefix': '/api',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('derives the deep link server and cookie path from X-Forwarded-* headers', async () => {
|
||||||
|
const { app } = await testApp({ publicApiUrl: undefined });
|
||||||
|
const key = (await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: proxied, payload: {} })).json() as AccessKeyView;
|
||||||
|
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://dev.aggtaa.com/api')}&key=${key.key}`);
|
||||||
|
|
||||||
|
const me = await app.inject({ url: '/admin/me', headers: proxied });
|
||||||
|
const cookie = String(me.headers['set-cookie']);
|
||||||
|
expect(cookie).toMatch(/Path=\/api\/admin/);
|
||||||
|
expect(cookie).toMatch(/Secure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a malformed X-Forwarded-Prefix', async () => {
|
||||||
|
const { app } = await testApp({ publicApiUrl: undefined });
|
||||||
|
const key = (
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/users/${SUPERADMIN}/keys`,
|
||||||
|
headers: { ...proxied, 'x-forwarded-prefix': '/api"><script>' },
|
||||||
|
payload: {},
|
||||||
|
})
|
||||||
|
).json() as AccessKeyView;
|
||||||
|
expect(key.deepLink).toContain(`server=${encodeURIComponent('https://dev.aggtaa.com')}&key=`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects admin requests without the proxy secret when configured', async () => {
|
||||||
|
const { app } = await testApp({ adminProxySecret: 's3cret' });
|
||||||
|
const headers = { 'remote-email': SUPERADMIN };
|
||||||
|
expect((await app.inject({ url: '/admin/me', headers })).statusCode).toBe(401);
|
||||||
|
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 'wrong' } })).statusCode).toBe(401);
|
||||||
|
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 's3cret' } })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not answer CORS preflight for admin paths', async () => {
|
||||||
|
const { app } = await testApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'OPTIONS',
|
||||||
|
url: '/admin/me',
|
||||||
|
headers: { origin: 'https://evil.example', 'access-control-request-method': 'GET' },
|
||||||
|
});
|
||||||
|
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
import { buildApp } from '../src/app.js';
|
||||||
|
import type { Config } from '../src/config.js';
|
||||||
|
|
||||||
|
export const SUPERADMIN = 'root@example.com';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function tempDir(): Promise<string> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'cardy-test-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConfig(overrides: Partial<Config> = {}): Promise<Config> {
|
||||||
|
return {
|
||||||
|
dataDir: await tempDir(),
|
||||||
|
keySecret: randomBytes(32),
|
||||||
|
superadminEmail: SUPERADMIN,
|
||||||
|
superadminGroup: 'default',
|
||||||
|
publicApiUrl: 'https://cards.example.com/api',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testApp(overrides: Partial<Config> = {}) {
|
||||||
|
const config = await testConfig(overrides);
|
||||||
|
const app = await buildApp(config);
|
||||||
|
return { app, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function multipart(parts: Array<{ name: string; value: string } | { name: string; filename: string; mime: string; data: Buffer }>) {
|
||||||
|
const boundary = `----cardy${randomBytes(8).toString('hex')}`;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
let head = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`;
|
||||||
|
if ('filename' in part) head += `; filename="${part.filename}"\r\nContent-Type: ${part.mime}`;
|
||||||
|
chunks.push(Buffer.from(`${head}\r\n\r\n`), 'data' in part ? part.data : Buffer.from(part.value), Buffer.from('\r\n'));
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
||||||
|
return { payload: Buffer.concat(chunks), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseKeySecret } from '../src/config.js';
|
||||||
|
import { ACCESS_KEY_LENGTH, decryptSecret, encryptSecret, generateAccessKey } from '../src/crypto.js';
|
||||||
|
import { YamlFile } from '../src/yamlFile.js';
|
||||||
|
import { tempDir } from './helpers.js';
|
||||||
|
|
||||||
|
describe('crypto', () => {
|
||||||
|
it('generates 32-char alphanumeric keys', () => {
|
||||||
|
const keys = new Set(Array.from({ length: 100 }, generateAccessKey));
|
||||||
|
expect(keys.size).toBe(100);
|
||||||
|
for (const key of keys) expect(key).toMatch(new RegExp(`^[A-Za-z0-9]{${ACCESS_KEY_LENGTH}}$`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips with AES-256-GCM using a random IV', () => {
|
||||||
|
const secret = randomBytes(32);
|
||||||
|
const a = encryptSecret('hello', secret);
|
||||||
|
const b = encryptSecret('hello', secret);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(decryptSecret(a, secret)).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong secret or tampered data', () => {
|
||||||
|
const secret = randomBytes(32);
|
||||||
|
const encrypted = encryptSecret('hello', secret);
|
||||||
|
expect(() => decryptSecret(encrypted, randomBytes(32))).toThrow();
|
||||||
|
const buf = Buffer.from(encrypted, 'base64');
|
||||||
|
buf[buf.length - 1]! ^= 1;
|
||||||
|
expect(() => decryptSecret(buf.toString('base64'), secret)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates KEY_ENCRYPTION_SECRET', () => {
|
||||||
|
expect(parseKeySecret(randomBytes(32).toString('base64'))).toHaveLength(32);
|
||||||
|
expect(() => parseKeySecret(randomBytes(16).toString('base64'))).toThrow(/32 bytes/);
|
||||||
|
expect(() => parseKeySecret(undefined)).toThrow(/required/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('YamlFile', () => {
|
||||||
|
const make = (file: string) =>
|
||||||
|
new YamlFile<{ items: number[] }>(file, (raw) => (raw as { items: number[] }) ?? { items: [] }, () => ({ items: [] }));
|
||||||
|
|
||||||
|
it('returns empty data for a missing file and persists updates', async () => {
|
||||||
|
const file = path.join(await tempDir(), 'sub', 'data.yaml');
|
||||||
|
const store = make(file);
|
||||||
|
expect(await store.read()).toEqual({ items: [] });
|
||||||
|
await store.update((d) => d.items.push(1));
|
||||||
|
expect(await make(file).read()).toEqual({ items: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent updates', async () => {
|
||||||
|
const store = make(path.join(await tempDir(), 'data.yaml'));
|
||||||
|
await Promise.all(Array.from({ length: 20 }, (_, i) => store.update((d) => d.items.push(i))));
|
||||||
|
expect((await store.read()).items).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write when the update throws', async () => {
|
||||||
|
const file = path.join(await tempDir(), 'data.yaml');
|
||||||
|
const store = make(file);
|
||||||
|
await store.update((d) => d.items.push(1));
|
||||||
|
await expect(
|
||||||
|
store.update((d) => {
|
||||||
|
d.items.push(2);
|
||||||
|
throw new Error('boom');
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
expect((await store.read()).items).toEqual([1]);
|
||||||
|
expect(await readFile(file, 'utf8')).not.toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks up manual edits', async () => {
|
||||||
|
const file = path.join(await tempDir(), 'data.yaml');
|
||||||
|
const store = make(file);
|
||||||
|
await store.update((d) => d.items.push(1));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
await writeFile(file, 'items: [7, 8]\n');
|
||||||
|
expect((await store.read()).items).toEqual([7, 8]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Copy to .env and fill in.
|
||||||
|
|
||||||
|
# Public host names the dev/preview server answers to (comma-separated).
|
||||||
|
ALLOWED_HOSTS=dev.aggtaa.com
|
||||||
|
|
||||||
|
# ---- Local development without the reverse proxy only ----
|
||||||
|
# The admin UI calls /api/admin/*. Behind the real proxy these requests never reach this server.
|
||||||
|
# Locally, Vite emulates the proxy: forwards /api/* to server-be with /api stripped.
|
||||||
|
# BACKEND_URL=http://127.0.0.1:10002
|
||||||
|
# ADMIN_PROXY_SECRET=
|
||||||
|
# Fakes the Remote-Email header set by Authelia. NEVER set it behind the real proxy.
|
||||||
|
# DEV_REMOTE_EMAIL=
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Cardy Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@cardy/server-fe",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "vite build && vite preview",
|
||||||
|
"typecheck": "tsc -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cardy/shared": "workspace:*",
|
||||||
|
"bwip-js": "^4.11.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { MeResponse } from '@cardy/shared';
|
||||||
|
import { api, ApiError } from './api.js';
|
||||||
|
import { ErrorText, useLoad } from './components.js';
|
||||||
|
import { CardEditorPage } from './pages/CardEditor.js';
|
||||||
|
import { CardsPage } from './pages/Cards.js';
|
||||||
|
import { GroupsPage } from './pages/Groups.js';
|
||||||
|
import { MyKeysPage } from './pages/MyKeys.js';
|
||||||
|
import { UsersPage } from './pages/Users.js';
|
||||||
|
import { linkClick, navigate, usePath } from './router.js';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const me = useLoad(api.me);
|
||||||
|
// Bumped when the superadmin switches groups so pages reload their data.
|
||||||
|
const [groupEpoch, setGroupEpoch] = useState(0);
|
||||||
|
|
||||||
|
if (me.loading && !me.data) return <div className="center muted">Loading…</div>;
|
||||||
|
if (!me.data) {
|
||||||
|
return (
|
||||||
|
<div className="center">
|
||||||
|
<h1>Cardy</h1>
|
||||||
|
<p className="error">{me.error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Shell me={me.data} groupEpoch={groupEpoch} onGroupChanged={async () => { await me.reload(); setGroupEpoch((e) => e + 1); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise<void> }) {
|
||||||
|
const path = usePath();
|
||||||
|
const isAdmin = me.role !== 'user';
|
||||||
|
const isSuper = me.role === 'superadmin';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []),
|
||||||
|
...(isSuper ? [{ path: '/groups', label: 'Groups' }] : []),
|
||||||
|
{ path: '/keys', label: 'My keys' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let page = null;
|
||||||
|
const cardMatch = /^\/cards\/([^/]+)$/.exec(path);
|
||||||
|
if (isAdmin && cardMatch) page = <CardEditorPage cardId={cardMatch[1]!} />;
|
||||||
|
else if (isAdmin && path === '/cards') page = <CardsPage />;
|
||||||
|
else if (isAdmin && path === '/users') page = <UsersPage me={me} />;
|
||||||
|
else if (isSuper && path === '/groups') page = <GroupsPage me={me} onGroupsChanged={onGroupChanged} />;
|
||||||
|
else if (path === '/keys') page = <MyKeysPage me={me} />;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!page) navigate(isAdmin ? '/cards' : '/keys', { replace: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">Cardy</div>
|
||||||
|
<nav>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<a key={t.path} href={t.path} onClick={linkClick} className={path.startsWith(t.path) ? 'active' : ''}>
|
||||||
|
{t.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="topbar-right">
|
||||||
|
{isSuper ? <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>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main key={groupEpoch}>{page}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupSwitcher({ me, onChanged, epoch }: { me: MeResponse; onChanged: () => Promise<void>; epoch: number }) {
|
||||||
|
const groups = useLoad(api.groups, [epoch]);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
return (
|
||||||
|
<label className="group-switcher">
|
||||||
|
<span className="muted small">Group</span>
|
||||||
|
<select
|
||||||
|
value={me.currentGroup.id}
|
||||||
|
onChange={async (e) => {
|
||||||
|
try {
|
||||||
|
await api.setCurrentGroup(e.target.value);
|
||||||
|
setError(undefined);
|
||||||
|
await onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(groups.data ?? [me.currentGroup]).map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ErrorText error={error ?? groups.error} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { AccessKeyView, Card, CardFields, CardPatch, Group, MeResponse, Role, UserView } from '@cardy/shared';
|
||||||
|
|
||||||
|
// Reverse proxy: /api/admin/* goes through Authelia to server-be.
|
||||||
|
const BASE = '/api/admin';
|
||||||
|
const RELOAD_MARK = 'cardy:auth-reload';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 401 without Cardy's JSON body comes from Authelia (session expired): reload the page so
|
||||||
|
* Authelia shows its login form. Guarded against reload loops.
|
||||||
|
*/
|
||||||
|
function reloadForLogin(): boolean {
|
||||||
|
let last = 0;
|
||||||
|
try {
|
||||||
|
last = Number(sessionStorage.getItem(RELOAD_MARK) ?? 0);
|
||||||
|
if (Date.now() - last < 15_000) return false;
|
||||||
|
sessionStorage.setItem(RELOAD_MARK, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
// Accept/X-Requested-With make Authelia answer 401 instead of redirecting to its login page.
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
|
||||||
|
if (method !== 'GET') headers['X-Cardy-Admin'] = '1';
|
||||||
|
let payload: BodyInit | undefined;
|
||||||
|
if (body instanceof FormData) payload = body;
|
||||||
|
else if (body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
payload = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(BASE + path, { method, headers, body: payload, credentials: 'same-origin' });
|
||||||
|
if (!res.ok) {
|
||||||
|
let cardyError: string | undefined;
|
||||||
|
try {
|
||||||
|
cardyError = ((await res.json()) as { error?: string }).error;
|
||||||
|
} catch {
|
||||||
|
// not JSON: the response came from the proxy or Authelia
|
||||||
|
}
|
||||||
|
if (res.status === 401 && cardyError === undefined && reloadForLogin()) {
|
||||||
|
await new Promise(() => undefined); // page is reloading
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, cardyError ?? `${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return (res.status === 204 ? undefined : await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = encodeURIComponent;
|
||||||
|
|
||||||
|
function files(list: File[]): FormData {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const f of list) form.append('file', f, f.name);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => request<MeResponse>('GET', '/me'),
|
||||||
|
myKeys: () => request<AccessKeyView[]>('GET', '/my-keys'),
|
||||||
|
|
||||||
|
groups: () => request<Group[]>('GET', '/groups'),
|
||||||
|
createGroup: (name: string) => request<Group>('POST', '/groups', { name }),
|
||||||
|
renameGroup: (id: string, name: string) => request<Group>('PATCH', `/groups/${enc(id)}`, { name }),
|
||||||
|
deleteGroup: (id: string) => request<void>('DELETE', `/groups/${enc(id)}`),
|
||||||
|
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 }) =>
|
||||||
|
request<UserView>('PATCH', `/users/${enc(email)}`, patch),
|
||||||
|
deleteUser: (email: string) => request<void>('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)}`),
|
||||||
|
|
||||||
|
cards: () => request<Card[]>('GET', '/cards'),
|
||||||
|
createCard: (fields: CardFields) => request<Card>('POST', '/cards', fields),
|
||||||
|
updateCard: (id: string, patch: CardPatch) => request<Card>('PATCH', `/cards/${enc(id)}`, patch),
|
||||||
|
/** Marks as deleted; for an already deleted card removes it permanently (returns undefined). */
|
||||||
|
deleteCard: (id: string) => request<Card | undefined>('DELETE', `/cards/${enc(id)}`),
|
||||||
|
setLogo: (id: string, file: File) => request<Card>('PUT', `/cards/${enc(id)}/logo`, files([file])),
|
||||||
|
removeLogo: (id: string) => request<Card>('DELETE', `/cards/${enc(id)}/logo`),
|
||||||
|
addImages: (id: string, list: File[]) => request<Card>('POST', `/cards/${enc(id)}/images`, files(list)),
|
||||||
|
removeImage: (id: string, imageId: string) => request<Card>('DELETE', `/cards/${enc(id)}/images/${enc(imageId)}`),
|
||||||
|
|
||||||
|
logoUrl: (card: Card) => `${BASE}/cards/${enc(card.id)}/logo?v=${card.imagesVersion}`,
|
||||||
|
imageUrl: (card: Card, imageId: string) => `${BASE}/cards/${enc(card.id)}/images/${enc(imageId)}`,
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
BWIP_BCID,
|
||||||
|
TWO_DIMENSIONAL_CODE_TYPES,
|
||||||
|
cardAbbreviation,
|
||||||
|
textColorOn,
|
||||||
|
type AccessKeyView,
|
||||||
|
type Card,
|
||||||
|
type CodeType,
|
||||||
|
} from '@cardy/shared';
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
/** Loads data and exposes a reload function; errors are reported via `error`. */
|
||||||
|
export function useLoad<T>(load: () => Promise<T>, deps: unknown[] = []) {
|
||||||
|
const [data, setData] = useState<T>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const run = useCallback(load, deps);
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setData(await run());
|
||||||
|
setError(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [run]);
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
return { data, setData, error, loading, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps an async action with a busy flag and error message. */
|
||||||
|
export function useAction() {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return { busy, error, setError, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorText({ error }: { error?: string }) {
|
||||||
|
return error ? <p className="error">{error}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<header className="modal-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button className="ghost" onClick={onClose} aria-label="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied' : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyQrModal({ keyView, owner, onClose }: { keyView: AccessKeyView; owner: string; onClose: () => void }) {
|
||||||
|
const [svg, setSvg] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
QRCode.toString(keyView.deepLink, { type: 'svg', margin: 2, errorCorrectionLevel: 'M' }).then(setSvg);
|
||||||
|
}, [keyView.deepLink]);
|
||||||
|
return (
|
||||||
|
<Modal title={keyView.label ? `Key “${keyView.label}”` : 'Access key'} onClose={onClose}>
|
||||||
|
<p className="muted">
|
||||||
|
{owner} · created {formatDate(keyView.createdAt)}
|
||||||
|
</p>
|
||||||
|
<div className="qr" dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
|
<p className="muted small">Scan with the Cardy app or the phone camera.</p>
|
||||||
|
<div className="field">
|
||||||
|
<label>Key</label>
|
||||||
|
<div className="row nowrap">
|
||||||
|
<code className="mono grow break">{keyView.key}</code>
|
||||||
|
<CopyButton text={keyView.key} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Login link</label>
|
||||||
|
<div className="row nowrap">
|
||||||
|
<a className="mono grow break link-box" href={keyView.deepLink} title="Open in the Cardy app">
|
||||||
|
{keyView.deepLink}
|
||||||
|
</a>
|
||||||
|
<CopyButton text={keyView.deepLink} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bwip = typeof import('bwip-js/browser');
|
||||||
|
let bwipPromise: Promise<Bwip> | undefined;
|
||||||
|
|
||||||
|
export function CodePreview({ code, codeType }: { code: string; codeType: CodeType }) {
|
||||||
|
// bwip-js is ~1 MB, load it only when a code is shown.
|
||||||
|
const [bwipjs, setBwipjs] = useState<Bwip>();
|
||||||
|
useEffect(() => {
|
||||||
|
bwipPromise ??= import('bwip-js/browser').then((m) => (m as { default?: Bwip }).default ?? m);
|
||||||
|
void bwipPromise.then((b) => setBwipjs(() => b));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const result = useMemo(() => {
|
||||||
|
if (!code) return { error: 'Enter a code to see the preview' };
|
||||||
|
if (!bwipjs) return { error: 'Loading…' };
|
||||||
|
try {
|
||||||
|
const svg = bwipjs.toSVG({
|
||||||
|
bcid: BWIP_BCID[codeType],
|
||||||
|
text: code,
|
||||||
|
scale: 3,
|
||||||
|
...(TWO_DIMENSIONAL_CODE_TYPES.has(codeType) ? {} : { height: 14, includetext: true, textxalign: 'center' as const }),
|
||||||
|
paddingwidth: 4,
|
||||||
|
paddingheight: 4,
|
||||||
|
backgroundcolor: 'FFFFFF',
|
||||||
|
});
|
||||||
|
return { svg };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') };
|
||||||
|
}
|
||||||
|
}, [bwipjs, code, codeType]);
|
||||||
|
if ('error' in result) return <p className="code-preview-error">{result.error}</p>;
|
||||||
|
return <div className="code-preview" dangerouslySetInnerHTML={{ __html: result.svg }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardBadge({ card, size = 56 }: { card: Pick<Card, 'id' | 'name' | 'color' | 'logo' | 'imagesVersion'>; size?: number }) {
|
||||||
|
const style = { width: size, height: size, background: card.color, color: textColorOn(card.color) };
|
||||||
|
if (card.logo) {
|
||||||
|
return (
|
||||||
|
<span className="badge" style={style}>
|
||||||
|
<img src={api.logoUrl(card as Card)} alt="" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="badge" style={{ ...style, fontSize: size * 0.36 }}>
|
||||||
|
{cardAbbreviation(card.name)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilePicker({
|
||||||
|
label,
|
||||||
|
multiple,
|
||||||
|
disabled,
|
||||||
|
onFiles,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onFiles: (files: File[]) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="secondary" disabled={disabled} onClick={() => ref.current?.click()}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple={multiple}
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const list = [...(e.target.files ?? [])];
|
||||||
|
e.target.value = '';
|
||||||
|
if (list.length) onFiles(list);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App.js';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CODE_TYPES, CODE_TYPE_LABELS, cardFieldsSchema, randomCardColor, type Card, type CardFields } from '@cardy/shared';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { CardBadge, CodePreview, ErrorText, FilePicker, formatDate, useAction, useLoad } from '../components.js';
|
||||||
|
import { linkClick, navigate } from '../router.js';
|
||||||
|
|
||||||
|
export function CardEditorPage({ cardId }: { cardId: string }) {
|
||||||
|
const isNew = cardId === 'new';
|
||||||
|
const loaded = useLoad(async () => (isNew ? undefined : (await api.cards()).find((c) => c.id === cardId) ?? null), [cardId]);
|
||||||
|
|
||||||
|
if (loaded.loading && loaded.data === undefined && !isNew) return <p className="muted">Loading…</p>;
|
||||||
|
if (loaded.error) return <ErrorText error={loaded.error} />;
|
||||||
|
if (loaded.data === null) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p className="empty">Card not found.</p>
|
||||||
|
<a href="/cards" onClick={linkClick}>
|
||||||
|
Back to cards
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Editor key={cardId} initial={loaded.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({ initial }: { initial?: Card }) {
|
||||||
|
const [card, setCard] = useState<Card | undefined>(initial);
|
||||||
|
const [fields, setFields] = useState<CardFields>(
|
||||||
|
initial
|
||||||
|
? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType }
|
||||||
|
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13' },
|
||||||
|
);
|
||||||
|
const action = useAction();
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setSaved(false), [fields]);
|
||||||
|
|
||||||
|
const dirty = !card || card.name !== fields.name || card.color !== fields.color || card.code !== fields.code || card.codeType !== fields.codeType;
|
||||||
|
const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
const save = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const parsed = cardFieldsSchema.safeParse(fields);
|
||||||
|
if (!parsed.success) {
|
||||||
|
action.setError(parsed.error.issues.map((i) => i.message).join('; '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await action.run(() => (card ? api.updateCard(card.id, parsed.data) : api.createCard(parsed.data)));
|
||||||
|
if (!result) return;
|
||||||
|
setCard(result);
|
||||||
|
setSaved(true);
|
||||||
|
if (!card) navigate(`/cards/${result.id}`, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="editor">
|
||||||
|
<div className="row page-head">
|
||||||
|
<a href="/cards" onClick={linkClick} className="ghost-link">
|
||||||
|
← Cards
|
||||||
|
</a>
|
||||||
|
<h1 className="grow">{card ? card.name : 'New card'}</h1>
|
||||||
|
{card && <CardBadge card={{ ...card, ...fields }} size={48} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{card?.deleted && (
|
||||||
|
<div className="banner danger row">
|
||||||
|
<span className="grow">This card is deleted. Apps don't see it.</span>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={action.busy}
|
||||||
|
onClick={async () => {
|
||||||
|
const restored = await action.run(() => api.updateCard(card.id, { deleted: false }));
|
||||||
|
if (restored) setCard(restored);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="editor-grid">
|
||||||
|
<form className="panel" onSubmit={save}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="name">Name</label>
|
||||||
|
<input id="name" value={fields.name} onChange={(e) => set('name', e.target.value)} maxLength={200} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="color">Color</label>
|
||||||
|
<div className="row">
|
||||||
|
<input id="color" type="color" value={fields.color} onChange={(e) => set('color', e.target.value.toUpperCase())} />
|
||||||
|
<input
|
||||||
|
className="mono"
|
||||||
|
value={fields.color}
|
||||||
|
onChange={(e) => set('color', e.target.value)}
|
||||||
|
pattern="#[0-9a-fA-F]{6}"
|
||||||
|
size={8}
|
||||||
|
aria-label="Color hex"
|
||||||
|
/>
|
||||||
|
<button type="button" className="secondary" onClick={() => set('color', randomCardColor())}>
|
||||||
|
Random
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="codeType">Code type</label>
|
||||||
|
<select id="codeType" value={fields.codeType} onChange={(e) => set('codeType', e.target.value as CardFields['codeType'])}>
|
||||||
|
{CODE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{CODE_TYPE_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="code">Code</label>
|
||||||
|
<textarea id="code" className="mono" rows={2} value={fields.code} onChange={(e) => set('code', e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<CodePreview code={fields.code} codeType={fields.codeType} />
|
||||||
|
<div className="row">
|
||||||
|
<button type="submit" disabled={action.busy || (!dirty && !!card)}>
|
||||||
|
{card ? 'Save' : 'Create card'}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="muted small">Saved</span>}
|
||||||
|
<span className="grow" />
|
||||||
|
{card && <DeleteButtons card={card} onChange={setCard} />}
|
||||||
|
</div>
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
{card && (
|
||||||
|
<p className="muted small">
|
||||||
|
Created {formatDate(card.createdAt)} · updated {formatDate(card.updatedAt)} · images version {card.imagesVersion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{card ? <ImagesPanel card={card} onChange={setCard} /> : <div className="panel muted">Create the card to add a logo and images.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteButtons({ card, onChange }: { card: Card; onChange: (card: Card) => void }) {
|
||||||
|
const action = useAction();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
disabled={action.busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (card.deleted) {
|
||||||
|
if (!confirm(`Permanently delete "${card.name}" with all its images? This cannot be undone.`)) return;
|
||||||
|
if ((await action.run(async () => (await api.deleteCard(card.id), true))) !== undefined) navigate('/cards');
|
||||||
|
} else {
|
||||||
|
if (!confirm(`Delete "${card.name}"? It can be restored later.`)) return;
|
||||||
|
const deleted = await action.run(() => api.deleteCard(card.id));
|
||||||
|
if (deleted) onChange(deleted);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.deleted ? 'Delete permanently' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagesPanel({ card, onChange }: { card: Card; onChange: (card: Card) => void }) {
|
||||||
|
const action = useAction();
|
||||||
|
const apply = async (fn: () => Promise<Card>) => {
|
||||||
|
const updated = await action.run(fn);
|
||||||
|
if (updated) onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h2>Logo</h2>
|
||||||
|
<div className="row">
|
||||||
|
{card.logo ? <img className="logo-preview" src={api.logoUrl(card)} alt="Logo" /> : <span className="muted">No logo</span>}
|
||||||
|
<span className="grow" />
|
||||||
|
<FilePicker label={card.logo ? 'Replace' : 'Upload'} disabled={action.busy} onFiles={([f]) => f && apply(() => api.setLogo(card.id, f))} />
|
||||||
|
{card.logo && (
|
||||||
|
<button className="danger" disabled={action.busy} onClick={() => apply(() => api.removeLogo(card.id))}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Images</h2>
|
||||||
|
<div className="thumbs">
|
||||||
|
{card.images.map((img) => (
|
||||||
|
<figure key={img.id} className="thumb">
|
||||||
|
<a href={api.imageUrl(card, img.id)} target="_blank" rel="noreferrer">
|
||||||
|
<img src={api.imageUrl(card, img.id)} alt="" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="danger small"
|
||||||
|
disabled={action.busy}
|
||||||
|
onClick={() => confirm('Delete this image?') && apply(() => api.removeImage(card.id, img.id))}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
{card.images.length === 0 && <span className="muted">No images</span>}
|
||||||
|
</div>
|
||||||
|
<FilePicker label="Add images" multiple disabled={action.busy} onFiles={(files) => apply(() => api.addImages(card.id, files))} />
|
||||||
|
{action.busy && <span className="muted small"> Uploading…</span>}
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { CODE_TYPE_LABELS } from '@cardy/shared';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { CardBadge, ErrorText, useLoad } from '../components.js';
|
||||||
|
import { linkClick } from '../router.js';
|
||||||
|
|
||||||
|
export function CardsPage() {
|
||||||
|
const cards = useLoad(api.cards);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [showDeleted, setShowDeleted] = useState(false);
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
return (cards.data ?? [])
|
||||||
|
.filter((c) => showDeleted || !c.deleted)
|
||||||
|
.filter((c) => !q || c.name.toLowerCase().includes(q))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [cards.data, filter, showDeleted]);
|
||||||
|
const deletedCount = cards.data?.filter((c) => c.deleted).length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="row page-head">
|
||||||
|
<h1 className="grow">Cards</h1>
|
||||||
|
<a className="button" href="/cards/new" onClick={linkClick}>
|
||||||
|
New card
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<input className="grow" type="search" placeholder="Filter by name" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)} />
|
||||||
|
Show deleted ({deletedCount})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<ErrorText error={cards.error} />
|
||||||
|
{cards.data && visible.length === 0 && <p className="empty">{cards.data.length ? 'No matching cards.' : 'No cards yet.'}</p>}
|
||||||
|
<div className="card-grid">
|
||||||
|
{visible.map((c) => (
|
||||||
|
<a key={c.id} href={`/cards/${c.id}`} onClick={linkClick} className={`card-tile${c.deleted ? ' deleted' : ''}`}>
|
||||||
|
<CardBadge card={c} />
|
||||||
|
<div className="grow">
|
||||||
|
<div className="card-name">{c.name}</div>
|
||||||
|
<div className="muted small">
|
||||||
|
{CODE_TYPE_LABELS[c.codeType]}
|
||||||
|
{c.images.length > 0 && ` · ${c.images.length} image${c.images.length === 1 ? '' : 's'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.deleted && <span className="pill danger">deleted</span>}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Group, MeResponse } from '@cardy/shared';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { ErrorText, useAction, useLoad } from '../components.js';
|
||||||
|
|
||||||
|
export function GroupsPage({ me, onGroupsChanged }: { me: MeResponse; onGroupsChanged: () => Promise<void> }) {
|
||||||
|
const groups = useLoad(api.groups);
|
||||||
|
const action = useAction();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
const changed = async () => {
|
||||||
|
await groups.reload();
|
||||||
|
await onGroupsChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Groups</h1>
|
||||||
|
<form
|
||||||
|
className="row"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await action.run(() => api.createGroup(name.trim()))) {
|
||||||
|
setName('');
|
||||||
|
await changed();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input placeholder="New group name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
<button type="submit" disabled={action.busy || !name.trim()}>
|
||||||
|
Add group
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<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} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurrent: boolean; isOwn: boolean; onChanged: () => Promise<void> }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [name, setName] = useState(group.name);
|
||||||
|
const action = useAction();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="list-item">
|
||||||
|
{editing ? (
|
||||||
|
<form
|
||||||
|
className="row grow"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await action.run(() => api.renameGroup(group.id, name.trim()))) {
|
||||||
|
setEditing(false);
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input className="grow" value={name} onChange={(e) => setName(e.target.value)} autoFocus required />
|
||||||
|
<button type="submit" disabled={action.busy}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary" onClick={() => { setEditing(false); setName(group.name); }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grow">
|
||||||
|
{group.name}
|
||||||
|
{isCurrent && <span className="pill accent">current</span>}
|
||||||
|
{isOwn && <span className="pill">yours</span>}
|
||||||
|
</div>
|
||||||
|
<button className="secondary" onClick={() => api.setCurrentGroup(group.id).then(onChanged)} disabled={isCurrent}>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button className="secondary" onClick={() => setEditing(true)}>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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.`);
|
||||||
|
if (ok && (await action.run(async () => (await api.deleteGroup(group.id), true)))) await onChanged();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { AccessKeyView, MeResponse } from '@cardy/shared';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { ErrorText, KeyQrModal, formatDate, useLoad } from '../components.js';
|
||||||
|
|
||||||
|
export function MyKeysPage({ me }: { me: MeResponse }) {
|
||||||
|
const keys = useLoad(api.myKeys);
|
||||||
|
const [shown, setShown] = useState<AccessKeyView>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>My keys</h1>
|
||||||
|
<p className="muted">Scan a key's QR code with the Cardy app to sign in. Keys are issued by your group administrator.</p>
|
||||||
|
<ErrorText error={keys.error} />
|
||||||
|
{keys.data?.length === 0 && <p className="empty">You have no keys yet.</p>}
|
||||||
|
<ul className="list">
|
||||||
|
{keys.data?.map((k) => (
|
||||||
|
<li key={k.id} className="list-item">
|
||||||
|
<div className="grow">
|
||||||
|
<div>{k.label || 'Unnamed key'}</div>
|
||||||
|
<div className="muted small">
|
||||||
|
Created {formatDate(k.createdAt)} · <span className="mono">{k.key.slice(0, 4)}…{k.key.slice(-4)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShown(k)}>Show QR</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{shown && <KeyQrModal keyView={shown} owner={me.email} onClose={() => setShown(undefined)} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ROLES, type AccessKeyView, type Group, type MeResponse, type Role, 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} />
|
||||||
|
<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} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<void> }) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [role, setRole] = useState<Role>('user');
|
||||||
|
const action = useAction();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form
|
||||||
|
className="row"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await action.run(() => api.createUser(email.trim(), role))) {
|
||||||
|
setEmail('');
|
||||||
|
setRole('user');
|
||||||
|
await onAdded();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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) => (
|
||||||
|
<option key={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" disabled={action.busy}>
|
||||||
|
Add user
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({
|
||||||
|
user,
|
||||||
|
me,
|
||||||
|
roles,
|
||||||
|
groups,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
user: UserView;
|
||||||
|
me: MeResponse;
|
||||||
|
roles: Role[];
|
||||||
|
groups: Group[];
|
||||||
|
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 update = async (patch: { role?: Role; groupId?: string }) => {
|
||||||
|
if (await action.run(() => api.updateUser(user.email, patch))) await onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="list-item column">
|
||||||
|
<div className="row">
|
||||||
|
<div className="grow">
|
||||||
|
<div>
|
||||||
|
{user.email}
|
||||||
|
{isSelf && <span className="pill">you</span>}
|
||||||
|
</div>
|
||||||
|
<div className="muted small">
|
||||||
|
{user.keys.length} key{user.keys.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={user.role}
|
||||||
|
disabled={!canManage || isSelf || action.busy}
|
||||||
|
onChange={(e) => update({ role: e.target.value as Role })}
|
||||||
|
aria-label="Role"
|
||||||
|
>
|
||||||
|
{(roles.includes(user.role) ? roles : [...roles, user.role]).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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
{expanded && <UserKeys user={user} onChanged={onChanged} />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserKeys({ user, onChanged }: { user: UserView; onChanged: () => Promise<void> }) {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [shown, setShown] = useState<AccessKeyView>();
|
||||||
|
const action = useAction();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subpanel">
|
||||||
|
{user.keys.length === 0 && <p className="muted small">No keys.</p>}
|
||||||
|
<ul className="list compact">
|
||||||
|
{user.keys.map((k) => (
|
||||||
|
<li key={k.id} className="list-item">
|
||||||
|
<div className="grow">
|
||||||
|
<div>{k.label || 'Unnamed key'}</div>
|
||||||
|
<div className="muted small">
|
||||||
|
Created {formatDate(k.createdAt)} · <span className="mono">{k.key.slice(0, 4)}…{k.key.slice(-4)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="secondary" onClick={() => setShown(k)}>
|
||||||
|
Show QR
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
disabled={action.busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('Delete this key? Apps signed in with it stop working immediately.')) return;
|
||||||
|
if (await action.run(async () => (await api.deleteKey(user.email, k.id), true))) await onChanged();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form
|
||||||
|
className="row"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const created = await action.run(() => api.createKey(user.email, label.trim()));
|
||||||
|
if (created) {
|
||||||
|
setLabel('');
|
||||||
|
setShown(created);
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input placeholder="Label (optional), e.g. Pixel 8" value={label} onChange={(e) => setLabel(e.target.value)} maxLength={100} />
|
||||||
|
<button type="submit" disabled={action.busy}>
|
||||||
|
Generate key
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ErrorText error={action.error} />
|
||||||
|
{shown && <KeyQrModal keyView={shown} owner={user.email} onClose={() => setShown(undefined)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function navigate(path: string, { replace = false } = {}): void {
|
||||||
|
if (path === location.pathname) return;
|
||||||
|
if (replace) history.replaceState(null, '', path);
|
||||||
|
else history.pushState(null, '', path);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => listeners.forEach((l) => l()));
|
||||||
|
|
||||||
|
export function usePath(): string {
|
||||||
|
const [path, setPath] = useState(location.pathname);
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => setPath(location.pathname);
|
||||||
|
listeners.add(listener);
|
||||||
|
listener(); // catch navigations that happened before subscribing
|
||||||
|
return () => void listeners.delete(listener);
|
||||||
|
}, []);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click handler for <a href> that navigates without a reload (keeps ctrl/middle-click working). */
|
||||||
|
export function linkClick(e: React.MouseEvent<HTMLAnchorElement>): void {
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(e.currentTarget.getAttribute('href') ?? '/');
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
/* Dark theme only. */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0f1115;
|
||||||
|
--surface: #171a21;
|
||||||
|
--surface-2: #1f232c;
|
||||||
|
--border: #2c313c;
|
||||||
|
--text: #e6e8ec;
|
||||||
|
--muted: #8b93a3;
|
||||||
|
--accent: #5b9dff;
|
||||||
|
--accent-strong: #3d86f5;
|
||||||
|
--accent-text: #0b1220;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--danger-bg: #3a1b1f;
|
||||||
|
--radius: 10px;
|
||||||
|
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.45 var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.break {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 15vh auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- controls ---- */
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button,
|
||||||
|
.button {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 7px 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled),
|
||||||
|
.button:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.small {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-link {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.nowrap {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.nowrap > button {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.accent {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger-bg);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- layout ---- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section > .row {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpanel {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.danger {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- lists ---- */
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list.compact {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item.column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpanel .list-item {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- cards ---- */
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tile:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tile.deleted {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.editor-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Codes stay black on white so they remain scannable. */
|
||||||
|
.code-preview {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview svg {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview-error {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- modal ---- */
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(480px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
width: min(280px, 100%);
|
||||||
|
margin: 12px auto 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal code,
|
||||||
|
.modal .link-box {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .link-box {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .link-box:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig, loadEnv, type ProxyOptions } from 'vite';
|
||||||
|
|
||||||
|
// Behind the real reverse proxy /api never reaches this server. The /api proxy below only emulates
|
||||||
|
// that reverse proxy for local development without it (strips /api, sets X-Forwarded-Prefix).
|
||||||
|
//
|
||||||
|
// Env (server-fe/.env or process env):
|
||||||
|
// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com)
|
||||||
|
// BACKEND_URL local dev: server-be address (default http://127.0.0.1:10002)
|
||||||
|
// ADMIN_PROXY_SECRET local dev: sent as X-Cardy-Proxy-Secret, must match server-be
|
||||||
|
// DEV_REMOTE_EMAIL local dev: fakes the Remote-Email header normally set by Authelia
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const headers: Record<string, string> = { 'X-Forwarded-Prefix': '/api' };
|
||||||
|
if (env.ADMIN_PROXY_SECRET) headers['X-Cardy-Proxy-Secret'] = env.ADMIN_PROXY_SECRET;
|
||||||
|
if (env.DEV_REMOTE_EMAIL) {
|
||||||
|
headers['Remote-Email'] = env.DEV_REMOTE_EMAIL;
|
||||||
|
console.warn(`\n⚠ DEV_REMOTE_EMAIL is set: every request acts as ${env.DEV_REMOTE_EMAIL}. Never use it behind the real proxy.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy: Record<string, ProxyOptions> = {
|
||||||
|
'/api': {
|
||||||
|
target: env.BACKEND_URL || 'http://127.0.0.1:10002',
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '') || '/',
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const server = {
|
||||||
|
host: true,
|
||||||
|
port: 10001,
|
||||||
|
strictPort: true,
|
||||||
|
allowedHosts: (env.ALLOWED_HOSTS || 'dev.aggtaa.com').split(',').map((h) => h.trim()),
|
||||||
|
proxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
// bwip-js alone is ~1 MB; it is lazy-loaded.
|
||||||
|
build: { chunkSizeWarningLimit: 1100 },
|
||||||
|
server,
|
||||||
|
preview: server,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@cardy/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vitest": "^5.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CARD_PALETTE, cardAbbreviation, randomCardColor, textColorOn } from './cardDisplay.js';
|
||||||
|
|
||||||
|
describe('card display helpers', () => {
|
||||||
|
it('abbreviates names', () => {
|
||||||
|
expect(cardAbbreviation('Magnit')).toBe('MA');
|
||||||
|
expect(cardAbbreviation('Lenta Plus Card')).toBe('LP');
|
||||||
|
expect(cardAbbreviation(' ')).toBe('?');
|
||||||
|
expect(cardAbbreviation('пятёрочка')).toBe('ПЯ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks palette colors and readable text colors', () => {
|
||||||
|
expect(CARD_PALETTE).toContain(randomCardColor());
|
||||||
|
expect(textColorOn('#FFFFFF')).toBe('#000000');
|
||||||
|
expect(textColorOn('#123456')).toBe('#FFFFFF');
|
||||||
|
for (const c of CARD_PALETTE) expect(textColorOn(c)).toBe('#FFFFFF');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Colors that keep white text readable. */
|
||||||
|
export const CARD_PALETTE = [
|
||||||
|
'#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C',
|
||||||
|
'#2E7D32', '#33691E', '#BF360C', '#880E4F', '#4E342E', '#37474F', '#006064', '#5D4037',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function randomCardColor(): string {
|
||||||
|
return CARD_PALETTE[Math.floor(Math.random() * CARD_PALETTE.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to two letters: initials of the first two words, or the first two characters of a single word. */
|
||||||
|
export function cardAbbreviation(name: string): string {
|
||||||
|
const words = name.trim().split(/[\s\-_.]+/u).filter(Boolean);
|
||||||
|
if (words.length === 0) return '?';
|
||||||
|
const letters = words.length === 1 ? [...words[0]!].slice(0, 2) : words.slice(0, 2).map((w) => [...w][0]!);
|
||||||
|
return letters.join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Black or white, whichever reads better on the given #RRGGBB background. */
|
||||||
|
export function textColorOn(background: string): '#000000' | '#FFFFFF' {
|
||||||
|
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(background);
|
||||||
|
if (!m) return '#FFFFFF';
|
||||||
|
const [r, g, b] = [m[1], m[2], m[3]].map((h) => {
|
||||||
|
const c = parseInt(h!, 16) / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
}) as [number, number, number];
|
||||||
|
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
return luminance > 0.179 ? '#000000' : '#FFFFFF';
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export const CODE_TYPES = [
|
||||||
|
'EAN13',
|
||||||
|
'EAN8',
|
||||||
|
'UPCA',
|
||||||
|
'UPCE',
|
||||||
|
'CODE128',
|
||||||
|
'CODE39',
|
||||||
|
'ITF',
|
||||||
|
'CODABAR',
|
||||||
|
'QR',
|
||||||
|
'DATAMATRIX',
|
||||||
|
'PDF417',
|
||||||
|
'AZTEC',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CodeType = (typeof CODE_TYPES)[number];
|
||||||
|
|
||||||
|
export const CODE_TYPE_LABELS: Record<CodeType, string> = {
|
||||||
|
EAN13: 'EAN-13',
|
||||||
|
EAN8: 'EAN-8',
|
||||||
|
UPCA: 'UPC-A',
|
||||||
|
UPCE: 'UPC-E',
|
||||||
|
CODE128: 'Code 128',
|
||||||
|
CODE39: 'Code 39',
|
||||||
|
ITF: 'ITF (Interleaved 2 of 5)',
|
||||||
|
CODABAR: 'Codabar',
|
||||||
|
QR: 'QR Code',
|
||||||
|
DATAMATRIX: 'Data Matrix',
|
||||||
|
PDF417: 'PDF417',
|
||||||
|
AZTEC: 'Aztec',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** bwip-js symbology ids ("bcid") for each code type. */
|
||||||
|
export const BWIP_BCID: Record<CodeType, string> = {
|
||||||
|
EAN13: 'ean13',
|
||||||
|
EAN8: 'ean8',
|
||||||
|
UPCA: 'upca',
|
||||||
|
UPCE: 'upce',
|
||||||
|
CODE128: 'code128',
|
||||||
|
CODE39: 'code39',
|
||||||
|
ITF: 'interleaved2of5',
|
||||||
|
CODABAR: 'rationalizedCodabar',
|
||||||
|
QR: 'qrcode',
|
||||||
|
DATAMATRIX: 'datamatrix',
|
||||||
|
PDF417: 'pdf417',
|
||||||
|
AZTEC: 'azteccode',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TWO_DIMENSIONAL_CODE_TYPES: ReadonlySet<CodeType> = new Set(['QR', 'DATAMATRIX', 'AZTEC']);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildLoginDeepLink, parseLoginDeepLink } from './deepLink.js';
|
||||||
|
|
||||||
|
describe('login deep link', () => {
|
||||||
|
it('round-trips server and key', () => {
|
||||||
|
const link = buildLoginDeepLink('https://cards.example.com/sub?x=1&y=2', 'AbC123');
|
||||||
|
expect(link).toBe('cardy://login?server=https%3A%2F%2Fcards.example.com%2Fsub%3Fx%3D1%26y%3D2&key=AbC123');
|
||||||
|
expect(parseLoginDeepLink(link)).toEqual({ server: 'https://cards.example.com/sub?x=1&y=2', key: 'AbC123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects foreign or incomplete links', () => {
|
||||||
|
expect(parseLoginDeepLink('https://login?server=a&key=b')).toBeNull();
|
||||||
|
expect(parseLoginDeepLink('cardy://other?server=a&key=b')).toBeNull();
|
||||||
|
expect(parseLoginDeepLink('cardy://login?server=a')).toBeNull();
|
||||||
|
expect(parseLoginDeepLink('not a url')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const DEEP_LINK_SCHEME = 'cardy';
|
||||||
|
|
||||||
|
export function buildLoginDeepLink(server: string, key: string): string {
|
||||||
|
return `${DEEP_LINK_SCHEME}://login?server=${encodeURIComponent(server)}&key=${encodeURIComponent(key)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLoginDeepLink(link: string): { server: string; key: string } | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(link.trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Non-special schemes put "login" into host for "cardy://login?…".
|
||||||
|
if (url.protocol !== `${DEEP_LINK_SCHEME}:` || url.host !== 'login') return null;
|
||||||
|
const server = url.searchParams.get('server');
|
||||||
|
const key = url.searchParams.get('key');
|
||||||
|
if (!server || !key) return null;
|
||||||
|
return { server, key };
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './codeTypes.js';
|
||||||
|
export * from './models.js';
|
||||||
|
export * from './schemas.js';
|
||||||
|
export * from './deepLink.js';
|
||||||
|
export * from './cardDisplay.js';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { CodeType } from './codeTypes.js';
|
||||||
|
|
||||||
|
export const ROLES = ['user', 'admin', 'superadmin'] as const;
|
||||||
|
export type Role = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageRef {
|
||||||
|
id: string;
|
||||||
|
file: string;
|
||||||
|
mime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Card {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
code: string;
|
||||||
|
codeType: CodeType;
|
||||||
|
logo: ImageRef | null;
|
||||||
|
images: ImageRef[];
|
||||||
|
imagesVersion: number;
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access key as shown in the admin UI (decrypted). */
|
||||||
|
export interface AccessKeyView {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
createdAt: string;
|
||||||
|
key: string;
|
||||||
|
deepLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserView {
|
||||||
|
email: string;
|
||||||
|
groupId: string;
|
||||||
|
role: Role;
|
||||||
|
keys: AccessKeyView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
group: Group;
|
||||||
|
currentGroup: Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiAuthResponse {
|
||||||
|
email: string;
|
||||||
|
group: Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { CODE_TYPES } from './codeTypes.js';
|
||||||
|
import { ROLES } from './models.js';
|
||||||
|
|
||||||
|
export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB');
|
||||||
|
|
||||||
|
export const cardFieldsSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, 'Name is required').max(200),
|
||||||
|
color: colorSchema,
|
||||||
|
code: z.string().min(1, 'Code is required').max(4000),
|
||||||
|
codeType: z.enum(CODE_TYPES),
|
||||||
|
});
|
||||||
|
export type CardFields = z.infer<typeof cardFieldsSchema>;
|
||||||
|
|
||||||
|
export const cardPatchSchema = cardFieldsSchema.partial().extend({
|
||||||
|
deleted: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type CardPatch = z.infer<typeof cardPatchSchema>;
|
||||||
|
|
||||||
|
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
|
||||||
|
|
||||||
|
export const groupInputSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, 'Name is required').max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userCreateSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
role: z.enum(ROLES).default('user'),
|
||||||
|
groupId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userPatchSchema = z.object({
|
||||||
|
role: z.enum(ROLES).optional(),
|
||||||
|
groupId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const keyCreateSchema = z.object({
|
||||||
|
label: z.string().trim().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const currentGroupSchema = z.object({
|
||||||
|
groupId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user