diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 0c0d201..839fc3b 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -44,7 +44,7 @@
  • Series @@ -66,6 +66,14 @@ Board Channels
  • +
  • + + Tags + +
  • @@ -178,6 +186,15 @@ Board Channels
  • +
  • + + Tags + +
  • @@ -98,7 +98,7 @@
    Cancel @@ -211,7 +211,7 @@ const createSeries = async (redirectAfter = true) => { if (redirectAfter) { setTimeout(() => { - router.push('/admin/series-management') + router.push('/admin/series') }, 1500) } diff --git a/app/pages/admin/series-management.vue b/app/pages/admin/series/index.vue similarity index 100% rename from app/pages/admin/series-management.vue rename to app/pages/admin/series/index.vue diff --git a/app/pages/admin/tags/index.vue b/app/pages/admin/tags/index.vue new file mode 100644 index 0000000..79ab0cf --- /dev/null +++ b/app/pages/admin/tags/index.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/e2e/admin-series.spec.js b/e2e/admin-series.spec.js index 897bdb0..ed0f6d8 100644 --- a/e2e/admin-series.spec.js +++ b/e2e/admin-series.spec.js @@ -2,7 +2,7 @@ import { test, expect } from './helpers/fixtures.js' test.describe('Admin series management page', () => { test('series list loads for admin', async ({ adminPage }) => { - await adminPage.goto('/admin/series-management') + await adminPage.goto('/admin/series') await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({ timeout: 15000, }) @@ -12,9 +12,9 @@ test.describe('Admin series management page', () => { test.describe('Admin series access control', () => { test('non-admin redirect', async ({ page }) => { - await page.goto('/admin/series-management') + await page.goto('/admin/series') await page.waitForURL((url) => !url.pathname.startsWith('/admin')) - expect(page.url()).not.toContain('/admin/series-management') + expect(page.url()).not.toContain('/admin/series') }) }) @@ -39,7 +39,7 @@ test.describe('Admin series CRUD', () => { await adminPage.getByRole('button', { name: 'Create Series' }).click() - await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 }) + await adminPage.waitForURL('**/admin/series', { timeout: 15000 }) const card = adminPage.locator('.series-card', { hasText: title }) await expect(card).toBeVisible({ timeout: 10000 }) diff --git a/e2e/admin-tags.spec.js b/e2e/admin-tags.spec.js new file mode 100644 index 0000000..58f47c2 --- /dev/null +++ b/e2e/admin-tags.spec.js @@ -0,0 +1,57 @@ +import { test, expect } from './helpers/fixtures.js' + +test.describe('Admin tags page', () => { + test('page loads for admin', async ({ adminPage }) => { + await adminPage.goto('/admin/tags') + await expect(adminPage.getByRole('heading', { name: 'Tags', level: 1 })).toBeVisible({ + timeout: 15000, + }) + }) + + test('approve and reject pending suggestions', async ({ adminPage }) => { + const suffix = Date.now().toString().slice(-6) + const approveLabel = `e2e-tag-approve-${suffix}` + const rejectLabel = `e2e-tag-reject-${suffix}` + + // Load the page first so the csrf-token cookie is set, then seed two + // pending suggestions via the authed member endpoint (state-changing + // requests require the double-submit CSRF header — see middleware/01.csrf.js). + await adminPage.goto('/admin/tags') + const cookies = await adminPage.context().cookies() + const csrf = cookies.find((c) => c.name === 'csrf-token')?.value + + await adminPage.request.post('/api/tags/suggest', { + headers: { 'x-csrf-token': csrf }, + data: { label: approveLabel, pool: 'craft' }, + }) + await adminPage.request.post('/api/tags/suggest', { + headers: { 'x-csrf-token': csrf }, + data: { label: rejectLabel, pool: 'cooperative' }, + }) + + await adminPage.reload() + await adminPage.waitForLoadState('networkidle') + + const approveRow = adminPage.locator('tr', { hasText: approveLabel }) + await expect(approveRow).toBeVisible({ timeout: 10000 }) + await approveRow.getByRole('button', { name: 'Approve' }).click() + await expect(adminPage.locator('tr', { hasText: approveLabel })).toHaveCount(0, { + timeout: 10000, + }) + + const rejectRow = adminPage.locator('tr', { hasText: rejectLabel }) + await expect(rejectRow).toBeVisible() + await rejectRow.getByRole('button', { name: 'Reject' }).click() + await expect(adminPage.locator('tr', { hasText: rejectLabel })).toHaveCount(0, { + timeout: 10000, + }) + }) +}) + +test.describe('Admin tags access control', () => { + test('non-admin redirect', async ({ page }) => { + await page.goto('/admin/tags') + await page.waitForURL((url) => !url.pathname.startsWith('/admin')) + expect(page.url()).not.toContain('/admin/tags') + }) +}) diff --git a/server/api/admin/tag-suggestions/[id].patch.js b/server/api/admin/tag-suggestions/[id].patch.js new file mode 100644 index 0000000..4ce0570 --- /dev/null +++ b/server/api/admin/tag-suggestions/[id].patch.js @@ -0,0 +1,43 @@ +import TagSuggestion from '../../../models/tagSuggestion.js' +import Tag from '../../../models/tag.js' +import { connectDB } from '../../../utils/mongoose.js' +import { requireAdmin } from '../../../utils/auth.js' +import { validateBody } from '../../../utils/validateBody.js' +import { tagSuggestionReviewSchema } from '../../../utils/schemas.js' + +const slugify = (s) => + s + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + await connectDB() + + const id = getRouterParam(event, 'id') + const { action } = await validateBody(event, tagSuggestionReviewSchema) + + const suggestion = await TagSuggestion.findById(id).lean() + if (!suggestion) { + throw createError({ statusCode: 404, statusMessage: 'Tag suggestion not found' }) + } + if (suggestion.status !== 'pending') { + throw createError({ statusCode: 409, statusMessage: 'Tag suggestion already reviewed' }) + } + + if (action === 'approve') { + const slug = slugify(suggestion.label) + if (slug && !(await Tag.findOne({ slug }))) { + await Tag.create({ slug, label: suggestion.label, pool: suggestion.pool, active: true }) + } + } + + const status = action === 'approve' ? 'approved' : 'rejected' + await TagSuggestion.findByIdAndUpdate(id, { status }, { runValidators: false }) + + return { suggestion: { id, status } } +}) diff --git a/server/api/admin/tag-suggestions/index.get.js b/server/api/admin/tag-suggestions/index.get.js new file mode 100644 index 0000000..1ba049a --- /dev/null +++ b/server/api/admin/tag-suggestions/index.get.js @@ -0,0 +1,23 @@ +import TagSuggestion from '../../../models/tagSuggestion.js' +import { connectDB } from '../../../utils/mongoose.js' +import { requireAdmin } from '../../../utils/auth.js' + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + await connectDB() + + const suggestions = await TagSuggestion.find({ status: 'pending' }) + .sort({ createdAt: 1 }) + .populate('suggestedBy', 'name') + .lean() + + return { + suggestions: suggestions.map((s) => ({ + id: String(s._id), + label: s.label, + pool: s.pool, + suggestedBy: s.suggestedBy?.name || 'Unknown', + createdAt: s.createdAt + })) + } +}) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 84ccf96..8504534 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -388,6 +388,10 @@ export const adminTagCreateSchema = z.object({ pool: z.enum(['craft', 'cooperative']) }) +export const tagSuggestionReviewSchema = z.object({ + action: z.enum(['approve', 'reject']) +}) + // --- Board post / channel schemas --- export const boardPostCreateSchema = z.object({