Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb8f2ff9c | ||
|
|
bc865813cb | ||
|
|
e8f971b54e | ||
|
|
d522f35663 | ||
|
|
e7d76f40de | ||
|
|
5b3e10be94 | ||
|
|
3fc6894070 | ||
|
|
69c1e4926c |
No files matched your search
@@ -47,8 +47,9 @@ and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
|
|||||||
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
|
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
|
||||||
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
|
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
|
||||||
A permission is **(app, role)** in `user_app_permissions`, keyed on
|
A permission is **(app, role)** in `user_app_permissions`, keyed on
|
||||||
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
|
`(user_id, app, role)` so one user can hold several roles per app. Two roles exist: `access`
|
||||||
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
|
(full access) and `read` (read-only, not for `admin`) - see `docs/admin-roles.md`, including
|
||||||
|
how `requireAppAccess` enforces `read` by HTTP method; `APP_ROLES` in `admin.schema.ts` is the contract,
|
||||||
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
|
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
|
||||||
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
|
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
|
||||||
narrow to one role, and queries the database on every request (no cookie cache) so disabling
|
narrow to one role, and queries the database on every request (no cookie cache) so disabling
|
||||||
@@ -89,6 +90,19 @@ error. Only the integration tests catch this.
|
|||||||
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
|
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
|
||||||
use the published `@better-auth/cli`; it lags the library.
|
use the published `@better-auth/cli`; it lags the library.
|
||||||
|
|
||||||
|
**`docker-compose.dev.yml` builds a fresh local dev database from `docker/init/`, not
|
||||||
|
from `sql/<domain>/` directly** - the two domains use different mechanisms and both need
|
||||||
|
to be kept in sync by hand whenever a migration is added: `docker/init/03-tickets-schema.sql`
|
||||||
|
and `02-feedback-schema.sql` are thin files that `SOURCE` every `sql/<domain>/NNN_*.sql`
|
||||||
|
in order (add the new migration's `SOURCE` line there too); `01-calendar-schema-dev.sql`
|
||||||
|
and `04-admin-schema.sql` instead fold each migration's effect directly into one
|
||||||
|
reconstructed CREATE-TABLE schema (own header comment: "keep the two in step") - no
|
||||||
|
`SOURCE` list to extend, edit the reconstructed schema itself. Found
|
||||||
|
`03-tickets-schema.sql` missing the `SOURCE` line for `003_add_confirmation_email_status.sql`
|
||||||
|
while adding `004` - every tickets dev DB spun up since that migration was added has been
|
||||||
|
silently missing the column (mail sends still succeed, `recordConfirmationEmailResult`'s
|
||||||
|
`UPDATE` just fails and logs). Fixed.
|
||||||
|
|
||||||
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
||||||
|
|
||||||
**Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar.
|
**Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
USE nachklang_tickets;
|
USE nachklang_tickets;
|
||||||
SOURCE /migrations/tickets/001_init.sql;
|
SOURCE /migrations/tickets/001_init.sql;
|
||||||
SOURCE /migrations/tickets/002_add_require_address.sql;
|
SOURCE /migrations/tickets/002_add_require_address.sql;
|
||||||
|
SOURCE /migrations/tickets/003_add_confirmation_email_status.sql;
|
||||||
|
SOURCE /migrations/tickets/004_add_tickets_mailed.sql;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Admin roles: full access and read-only
|
||||||
|
|
||||||
|
Added 2026-10-29. A user's permission is a list of **(app, role)** grants in
|
||||||
|
`user_app_permissions` (see `src/models/admin/admin.schema.ts`). Each app can now be granted at
|
||||||
|
one of two levels:
|
||||||
|
|
||||||
|
| Role | UI label (admin app) | Apps | Meaning |
|
||||||
|
|----------|----------------------|-------------------------------|--------------------------------------------|
|
||||||
|
| `access` | Vollzugriff | calendar, feedback, tickets, admin | Everything in that app, as before. |
|
||||||
|
| `read` | Nur lesen | calendar, feedback, tickets | See everything, change nothing. |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **`access` and `read` on the same app are mutually exclusive.** `toPermissions` returns `null`
|
||||||
|
for a list holding both, so `PUT /admin/users/:id/permissions` and `POST /admin/invitations`
|
||||||
|
answer 400. There is no "higher one wins".
|
||||||
|
- **`admin` (user management) has no `read` role.** Viewing it means viewing other people's
|
||||||
|
email addresses, IPs and sessions; that is a different trust level from viewing concerts.
|
||||||
|
- Existing grants were not migrated: every pre-existing row is `access` and still means full
|
||||||
|
access.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`requireAppAccess(app)` (no role argument - which is how every app router uses it) does, after
|
||||||
|
the existing session/disabled/has-any-grant checks:
|
||||||
|
|
||||||
|
- `GET`, `HEAD`, `OPTIONS` - allowed for any grant on the app.
|
||||||
|
- anything else - allowed only with `access` (`canWrite`). A `read`-only user gets
|
||||||
|
`403 {status: 'FORBIDDEN', message: 'Du hast für diesen Bereich nur Lesezugriff.'}`.
|
||||||
|
|
||||||
|
This is by method on purpose: a write route added later is covered without being marked. It
|
||||||
|
relies on one convention that **must stay true**: admin routes read with GET and change with
|
||||||
|
POST/PUT/PATCH/DELETE. When this was built, every GET handler in tickets, feedback and calendar
|
||||||
|
was checked to call only read functions (no SQL writes, no mail). A GET with a side effect, or a
|
||||||
|
POST that only reads, breaks the read-only role in one direction or the other.
|
||||||
|
|
||||||
|
Consequences worth knowing:
|
||||||
|
|
||||||
|
- "Resend confirmation email" (tickets) is a POST and therefore not available read-only - by
|
||||||
|
decision: it has an effect outside the system.
|
||||||
|
- CSV exports and reports are GETs and therefore available read-only - also by decision.
|
||||||
|
Read-only means all data, including personal data. A restricted variant would be a new role.
|
||||||
|
- `requireAppAccess(app, role)` with an explicit role does **not** apply the method rule: the
|
||||||
|
caller has already named exactly the grant it needs.
|
||||||
|
|
||||||
|
## Telling the frontend
|
||||||
|
|
||||||
|
- `GET /tickets/admin/me` and `GET /feedback/admin/me` return `readOnly: boolean` alongside
|
||||||
|
`email` and `fullName`. Frontends hide write controls when it is `true`; the API refuses the
|
||||||
|
writes regardless.
|
||||||
|
- `GET /admin/me` returns the full `permissions` list; `isReadOnly(permissions, app)` in
|
||||||
|
`admin.schema.ts` is the same rule.
|
||||||
|
|
||||||
|
## Calendar
|
||||||
|
|
||||||
|
The API side is done: calendar writes sit behind `requireAppAccess('calendar')` and so refuse a
|
||||||
|
`read`-only user, and the optional-cookie reads (`/:calendar/json`, `/ical`) check
|
||||||
|
`access.apps.includes('calendar')`, which a `read` grant satisfies - read-only users see the
|
||||||
|
restricted calendars.
|
||||||
|
|
||||||
|
The current Angular frontend (`Calendar_Frontend`) was deliberately **not** changed, because it
|
||||||
|
is being rewritten in Next.js. Until then a read-only calendar user sees the edit buttons and
|
||||||
|
gets the 403 above when using them. Contract for the rewrite: there is no `/calendar/.../me`
|
||||||
|
with `readOnly`; read `GET /admin/me` and compute `isReadOnly(permissions, 'calendar')` - or add
|
||||||
|
a calendar `/me` mirroring tickets/feedback.
|
||||||
|
|
||||||
|
## Adding a finer role later
|
||||||
|
|
||||||
|
`canWrite` checks for `access` specifically, so a new role (e.g. `tickets:redeem`) grants no
|
||||||
|
writes through the method rule. Watch out: tickets and feedback apply the role-less
|
||||||
|
`requireAdminAuth` once at the top of their admin router, so that gate would already refuse the
|
||||||
|
new role's POST before any route-level `requireAppAccess(app, 'redeem')` runs. A finer role
|
||||||
|
therefore needs `canWrite` (or the top-level gate) taught about it, not just a route-level check.
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
# Migrating the Calendar domain onto the admin identity module
|
# Migrating the Calendar domain onto the admin identity module
|
||||||
|
|
||||||
Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on
|
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
|
||||||
2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is
|
decision and part of step 5 brought forward; the rest went out as written.
|
||||||
implemented and awaiting deploy - see its own checklist below, whose ordering is the
|
|
||||||
**opposite** of step 4's.
|
|
||||||
|
|
||||||
Verified live after step 4: the public calendar still answers anonymously, all 23 public
|
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
|
||||||
events kept a resolvable author, restricted calendars still refuse without a credential,
|
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
|
||||||
legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out.
|
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
|
||||||
|
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
|
||||||
|
|
||||||
|
Verified in production after the final deploy: the public calendar answers anonymously on all
|
||||||
|
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
|
||||||
|
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
|
||||||
|
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
|
||||||
|
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
|
||||||
|
`created_by_name` snapshot, since the table it was copied from no longer exists under that
|
||||||
|
name. That last one is the whole reason part of step 5 was brought forward.
|
||||||
|
|
||||||
|
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
|
||||||
|
|
||||||
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
|
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
|
||||||
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
|
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
|
||||||
@@ -157,8 +166,8 @@ Each step is meant to leave production working on its own.
|
|||||||
|
|
||||||
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
||||||
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
||||||
5. **Drop the legacy path.** **Implemented 2026-09-06** on `feature/calendar-drop-legacy-path`;
|
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
|
||||||
not yet deployed. Gated on step 4 being live, which it now is.
|
was.
|
||||||
|
|
||||||
What went:
|
What went:
|
||||||
|
|
||||||
@@ -178,7 +187,7 @@ Each step is meant to leave production working on its own.
|
|||||||
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
|
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
|
||||||
close with it - not by adding expiries but by deleting the code that issued them.
|
close with it - not by adding expiries but by deleting the code that issued them.
|
||||||
|
|
||||||
### Deploy checklist — note the order is REVERSED from step 4
|
### Deploy checklist — kept for the record; the order was REVERSED from step 4
|
||||||
|
|
||||||
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
|
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
|
||||||
table that the currently running build still selects and joins, so running it first fails
|
table that the currently running build still selects and joins, so running it first fails
|
||||||
@@ -209,6 +218,15 @@ Each step is meant to leave production working on its own.
|
|||||||
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
|
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
|
||||||
anything reading `/calendar/events/*/json` that is not the calendar frontend.
|
anything reading `/calendar/events/*/json` that is not the calendar frontend.
|
||||||
|
|
||||||
|
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
|
||||||
|
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
|
||||||
|
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
|
||||||
|
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
|
||||||
|
listing, which omits `status`, and the UI's status switch fell through to its error label.
|
||||||
|
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
|
||||||
|
fails honestly instead of faking a session. This is the same "does not look broken" window
|
||||||
|
the step 4 checklist warns about, seen from the other side.
|
||||||
|
|
||||||
## What the code actually looks like (surveyed 2026-09-06)
|
## What the code actually looks like (surveyed 2026-09-06)
|
||||||
|
|
||||||
Four things found while doing step 1 that change how the later steps should be built:
|
Four things found while doing step 1 that change how the later steps should be built:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Nachklang e.V. Tickets module — adds a per-event "tickets are mailed" flag.
|
||||||
|
-- Defaults to 0 (not mailed) because no event mails physical tickets today -
|
||||||
|
-- the redemption confirmation email uses this to decide whether to tell the
|
||||||
|
-- guest their tickets await pickup at the Abendkasse instead. Apply manually
|
||||||
|
-- against TICKETS_DB, after 003_add_confirmation_email_status.sql:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 004_add_tickets_mailed.sql
|
||||||
|
ALTER TABLE event_ticket_settings
|
||||||
|
ADD COLUMN tickets_mailed TINYINT(1) NOT NULL DEFAULT 0 AFTER require_address;
|
||||||
@@ -2,9 +2,14 @@ import express from 'express';
|
|||||||
import {fromNodeHeaders} from 'better-auth/node';
|
import {fromNodeHeaders} from 'better-auth/node';
|
||||||
import {auth} from './admin.auth.js';
|
import {auth} from './admin.auth.js';
|
||||||
import * as UsersService from './users/users.admin.service.js';
|
import * as UsersService from './users/users.admin.service.js';
|
||||||
import {AppName, AppPermission, AppRole} from './admin.schema.js';
|
import {AppName, AppPermission, AppRole, canWrite} from './admin.schema.js';
|
||||||
import {sendServerError} from './admin.errors.js';
|
import {sendServerError} from './admin.errors.js';
|
||||||
|
|
||||||
|
// What a read-only grant may do. Every admin route reads with GET and changes
|
||||||
|
// with POST/PUT/PATCH/DELETE - keep it that way, or a read-only user either
|
||||||
|
// loses a view or gains a write.
|
||||||
|
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one authenticator for every admin area in this API. It replaces
|
* The one authenticator for every admin area in this API. It replaces
|
||||||
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
|
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
|
||||||
@@ -131,6 +136,15 @@ export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestH
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The read-only level, enforced by method rather than per route so a
|
||||||
|
// write route added later is covered without being marked. Only the
|
||||||
|
// role-less form applies it: a caller asking for a specific role has
|
||||||
|
// already said exactly what is required.
|
||||||
|
if (role === undefined && !READ_METHODS.has(req.method) && !canWrite(access.permissions, app)) {
|
||||||
|
forbidden(res, 'Du hast für diesen Bereich nur Lesezugriff.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.locals.admin = access;
|
res.locals.admin = access;
|
||||||
next();
|
next();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -35,13 +35,26 @@ export const isAppName = (value: unknown): value is AppName => {
|
|||||||
*/
|
*/
|
||||||
export const ACCESS_ROLE = 'access';
|
export const ACCESS_ROLE = 'access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only access to an app: everything `access` may see, nothing it may
|
||||||
|
* change. requireAppAccess enforces it by HTTP method - a user holding only
|
||||||
|
* this role gets GET/HEAD/OPTIONS and a 403 for anything else - so a new write
|
||||||
|
* route is covered without anyone remembering to mark it.
|
||||||
|
*
|
||||||
|
* It and `access` are mutually exclusive on one app (toPermissions rejects a
|
||||||
|
* list holding both), because "full access and also read-only" has no sensible
|
||||||
|
* reading. The `admin` app deliberately has no read role: viewing user
|
||||||
|
* management means viewing other people's addresses, IPs and sessions.
|
||||||
|
*/
|
||||||
|
export const READ_ROLE = 'read';
|
||||||
|
|
||||||
export type AppRole = string;
|
export type AppRole = string;
|
||||||
|
|
||||||
/** Every role that exists, per app, in display order. Extend to add one. */
|
/** Every role that exists, per app, in display order. Extend to add one. */
|
||||||
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
|
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
|
||||||
calendar: [ACCESS_ROLE],
|
calendar: [ACCESS_ROLE, READ_ROLE],
|
||||||
feedback: [ACCESS_ROLE],
|
feedback: [ACCESS_ROLE, READ_ROLE],
|
||||||
tickets: [ACCESS_ROLE],
|
tickets: [ACCESS_ROLE, READ_ROLE],
|
||||||
admin: [ACCESS_ROLE]
|
admin: [ACCESS_ROLE]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +98,7 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return permissions.filter(permission => {
|
const unique = permissions.filter(permission => {
|
||||||
const key = `${permission.app}:${permission.role}`;
|
const key = `${permission.app}:${permission.role}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -93,6 +106,11 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
|
|||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rejected rather than resolved to either level: whichever one we picked,
|
||||||
|
// the caller asked for something else.
|
||||||
|
const contradictory = APP_NAMES.some(app => seen.has(`${app}:${ACCESS_ROLE}`) && seen.has(`${app}:${READ_ROLE}`));
|
||||||
|
return contradictory ? null : unique;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The distinct apps a permission list grants any access to. */
|
/** The distinct apps a permission list grants any access to. */
|
||||||
@@ -100,6 +118,20 @@ export const appsOf = (permissions: AppPermission[]): AppName[] => {
|
|||||||
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
|
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether these grants allow changing anything in `app`. Only `access` does:
|
||||||
|
* `read` is the read-only level, and a finer role added later grants exactly
|
||||||
|
* what its own check says, not writes in general.
|
||||||
|
*/
|
||||||
|
export const canWrite = (permissions: AppPermission[], app: AppName): boolean => {
|
||||||
|
return permissions.some(permission => permission.app === app && permission.role === ACCESS_ROLE);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Holds some grant on `app`, but none that allows changes. */
|
||||||
|
export const isReadOnly = (permissions: AppPermission[], app: AppName): boolean => {
|
||||||
|
return appsOf(permissions).includes(app) && !canWrite(permissions, app);
|
||||||
|
};
|
||||||
|
|
||||||
export interface UserTable {
|
export interface UserTable {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {requireAdminAuth} from '../feedback.auth.js';
|
import {requireAdminAuth} from '../feedback.auth.js';
|
||||||
|
import {isReadOnly} from '../../admin/admin.schema.js';
|
||||||
import {sendServerError} from '../feedback.errors.js';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
import {eventsAdminRouter} from './events.admin.router.js';
|
import {eventsAdminRouter} from './events.admin.router.js';
|
||||||
import {songsAdminRouter} from './songs.admin.router.js';
|
import {songsAdminRouter} from './songs.admin.router.js';
|
||||||
@@ -40,13 +41,21 @@ adminRouter.use(requireAdminAuth);
|
|||||||
* type: string
|
* type: string
|
||||||
* fullName:
|
* fullName:
|
||||||
* type: string
|
* type: string
|
||||||
|
* readOnly:
|
||||||
|
* type: boolean
|
||||||
|
* description: Holds only the read role for feedback - every write answers 403.
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
* 403:
|
* 403:
|
||||||
* description: Signed in without the permission for this app, or account disabled
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
adminRouter.get('/me', (req: Request, res: Response) => {
|
adminRouter.get('/me', (req: Request, res: Response) => {
|
||||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
res.status(200).send({
|
||||||
|
email: res.locals.admin.email,
|
||||||
|
fullName: res.locals.admin.displayName,
|
||||||
|
// Lets the frontend hide what the API would refuse anyway.
|
||||||
|
readOnly: isReadOnly(res.locals.admin.permissions, 'feedback')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {requireAdminAuth} from '../tickets.auth.js';
|
import {requireAdminAuth} from '../tickets.auth.js';
|
||||||
|
import {isReadOnly} from '../../admin/admin.schema.js';
|
||||||
import {vouchersAdminRouter} from './vouchers.admin.router.js';
|
import {vouchersAdminRouter} from './vouchers.admin.router.js';
|
||||||
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
|
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
|
||||||
import {eventsAdminRouter} from './events.admin.router.js';
|
import {eventsAdminRouter} from './events.admin.router.js';
|
||||||
@@ -21,13 +22,30 @@ adminRouter.use(requireAdminAuth);
|
|||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* fullName:
|
||||||
|
* type: string
|
||||||
|
* readOnly:
|
||||||
|
* type: boolean
|
||||||
|
* description: Holds only the read role for tickets - every write answers 403.
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
* 403:
|
* 403:
|
||||||
* description: Signed in without the permission for this app, or account disabled
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
adminRouter.get('/me', (req: Request, res: Response) => {
|
adminRouter.get('/me', (req: Request, res: Response) => {
|
||||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
res.status(200).send({
|
||||||
|
email: res.locals.admin.email,
|
||||||
|
fullName: res.locals.admin.displayName,
|
||||||
|
// Lets the frontend hide what the API would refuse anyway.
|
||||||
|
readOnly: isReadOnly(res.locals.admin.permissions, 'tickets')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
adminRouter.use('/vouchers', vouchersAdminRouter);
|
adminRouter.use('/vouchers', vouchersAdminRouter);
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
* /tickets/admin/events/{eventId}/settings:
|
* /tickets/admin/events/{eventId}/settings:
|
||||||
* put:
|
* put:
|
||||||
* summary: Set a concert's voucher settings
|
* summary: Set a concert's voucher settings
|
||||||
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
|
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), whether to collect a mailing address, and whether tickets are mailed to guests.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* security:
|
* security:
|
||||||
* - AdminSessionCookie: []
|
* - AdminSessionCookie: []
|
||||||
@@ -122,6 +122,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
* requireAddress:
|
* requireAddress:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: Only meaningful when collectAddress is true.
|
* description: Only meaningful when collectAddress is true.
|
||||||
|
* ticketsMailed:
|
||||||
|
* type: boolean
|
||||||
|
* description: When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse instead.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Saved
|
* description: Saved
|
||||||
@@ -132,7 +135,7 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
*/
|
*/
|
||||||
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const {capacity, redemptionDeadline, collectAddress, requireAddress} = req.body || {};
|
const {capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed} = req.body || {};
|
||||||
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
|
await EventsAdminService.setEventSettings(Number(req.params.eventId), {
|
||||||
capacity: capacity ?? null,
|
capacity: capacity ?? null,
|
||||||
// The mariadb driver needs an actual Date to serialize a DATETIME
|
// The mariadb driver needs an actual Date to serialize a DATETIME
|
||||||
@@ -140,7 +143,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
|
|||||||
// rejected with "Incorrect datetime value".
|
// rejected with "Incorrect datetime value".
|
||||||
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
|
redemptionDeadline: redemptionDeadline ? new Date(redemptionDeadline) : null,
|
||||||
collectAddress: !!collectAddress,
|
collectAddress: !!collectAddress,
|
||||||
requireAddress: !!collectAddress && !!requireAddress
|
requireAddress: !!collectAddress && !!requireAddress,
|
||||||
|
ticketsMailed: !!ticketsMailed
|
||||||
});
|
});
|
||||||
res.status(200).send({status: 'OK'});
|
res.status(200).send({status: 'OK'});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const getEventStats = async (eventId: number): Promise<EventStats> => {
|
|||||||
redemptionDeadline: ticketState.redemptionDeadline,
|
redemptionDeadline: ticketState.redemptionDeadline,
|
||||||
collectAddress: ticketState.collectAddress,
|
collectAddress: ticketState.collectAddress,
|
||||||
requireAddress: ticketState.requireAddress,
|
requireAddress: ticketState.requireAddress,
|
||||||
|
ticketsMailed: ticketState.ticketsMailed,
|
||||||
guestsUsed: ticketState.guestsUsed,
|
guestsUsed: ticketState.guestsUsed,
|
||||||
spotsRemaining: ticketState.spotsRemaining,
|
spotsRemaining: ticketState.spotsRemaining,
|
||||||
unusedCodes,
|
unusedCodes,
|
||||||
@@ -119,10 +120,10 @@ export const setEventSettings = async (eventId: number, settings: Omit<EventTick
|
|||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address)
|
`INSERT INTO event_ticket_settings (event_id, capacity, redemption_deadline, collect_address, require_address, tickets_mailed)
|
||||||
VALUES (?,?,?,?,?)
|
VALUES (?,?,?,?,?,?)
|
||||||
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address)`,
|
ON DUPLICATE KEY UPDATE capacity = VALUES(capacity), redemption_deadline = VALUES(redemption_deadline), collect_address = VALUES(collect_address), require_address = VALUES(require_address), tickets_mailed = VALUES(tickets_mailed)`,
|
||||||
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0]
|
[eventId, settings.capacity, settings.redemptionDeadline, settings.collectAddress ? 1 : 0, settings.requireAddress ? 1 : 0, settings.ticketsMailed ? 1 : 0]
|
||||||
);
|
);
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -129,12 +129,13 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
|||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [name, email, eventIds]
|
* required: [name, eventIds]
|
||||||
* properties:
|
* properties:
|
||||||
* name:
|
* name:
|
||||||
* type: string
|
* type: string
|
||||||
* email:
|
* email:
|
||||||
* type: string
|
* type: string
|
||||||
|
* description: Optional - a guest can supply their own email when redeeming.
|
||||||
* eventIds:
|
* eventIds:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
|
|||||||
throw new Error('at least one row is required');
|
throw new Error('at least one row is required');
|
||||||
}
|
}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.name || !row.email || row.eventIds.length === 0) {
|
if (!row.name || row.eventIds.length === 0) {
|
||||||
throw new Error('each row requires a name, email, and at least one eligible event');
|
throw new Error('each row requires a name and at least one eligible event');
|
||||||
}
|
}
|
||||||
if (!isValidEmail(row.email)) {
|
if (row.email && !isValidEmail(row.email)) {
|
||||||
throw new Error(`"${row.email}" does not look like a valid email address`);
|
throw new Error(`"${row.email}" does not look like a valid email address`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
|
|||||||
codes.push(code);
|
codes.push(code);
|
||||||
await conn.query(
|
await conn.query(
|
||||||
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
|
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
|
||||||
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
|
[code, 'UNUSED', row.maxGuests, row.name, row.email || null, batchId, createdByEmail]
|
||||||
);
|
);
|
||||||
for (const eventId of row.eventIds) {
|
for (const eventId of row.eventIds) {
|
||||||
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
|
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface EventTicketState {
|
|||||||
redemptionDeadline: Date | null;
|
redemptionDeadline: Date | null;
|
||||||
collectAddress: boolean;
|
collectAddress: boolean;
|
||||||
requireAddress: boolean;
|
requireAddress: boolean;
|
||||||
|
ticketsMailed: boolean;
|
||||||
guestsUsed: number;
|
guestsUsed: number;
|
||||||
spotsRemaining: number | null;
|
spotsRemaining: number | null;
|
||||||
}
|
}
|
||||||
@@ -21,7 +22,7 @@ export interface EventTicketState {
|
|||||||
* (uncapped) don't need this - there's no cap to race against.
|
* (uncapped) don't need this - there's no cap to race against.
|
||||||
*/
|
*/
|
||||||
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
|
export const getEventTicketState = async (conn: any, eventId: number, forUpdate = false): Promise<EventTicketState> => {
|
||||||
const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`;
|
const settingsQuery = `SELECT capacity, redemption_deadline, collect_address, require_address, tickets_mailed FROM event_ticket_settings WHERE event_id = ?${forUpdate ? ' FOR UPDATE' : ''}`;
|
||||||
const settingsRows = await conn.query(settingsQuery, [eventId]);
|
const settingsRows = await conn.query(settingsQuery, [eventId]);
|
||||||
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
|
const capacity = settingsRows.length > 0 ? settingsRows[0].capacity : null;
|
||||||
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null;
|
const redemptionDeadline = settingsRows.length > 0 ? settingsRows[0].redemption_deadline : null;
|
||||||
@@ -29,6 +30,7 @@ export const getEventTicketState = async (conn: any, eventId: number, forUpdate
|
|||||||
// Only meaningful when collectAddress is also true - the field isn't
|
// Only meaningful when collectAddress is also true - the field isn't
|
||||||
// shown/collected at all otherwise, so "required" is moot.
|
// shown/collected at all otherwise, so "required" is moot.
|
||||||
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false;
|
const requireAddress = collectAddress && settingsRows.length > 0 ? !!settingsRows[0].require_address : false;
|
||||||
|
const ticketsMailed = settingsRows.length > 0 ? !!settingsRows[0].tickets_mailed : false;
|
||||||
|
|
||||||
const usedRows = await conn.query(
|
const usedRows = await conn.query(
|
||||||
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'",
|
"SELECT COALESCE(SUM(guest_count), 0) as used FROM redemptions WHERE event_id = ? AND status = 'ACTIVE'",
|
||||||
@@ -37,5 +39,5 @@ export const getEventTicketState = async (conn: any, eventId: number, forUpdate
|
|||||||
const guestsUsed = Number(usedRows[0].used);
|
const guestsUsed = Number(usedRows[0].used);
|
||||||
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed);
|
const spotsRemaining = capacity === null ? null : Math.max(0, capacity - guestsUsed);
|
||||||
|
|
||||||
return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, guestsUsed, spotsRemaining};
|
return {eventId, capacity, redemptionDeadline, collectAddress, requireAddress, ticketsMailed, guestsUsed, spotsRemaining};
|
||||||
};
|
};
|
||||||
@@ -24,6 +24,23 @@ export interface ConfirmationRecipient {
|
|||||||
guestNames: string[];
|
guestNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an event's tickets are mailed to guests - read directly rather than
|
||||||
|
* via getEventTicketState (tickets.capacity.ts) since that also computes a
|
||||||
|
* live guest count this function doesn't need. Absent settings row (no
|
||||||
|
* ticket-shop config yet) defaults to false, same "absence over sentinels"
|
||||||
|
* convention as the rest of event_ticket_settings.
|
||||||
|
*/
|
||||||
|
const ticketsAreMailed = async (eventId: number): Promise<boolean> => {
|
||||||
|
let conn = await NachklangTicketsDB.getConnection();
|
||||||
|
try {
|
||||||
|
const rows = await conn.query('SELECT tickets_mailed FROM event_ticket_settings WHERE event_id = ?', [eventId]);
|
||||||
|
return rows.length > 0 ? !!rows[0].tickets_mailed : false;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends the redemption confirmation email for one redemption. Returns whether
|
* Sends the redemption confirmation email for one redemption. Returns whether
|
||||||
* the mail was accepted by the relay. Never throws: a missing event is treated
|
* the mail was accepted by the relay. Never throws: a missing event is treated
|
||||||
@@ -36,14 +53,20 @@ export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipien
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ticketsMailed = await ticketsAreMailed(recipient.eventId);
|
||||||
|
const pickupNotice = ticketsMailed
|
||||||
|
? ''
|
||||||
|
: `\n\nDeine Tickets werden nicht postalisch versendet: Sie liegen am Konzertabend unter dem Namen ${recipient.contactName} für dich an der Abendkasse bereit.`;
|
||||||
|
|
||||||
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
|
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
|
||||||
const body =
|
const body =
|
||||||
`Hallo ${recipient.contactName},\n\n` +
|
`Hallo ${recipient.contactName},\n\n` +
|
||||||
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
|
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
|
||||||
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
|
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
|
||||||
`Ort: ${event.location}\n\n` +
|
`Ort: ${event.location}\n\n` +
|
||||||
`Angemeldete Gäste:\n${guestList}\n\n` +
|
`Angemeldete Gäste:\n${guestList}` +
|
||||||
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
|
pickupNotice +
|
||||||
|
`\n\nWir freuen uns auf dich!\n\nDein Nachklang-Team`;
|
||||||
|
|
||||||
let icsAttachment;
|
let icsAttachment;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
* type: integer
|
* type: integer
|
||||||
* EventTicketSettings:
|
* EventTicketSettings:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [eventId, collectAddress, requireAddress]
|
* required: [eventId, collectAddress, requireAddress, ticketsMailed]
|
||||||
* properties:
|
* properties:
|
||||||
* eventId:
|
* eventId:
|
||||||
* type: integer
|
* type: integer
|
||||||
@@ -165,9 +165,12 @@
|
|||||||
* requireAddress:
|
* requireAddress:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: Only meaningful when collectAddress is true.
|
* description: Only meaningful when collectAddress is true.
|
||||||
|
* ticketsMailed:
|
||||||
|
* type: boolean
|
||||||
|
* description: When false, the redemption confirmation email tells the guest their tickets await pickup at the Abendkasse instead.
|
||||||
* EventStats:
|
* EventStats:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [eventId, collectAddress, requireAddress, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
|
* required: [eventId, collectAddress, requireAddress, ticketsMailed, guestsUsed, unusedCodes, redeemedCodes, voidCodes]
|
||||||
* properties:
|
* properties:
|
||||||
* eventId:
|
* eventId:
|
||||||
* type: integer
|
* type: integer
|
||||||
@@ -182,6 +185,8 @@
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
* requireAddress:
|
* requireAddress:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* ticketsMailed:
|
||||||
|
* type: boolean
|
||||||
* guestsUsed:
|
* guestsUsed:
|
||||||
* type: integer
|
* type: integer
|
||||||
* spotsRemaining:
|
* spotsRemaining:
|
||||||
@@ -291,6 +296,7 @@ export interface EventTicketSettings {
|
|||||||
redemptionDeadline: Date | null;
|
redemptionDeadline: Date | null;
|
||||||
collectAddress: boolean;
|
collectAddress: boolean;
|
||||||
requireAddress: boolean;
|
requireAddress: boolean;
|
||||||
|
ticketsMailed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventStats extends EventTicketSettings {
|
export interface EventStats extends EventTicketSettings {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models
|
|||||||
const mockGetSession = auth.api.getSession as unknown as Mock;
|
const mockGetSession = auth.api.getSession as unknown as Mock;
|
||||||
const mockLoadAccess = UsersService.loadAccess as Mock;
|
const mockLoadAccess = UsersService.loadAccess as Mock;
|
||||||
|
|
||||||
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
const makeReq = (method = 'GET'): Request => ({method, headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
||||||
|
|
||||||
const makeRes = (): Response => {
|
const makeRes = (): Response => {
|
||||||
const res: any = {};
|
const res: any = {};
|
||||||
@@ -219,4 +219,62 @@ describe('requireAppAccess', () => {
|
|||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('read-only grant', () => {
|
||||||
|
const readOnlyUser = {
|
||||||
|
...activeUser,
|
||||||
|
permissions: [{app: 'tickets', role: 'read'}],
|
||||||
|
apps: ['tickets']
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['GET', 'HEAD', 'OPTIONS'])('lets %s through', async method => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue(readOnlyUser);
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('tickets')(makeReq(method), res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('403s %s', async method => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue(readOnlyUser);
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('tickets')(makeReq(method), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({message: 'Du hast für diesen Bereich nur Lesezugriff.'}));
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let full access to another app write here', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
permissions: [{app: 'tickets', role: 'read'}, {app: 'feedback', role: 'access'}],
|
||||||
|
apps: ['feedback', 'tickets']
|
||||||
|
});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('tickets')(makeReq('POST'), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets full access write', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue(activeUser);
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('feedback')(makeReq('DELETE'), res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {
|
import {
|
||||||
ACCESS_ROLE,
|
ACCESS_ROLE,
|
||||||
|
READ_ROLE,
|
||||||
appsOf,
|
appsOf,
|
||||||
|
canWrite,
|
||||||
isAppPermission,
|
isAppPermission,
|
||||||
isAppRole,
|
isAppRole,
|
||||||
|
isReadOnly,
|
||||||
toPermissions
|
toPermissions
|
||||||
} from '../../src/models/admin/admin.schema.js';
|
} from '../../src/models/admin/admin.schema.js';
|
||||||
|
|
||||||
@@ -59,6 +62,44 @@ describe('toPermissions', () => {
|
|||||||
it('reads an empty list as "no permissions", not as invalid', () => {
|
it('reads an empty list as "no permissions", not as invalid', () => {
|
||||||
expect(toPermissions([])).toEqual([]);
|
expect(toPermissions([])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
it('reads the read-only role', () => {
|
||||||
|
expect(toPermissions([{app: 'tickets', role: READ_ROLE}])).toEqual([
|
||||||
|
{app: 'tickets', role: READ_ROLE}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects full and read-only access to the same app together', () => {
|
||||||
|
expect(toPermissions(['tickets', {app: 'tickets', role: READ_ROLE}])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows full access to one app next to read-only access to another', () => {
|
||||||
|
expect(toPermissions(['tickets', {app: 'feedback', role: READ_ROLE}])).toEqual([
|
||||||
|
{app: 'tickets', role: ACCESS_ROLE},
|
||||||
|
{app: 'feedback', role: READ_ROLE}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects read-only access to user management', () => {
|
||||||
|
expect(toPermissions([{app: 'admin', role: READ_ROLE}])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canWrite / isReadOnly', () => {
|
||||||
|
it('lets only the access role write', () => {
|
||||||
|
const permissions = [
|
||||||
|
{app: 'tickets' as const, role: ACCESS_ROLE},
|
||||||
|
{app: 'feedback' as const, role: READ_ROLE}
|
||||||
|
];
|
||||||
|
expect(canWrite(permissions, 'tickets')).toBe(true);
|
||||||
|
expect(canWrite(permissions, 'feedback')).toBe(false);
|
||||||
|
expect(isReadOnly(permissions, 'tickets')).toBe(false);
|
||||||
|
expect(isReadOnly(permissions, 'feedback')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is neither for an app without any grant', () => {
|
||||||
|
expect(canWrite([], 'calendar')).toBe(false);
|
||||||
|
expect(isReadOnly([], 'calendar')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isAppRole', () => {
|
describe('isAppRole', () => {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
|
||||||
|
import type {Application} from 'express';
|
||||||
|
import {createApp} from '../../src/app.factory.js';
|
||||||
|
import {READ_ROLE} from '../../src/models/admin/admin.schema.js';
|
||||||
|
import {closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read-only role end to end: a real session cookie through the real
|
||||||
|
* middleware into each app's routers. The writes are rejected before any
|
||||||
|
* handler runs, which is why this suite needs no tickets/feedback/calendar
|
||||||
|
* database - a 403 here proves the gate held, not that a query failed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let app: Application;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closeDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const readOnlyEverywhere = async () => {
|
||||||
|
return createAndAcceptInvitation(app, 'reader@nachklang.art', 'Reader', [
|
||||||
|
{app: 'tickets', role: READ_ROLE},
|
||||||
|
{app: 'feedback', role: READ_ROLE},
|
||||||
|
{app: 'calendar', role: READ_ROLE}
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('read-only access', () => {
|
||||||
|
it('reports readOnly on each app\'s /me', async () => {
|
||||||
|
const {agent} = await readOnlyEverywhere();
|
||||||
|
|
||||||
|
const tickets = await agent.get('/tickets/admin/me');
|
||||||
|
const feedback = await agent.get('/feedback/admin/me');
|
||||||
|
|
||||||
|
expect(tickets.status).toBe(200);
|
||||||
|
expect(tickets.body.readOnly).toBe(true);
|
||||||
|
expect(feedback.status).toBe(200);
|
||||||
|
expect(feedback.body.readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports readOnly false for full access', async () => {
|
||||||
|
const {agent} = await createAndAcceptInvitation(app, 'full@nachklang.art', 'Full', ['tickets']);
|
||||||
|
|
||||||
|
const res = await agent.get('/tickets/admin/me');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.readOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['post', '/tickets/admin/vouchers/wildcard'],
|
||||||
|
['post', '/tickets/admin/redemptions/1/resend-confirmation'],
|
||||||
|
['patch', '/tickets/admin/redemptions/1'],
|
||||||
|
['put', '/tickets/admin/events/1/settings'],
|
||||||
|
['delete', '/feedback/admin/submissions/1'],
|
||||||
|
['post', '/feedback/admin/events'],
|
||||||
|
['post', '/calendar/events'],
|
||||||
|
['delete', '/calendar/events/1']
|
||||||
|
] as const)('refuses %s %s', async (method, path) => {
|
||||||
|
const {agent} = await readOnlyEverywhere();
|
||||||
|
|
||||||
|
const res = await agent[method](path).send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.message).toBe('Du hast für diesen Bereich nur Lesezugriff.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open user management', async () => {
|
||||||
|
const {agent} = await readOnlyEverywhere();
|
||||||
|
|
||||||
|
expect((await agent.get('/admin/users')).status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('granting read-only access', () => {
|
||||||
|
const signedInAdmin = () => createAndAcceptInvitation(app, 'admin@nachklang.art', 'Admin', ['admin']);
|
||||||
|
|
||||||
|
it('stores the read role per app', async () => {
|
||||||
|
const {agent} = await signedInAdmin();
|
||||||
|
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.put(`/admin/users/${other.userId}/permissions`)
|
||||||
|
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'feedback', role: 'access'}]});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.permissions).toEqual(expect.arrayContaining([
|
||||||
|
{app: 'tickets', role: READ_ROLE},
|
||||||
|
{app: 'feedback', role: 'access'}
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects full and read-only access to the same app', async () => {
|
||||||
|
const {agent} = await signedInAdmin();
|
||||||
|
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.put(`/admin/users/${other.userId}/permissions`)
|
||||||
|
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'tickets', role: 'access'}]});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects inviting someone with read-only user management', async () => {
|
||||||
|
const {agent} = await signedInAdmin();
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post('/admin/invitations')
|
||||||
|
.send({email: 'new@nachklang.art', name: 'New', permissions: [{app: 'admin', role: READ_ROLE}]});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,11 +47,19 @@ const RECIPIENT = {
|
|||||||
guestNames: ['Erika Mustermann', 'Hans Mustermann']
|
guestNames: ['Erika Mustermann', 'Hans Mustermann']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default: no event_ticket_settings row, same "absence over sentinels" case
|
||||||
|
// as everywhere else - ticketsAreMailed reads this as false (not mailed).
|
||||||
|
const makeSettingsConn = (ticketsMailed?: boolean) => ({
|
||||||
|
query: vi.fn().mockResolvedValue(ticketsMailed === undefined ? [] : [{tickets_mailed: ticketsMailed ? 1 : 0}]),
|
||||||
|
end: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetEvent.mockResolvedValue(EVENT);
|
mockGetEvent.mockResolvedValue(EVENT);
|
||||||
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
|
mockToIcal.mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR');
|
||||||
mockSendMail.mockResolvedValue(true);
|
mockSendMail.mockResolvedValue(true);
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sendRedemptionConfirmation', () => {
|
describe('sendRedemptionConfirmation', () => {
|
||||||
@@ -95,6 +103,32 @@ describe('sendRedemptionConfirmation', () => {
|
|||||||
|
|
||||||
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false);
|
expect(await sendRedemptionConfirmation(RECIPIENT)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds the Abendkasse pickup notice when the event does not mail tickets', async () => {
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn(false));
|
||||||
|
|
||||||
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
|
const body = mockSendMail.mock.calls[0][2];
|
||||||
|
expect(body).toContain('Abendkasse');
|
||||||
|
expect(body).toContain('Erika Mustermann');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the pickup notice when there is no ticket-shop settings row at all', async () => {
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn());
|
||||||
|
|
||||||
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
|
expect(mockSendMail.mock.calls[0][2]).toContain('Abendkasse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the pickup notice when the event mails tickets', async () => {
|
||||||
|
mockGetConnection.mockResolvedValue(makeSettingsConn(true));
|
||||||
|
|
||||||
|
await sendRedemptionConfirmation(RECIPIENT);
|
||||||
|
|
||||||
|
expect(mockSendMail.mock.calls[0][2]).not.toContain('Abendkasse');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('recordConfirmationEmailResult', () => {
|
describe('recordConfirmationEmailResult', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user