Put the feedback and tickets admin areas behind the shared identity (#13)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #13 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #13.
This commit is contained in:
@@ -16,14 +16,15 @@ adminRouter.use(requireAdminAuth);
|
||||
* get:
|
||||
* summary: Validate the current admin session
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
adminRouter.get('/me', (req: Request, res: Response) => {
|
||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
||||
|
||||
@@ -11,14 +11,15 @@ export const eventsAdminRouter = express.Router();
|
||||
* summary: List concerts for the admin event picker
|
||||
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -35,14 +36,15 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
* summary: List public-calendar events not yet added to the ticket shop
|
||||
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -58,9 +60,9 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
||||
* get:
|
||||
* summary: Get a concert's voucher/capacity stats
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -75,6 +77,8 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
||||
* $ref: '#/components/schemas/EventStats'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -91,9 +95,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
||||
* summary: Set a concert's voucher settings
|
||||
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -123,6 +127,8 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
||||
* description: Saved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -149,9 +155,9 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
|
||||
* summary: Remove an event from the ticket shop
|
||||
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -164,6 +170,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
|
||||
* description: Vouchers already reference this event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -11,9 +11,9 @@ export const redemptionsAdminRouter = express.Router();
|
||||
* summary: List redemptions (admin)
|
||||
* description: Filterable by event and status (ACTIVE/UNDONE).
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: query
|
||||
* name: eventId
|
||||
* schema:
|
||||
@@ -33,6 +33,8 @@ export const redemptionsAdminRouter = express.Router();
|
||||
* $ref: '#/components/schemas/RedemptionSummary'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -50,9 +52,9 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
* get:
|
||||
* summary: Get a single redemption (admin)
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
@@ -65,13 +67,15 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
* description: Unknown redemption
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* patch:
|
||||
* summary: Edit a redemption's contact info and/or guest list
|
||||
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
@@ -105,6 +109,8 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
* description: Not active, exceeds max guests, or exceeds remaining capacity
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -161,9 +167,9 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
|
||||
* summary: Undo a redemption
|
||||
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
@@ -186,6 +192,8 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
|
||||
* description: Redemption is not active
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -211,9 +219,9 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
|
||||
* summary: Resend the redemption confirmation email
|
||||
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: redemptionId
|
||||
* required: true
|
||||
@@ -230,6 +238,8 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
|
||||
* description: The email relay rejected the send
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -259,9 +269,9 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
|
||||
* get:
|
||||
* summary: Get a voucher's admin-action audit trail
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
@@ -278,6 +288,8 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
|
||||
* $ref: '#/components/schemas/AuditLogEntry'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
export const voucherHistoryRouter = express.Router();
|
||||
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -11,9 +11,9 @@ export const vouchersAdminRouter = express.Router();
|
||||
* summary: List vouchers (admin)
|
||||
* description: Filterable by event and status.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: query
|
||||
* name: eventId
|
||||
* schema:
|
||||
@@ -33,6 +33,8 @@ export const vouchersAdminRouter = express.Router();
|
||||
* $ref: '#/components/schemas/VoucherCode'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -51,9 +53,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
* summary: Batch-generate wildcard codes
|
||||
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -87,6 +88,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
* description: Invalid input
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -112,9 +115,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
||||
* summary: Bulk-create personalized codes
|
||||
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
|
||||
* tags: [tickets-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -147,6 +149,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
||||
* description: Invalid input
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -169,9 +173,9 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
|
||||
* get:
|
||||
* summary: Get a single voucher (admin)
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
@@ -184,6 +188,8 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
|
||||
* description: Unknown code
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -205,9 +211,9 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
||||
* summary: Void an unredeemed code
|
||||
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
|
||||
* tags: [tickets-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
@@ -230,6 +236,8 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
||||
* description: Code is not in UNUSED status
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import express from 'express';
|
||||
import * as UserService from '../calendar/users/users.service.js';
|
||||
import {sendServerError} from './tickets.errors.js';
|
||||
import {requireAppAccess} from '../admin/admin.middleware.js';
|
||||
|
||||
/**
|
||||
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
|
||||
* the tickets module that knows how admin authentication works. No route
|
||||
* handler and no service outside this file may import users.service, read
|
||||
* session headers, or touch bcrypt.
|
||||
* handler and no service outside this file may read session headers or
|
||||
* resolve a user itself.
|
||||
*
|
||||
* Today: reuses the existing Calendar users/sessions mechanism. Any
|
||||
* activated @nachklang.art account may administer vouchers - no roles, same
|
||||
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated
|
||||
* roles/permissions model is explicitly out of scope for v1.
|
||||
* Today: the shared admin identity in `src/models/admin/`. A session cookie
|
||||
* set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on
|
||||
* the account. Both are re-checked on every request, so disabling a user or
|
||||
* taking their tickets permission away takes effect immediately.
|
||||
*
|
||||
* Explicitly forbidden: accepting sessionId/sessionKey from query
|
||||
* parameters - headers only (see DEFERRED_SECURITY.md item 1).
|
||||
* Before 2026-09-06 this was a header session against the calendar users
|
||||
* table, and any activated @nachklang.art account could administer vouchers
|
||||
* (see docs/plan-ticket-shop.md, which called a roles model out of scope for
|
||||
* v1). It is in scope now, and lives in the admin module rather than here.
|
||||
*
|
||||
* Explicitly forbidden: accepting session credentials from query parameters -
|
||||
* see DEFERRED_SECURITY.md item 1.
|
||||
*/
|
||||
|
||||
export interface AdminIdentity {
|
||||
@@ -23,41 +26,6 @@ export interface AdminIdentity {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
|
||||
|
||||
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
|
||||
const sessionId = req.header('X-Session-Id');
|
||||
const sessionKey = req.header('X-Session-Key');
|
||||
if (!sessionId || !sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ip = req.ip || '';
|
||||
const user = await UserService.checkSession(sessionId, sessionKey, ip);
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(user.userId),
|
||||
email: user.email,
|
||||
displayName: user.fullName
|
||||
};
|
||||
};
|
||||
|
||||
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
|
||||
|
||||
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const identity = await activeAuthenticator(req);
|
||||
if (!identity) {
|
||||
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
|
||||
return;
|
||||
}
|
||||
res.locals.admin = identity;
|
||||
next();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
};
|
||||
// On failure: 401 when not signed in, 403 when signed in without the tickets
|
||||
// permission.
|
||||
export const requireAdminAuth = requireAppAccess('tickets');
|
||||
|
||||
Reference in New Issue
Block a user