Put the feedback and tickets admin areas behind the shared identity
feedback.auth.ts and tickets.auth.ts each become one binding to requireAppAccess. Everything downstream was already written against requireAdminAuth and res.locals.admin, and both still mean what they meant, so no router or service changed. What changed is the policy: an activated @nachklang.art account is no longer sufficient, an explicit per-app permission is. Three things followed from that and are not obvious from the diff: - APP_ORIGINS gets a production default. It feeds better-auth's trustedOrigins, and this is the first time the tickets and feedback origins matter there - before, the only browser origin that ever reached /admin/auth was the admin app itself. An origin missing from that list fails in a way that is easy to misread: sign-in works, the app works, and only sign-out returns an origin error. - Nothing reads X-Session-* any more; these two files were the last readers, and the calendar module passes its session in query parameters. The headers stay in the CORS allowedHeaders only so a browser still running a pre-cutover bundle gets a clean 401 rather than a preflight failure, and can come out once both frontends are deployed. - 40 admin operations documented a required X-Session-Id/X-Session-Key in swagger. They now declare the AdminSessionCookie scheme the admin module already defined, and each documents a 403 next to its 401. The integration assertions flip as their own comment predicted: one admin cookie opens both /feedback/admin/me and /tickets/admin/me, a user holding only feedback gets 200 and 403 respectively, and a legacy header session gets 401. The unit test that covered the old header authenticator is replaced by one asserting each module is bound to its own app and that neither consults the calendar users service. 163 unit tests and 43 integration tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,6 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* parameters:
|
||||
* SessionIdHeader:
|
||||
* in: header
|
||||
* name: X-Session-Id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* SessionKeyHeader:
|
||||
* in: header
|
||||
* name: X-Session-Key
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* schemas:
|
||||
* EventAdminSummary:
|
||||
* type: object
|
||||
|
||||
@@ -26,9 +26,8 @@ adminRouter.use(requireAdminAuth);
|
||||
* summary: Validate the current admin session
|
||||
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
@@ -43,6 +42,8 @@ adminRouter.use(requireAdminAuth);
|
||||
* type: string
|
||||
* 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});
|
||||
@@ -55,9 +56,9 @@ adminRouter.get('/me', (req: Request, res: Response) => {
|
||||
* summary: Delete a single submission
|
||||
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: submissionId
|
||||
* required: true
|
||||
@@ -70,6 +71,8 @@ adminRouter.get('/me', (req: Request, res: Response) => {
|
||||
* description: Unknown submission
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -18,9 +18,8 @@ export const eventsAdminRouter = express.Router();
|
||||
* summary: List all events (admin)
|
||||
* description: All events, published or not, past or future, with submission counts.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
@@ -32,13 +31,14 @@ export const eventsAdminRouter = express.Router();
|
||||
* $ref: '#/components/schemas/EventAdminSummary'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* post:
|
||||
* summary: Create an event
|
||||
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -68,6 +68,8 @@ export const eventsAdminRouter = express.Router();
|
||||
* description: Missing required fields
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -101,9 +103,9 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* summary: Get one event (admin)
|
||||
* description: Full event detail including setlist and assigned questions.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -120,12 +122,14 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* description: Unknown event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* put:
|
||||
* summary: Update an event
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -138,13 +142,15 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* description: Unknown event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* delete:
|
||||
* summary: Delete an event
|
||||
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -163,6 +169,8 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* description: Submissions exist and force was not set
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -214,9 +222,9 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
||||
* get:
|
||||
* summary: Get an event's setlist
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -227,12 +235,14 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* post:
|
||||
* summary: Add a song to an event's setlist
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -257,6 +267,8 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
||||
* description: Missing title
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -292,9 +304,9 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
|
||||
* summary: Bulk reorder an event's setlist
|
||||
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -317,6 +329,8 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
|
||||
* description: Reordered
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -334,9 +348,9 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
|
||||
* get:
|
||||
* summary: Get an event's assigned questions
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -347,13 +361,15 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* put:
|
||||
* summary: Bulk-set an event's assigned questions
|
||||
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -383,6 +399,8 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
|
||||
* description: Saved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -16,9 +16,9 @@ export const questionsAdminRouter = express.Router();
|
||||
* get:
|
||||
* summary: List the question library
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: query
|
||||
* name: includeArchived
|
||||
* schema:
|
||||
@@ -34,12 +34,13 @@ export const questionsAdminRouter = express.Router();
|
||||
* $ref: '#/components/schemas/AdminQuestion'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* post:
|
||||
* summary: Create a question
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -61,6 +62,8 @@ export const questionsAdminRouter = express.Router();
|
||||
* description: Missing or invalid fields
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -94,9 +97,9 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* summary: Edit a question's label/help text
|
||||
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: questionId
|
||||
* required: true
|
||||
@@ -123,13 +126,15 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* description: Unknown question
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* delete:
|
||||
* summary: Archive (or hard-delete) a question
|
||||
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: questionId
|
||||
* required: true
|
||||
@@ -142,6 +147,8 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
* description: Unknown question
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -19,9 +19,9 @@ export const reportsAdminRouter = express.Router();
|
||||
* summary: Aggregated feedback report for one event
|
||||
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -34,6 +34,8 @@ export const reportsAdminRouter = express.Router();
|
||||
* description: Unknown event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -55,9 +57,9 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
|
||||
* summary: Guest Book entries for one event
|
||||
* description: Newest first, paginated.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -81,6 +83,8 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -101,9 +105,9 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
|
||||
* summary: Newsletter signups for one event
|
||||
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -127,6 +131,8 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -147,9 +153,9 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
|
||||
* summary: CSV export of all answers for one event
|
||||
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -162,6 +168,8 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
|
||||
* text/csv: {}
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -187,9 +195,9 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
|
||||
* get:
|
||||
* summary: CSV export of Guest Book entries for one event
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
@@ -202,6 +210,8 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
|
||||
* text/csv: {}
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -16,9 +16,9 @@ export const songsAdminRouter = express.Router();
|
||||
* put:
|
||||
* summary: Edit a song's title/composer
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: songId
|
||||
* required: true
|
||||
@@ -45,13 +45,15 @@ export const songsAdminRouter = express.Router();
|
||||
* description: Unknown song
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
* delete:
|
||||
* summary: Remove a song
|
||||
* description: Past answers keep their song_title_snapshot even after the song is removed.
|
||||
* tags: [feedback-admin]
|
||||
* security:
|
||||
* - AdminSessionCookie: []
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: songId
|
||||
* required: true
|
||||
@@ -64,6 +66,8 @@ export const songsAdminRouter = express.Router();
|
||||
* description: Unknown song
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user