From 9c2cfb7a6f6e7a0fad8b7d769d2d6140044e3859 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 19:49:27 +0000 Subject: [PATCH 1/4] fix: close audit gaps in verification and announcements --- .audit/agent-verification-loop.tsv | 3 +- .dockerignore | 3 + .github/workflows/deploy.yml | 91 ++++++++++++++++++- .github/workflows/staging-e2e.yml | 13 --- AGENTS.md | 13 ++- README.md | 12 ++- e2e/admin-announcements.spec.ts | 31 ++++++- package.json | 4 +- plans/admin-announcements.md | 8 +- plans/agent-verification-loop.md | 4 +- plans/release-checklist.md | 3 +- plans/search-quality.md | 1 + playwright.config.ts | 13 ++- public/sw.js | 1 + src/components/admin/AdminAnnouncements.tsx | 81 ++++++++++++++--- src/components/layout/AnnouncementBanner.tsx | 9 +- src/components/layout/AppLayout.tsx | 2 +- src/lib/announcements.ts | 18 ++++ src/lib/observability.ts | 4 +- src/locales/en.json | 8 +- src/locales/es.json | 8 +- src/locales/hu.json | 8 +- src/test/announcements.test.ts | 32 ++++++- .../components/AdminAnnouncements.test.tsx | 35 +++++++ .../components/AnnouncementBanner.test.tsx | 39 ++++++++ src/test/observability.test.ts | 4 +- src/test/pages/AdminPage.test.tsx | 2 + src/test/runtime-contracts.test.ts | 44 +++++++++ src/test/vercel-deployment-config.test.ts | 4 +- .../functions/_shared/native-cors.test.ts | 5 + supabase/functions/admin/index.ts | 48 +++++++++- supabase/functions/announcements/index.ts | 1 + ...0260831191838_deactivate_announcements.sql | 22 +++++ .../tests/admin_announcements_rls_test.sql | 21 ++++- 34 files changed, 532 insertions(+), 63 deletions(-) create mode 100644 src/test/components/AnnouncementBanner.test.tsx create mode 100644 src/test/runtime-contracts.test.ts create mode 100644 supabase/migrations/20260831191838_deactivate_announcements.sql diff --git a/.audit/agent-verification-loop.tsv b/.audit/agent-verification-loop.tsv index d311de7d9..33d2f2d89 100644 --- a/.audit/agent-verification-loop.tsv +++ b/.audit/agent-verification-loop.tsv @@ -1,7 +1,7 @@ ts phase decision why evidence result 2026-08-31T15:27:00Z isolation created t3code/agent-verification-loop in its own T3 worktree kept the dirty main checkout and other worktrees untouched git worktree list clean isolated branch 2026-08-31T15:35:00Z browser made port 18080 loopback-only and disabled server reuse prevented another checkout on port 8080 from producing false browser results playwright.config.ts isolated server configured -2026-08-31T15:35:30Z staging kept real auth tokens out of traces and uploaded artifacts Playwright traces and storage state can contain bearer and refresh tokens playwright.config.ts, .github/workflows/deploy.yml secret-bearing artifacts excluded +2026-08-31T15:35:30Z staging disabled authenticated traces and excluded auth state traces and storage state can contain bearer and refresh tokens playwright.config.ts, .github/workflows/staging-e2e.yml partial: inherited failure video was still uploaded 2026-08-31T15:36:00Z staging used a unique user-owned source and exact UI deletion proved persistence while limiting cleanup to the row created by the test e2e/staging/authed-flows.spec.ts bounded cleanup implemented 2026-08-31T15:38:00Z local gates ran lint, typecheck, focused sparring tests, and Playwright discovery caught compile and selector problems before the full browser matrix local command output 14 focused tests and 168 browser cases listed 2026-08-31T15:39:00Z staging proved partial and missing staging configuration fails closed never substitute production or a skipped fake session for missing protected values playwright.config.ts both negative preflights exited 1 @@ -17,3 +17,4 @@ ts phase decision why evidence result 2026-08-31T16:56:00Z CodeRabbit fixed all four current-head review comments restricted trusted staging triggers and loopback auth, updated plan state, and sequenced Jam before app startup PR 21 review on 5f0f67a all findings implemented or bounded with deterministic proof 2026-08-31T16:59:00Z local gates reran CI-equivalent checks after CodeRabbit fixes review changes affected workflow security and application bootstrap npm run verify, Playwright 1.60 Noble container 89 files, 930 tests, coverage, build, 112 Chromium and 56 WebKit cases passed 2026-08-31T17:06:00Z CodeRabbit added a bounded Jam CDN timeout prevented a stalled recorder request from blocking application startup src/jam-loader.ts, src/test/jam-loader.test.ts 20 focused tests, lint, typecheck, build, Chromium and WebKit startup passed +2026-08-31T19:48:03Z staging disabled traces, screenshots, and video and removed staging artifact uploads all browser captures can expose authenticated staging data playwright.config.ts, .github/workflows/staging-e2e.yml no authenticated browser artifacts retained or uploaded diff --git a/.dockerignore b/.dockerignore index dc0c06332..9435529b3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ node_modules build +coverage +playwright-report +test-results .git .gitignore .env* diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0edcb15ea..bdad02ff7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,10 +14,8 @@ concurrency: cancel-in-progress: true jobs: - build: + quality: runs-on: ubuntu-latest - env: - DOCKER_PUSH: ${{ github.event_name == 'push' && secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} steps: - name: Checkout code uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -32,8 +30,11 @@ jobs: - name: Install dependencies run: npm ci - - name: Install Chromium - run: npx playwright install --with-deps chromium + - name: Set up Deno + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: 2.9.5 + cache: true - name: Lint run: npm run lint @@ -44,9 +45,71 @@ jobs: - name: Test run: npm test -- --coverage + - name: Edge function tests + run: npm run test:edge + - name: Build run: npm run build + - name: Upload production assets + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: production-assets-${{ github.sha }} + path: dist/ + if-no-files-found: error + retention-days: 1 + + database-tests: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Set up Supabase CLI + uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 + with: + version: 2.115.0 + + - name: Start isolated database and apply migrations + run: supabase db start + + - name: Database policy tests + run: npm run test:db + + - name: Stop isolated database + if: always() + run: supabase stop --no-backup + + browser-tests: + if: github.event_name == 'pull_request' + needs: quality + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Browser tests env: CI: 1 @@ -65,6 +128,24 @@ jobs: if-no-files-found: ignore retention-days: 7 + docker: + if: github.event_name != 'pull_request' + needs: quality + runs-on: ubuntu-latest + env: + DOCKER_PUSH: ${{ github.event_name == 'push' && secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Download production assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: production-assets-${{ github.sha }} + path: dist/ + - name: Set up QEMU if: env.DOCKER_PUSH == 'true' uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index 7962d264d..a15d4f7d5 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -68,16 +68,3 @@ jobs: STAGING_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }} STAGING_DEMO_CODE: ${{ secrets.STAGING_DEMO_CODE }} run: npm run test:e2e:staging - - - name: Upload staging failure artifacts - if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: staging-browser-failure-${{ github.run_id }} - path: | - test-results/**/*.png - test-results/**/*.webm - !test-results/.auth/ - !test-results/**/*.zip - if-no-files-found: ignore - retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index c4e5a83ec..0bbdd7eba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,21 @@ Run the smallest relevant reproduction first, then the complete gate before a PR npm run lint npm run typecheck npm test -- --coverage +npm run test:edge npm run build CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e ``` `npm run verify` runs that full credential-free ladder. It starts an isolated Vite server and executes the desktop and mobile Chromium projects. Browser failures stay in ignored `test-results/` and `playwright-report/` directories. CI uploads those directories for seven days when the browser step fails. +Database migrations and pgTAP policies have a separate Docker-backed gate. Supabase CLI 2.115 or newer is required so `CREATE INDEX CONCURRENTLY` runs outside the migration batch: + +```bash +supabase db start +npm run test:db +supabase stop --no-backup +``` + WebKit is an additional release smoke gate: ```bash @@ -46,11 +55,11 @@ STAGING_DEMO_CODE= \ npm run test:e2e:staging ``` -The suite rejects the production project, checks that the anon key belongs to the supplied staging URL, authenticates through `demo-code-login`, proves session reload, writes and reads back one unique source, makes one bounded AI request, and deletes only that source. It saves temporary auth state under `test-results/.auth/`. Never upload that directory or a staging trace because both can contain session tokens. +The suite rejects the production project, checks that the anon key belongs to the supplied staging URL, authenticates through `demo-code-login`, proves session reload, writes and reads back one unique source, makes one bounded AI request, and deletes only that source. It saves temporary auth state under `test-results/.auth/`. Staging disables traces, screenshots, and video, and its workflow uploads no browser artifacts. GitHub runs staging only for `main` push or a main-branch manual dispatch, through the protected `staging` environment, after the credential-free build job passes. Set the repository variable `STAGING_E2E_ENABLED=true` only after the protected environment has the three secrets, current staging functions, schema, provider secret, dedicated unlimited-use test code, and a main-only deployment rule. A skipped staging job is not a pass. -The staging origin `http://127.0.0.1:18080` must be deployed in the CORS allowlists for `demo-code-login`, `ai`, and `ai-stream` before enabling the gate. Do not deploy those functions or apply migrations to an unverified project. +The staging origin `http://127.0.0.1:18080` must be deployed in the CORS allowlists for `demo-code-login`, `ai`, `ai-stream`, and `announcements` before enabling the gate. Do not deploy those functions or apply migrations to an unverified project. ## Working rules diff --git a/README.md b/README.md index 59d3442e1..ef7027a81 100644 --- a/README.md +++ b/README.md @@ -1354,7 +1354,7 @@ Run the full credential-free verification ladder with one command: npm run verify ``` -It runs ESLint, TypeScript, the full selected-scope coverage suite, the production build, and desktop plus mobile Chromium against an isolated Vite server on `127.0.0.1:18080`. The browser runner never reuses an existing server. Failure traces, screenshots, videos, and the HTML report are written to ignored `test-results/` and `playwright-report/` directories. +It runs ESLint, TypeScript, the full selected-scope coverage suite, native Deno Edge Function tests, the production build, and desktop plus mobile Chromium against an isolated Vite server on `127.0.0.1:18080`. The browser runner never reuses an existing server. Failure traces, screenshots, videos, and the HTML report are written to ignored `test-results/` and `playwright-report/` directories. Useful focused commands: @@ -1362,6 +1362,14 @@ Useful focused commands: # Unit and Supabase function tests with enforced coverage npm test -- --coverage +# Native Deno Edge Function tests, including the exported handler boundary +npm run test:edge + +# Database migration replay and pgTAP policy tests, with local Supabase running +supabase db start +npm run test:db +supabase stop --no-backup + # One focused unit test npx vitest run src/test/security/url-validation.test.ts @@ -1383,7 +1391,7 @@ STAGING_DEMO_CODE= \ npm run test:e2e:staging ``` -Temporary auth state lives under `test-results/.auth/`. Never upload it or staging traces. GitHub exposes staging secrets only to the protected `staging` environment on `main`, and only after `STAGING_E2E_ENABLED=true` is set. Pull requests remain credential-free. +Temporary auth state lives under `test-results/.auth/`. Staging disables traces, screenshots, and video, and its workflow uploads no browser artifacts. GitHub exposes staging secrets only to the protected `staging` environment on `main`, and only after `STAGING_E2E_ENABLED=true` is set. Pull requests remain credential-free. A green local run does not mean the current GitHub head passed. Check the PR run itself. If GitHub Actions cannot start because of billing or spending limits, report that as an external exception. For bugs, add a failing reproduction first when the repository has a suitable test layer, and state plainly when browser or real-staging proof did not run. diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 1d189ad15..498e615e4 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -48,8 +48,9 @@ test('user sees and permanently dismisses an active announcement', async ({ page expect(dismissBody).toEqual({ announcementId: ANNOUNCEMENT_ID }); }); -test('admin creates a targeted announcement and deletes it from history', async ({ page }) => { +test('admin can deactivate without losing history, then explicitly delete', async ({ page }) => { let createBody: Record | null = null; + let deactivateBody: Record | null = null; let deleteBody: Record | null = null; await page.route('**/functions/v1/announcements*', (route) => route.fulfill({ json: { announcements: [] } })); @@ -90,6 +91,27 @@ test('admin creates a targeted announcement and deletes it from history', async target_user_ids: [TARGET_USER_ID], starts_at: '2026-08-31T12:00:00.000Z', expires_at: null, + deactivated_at: null, + created_by: '99999999-9999-4999-8999-999999999999', + created_at: '2026-08-31T12:00:00.000Z', + }, + }, + }); + return; + } + if (action === 'deactivate-announcement') { + deactivateBody = request.postDataJSON(); + await route.fulfill({ + json: { + announcement: { + id: ANNOUNCEMENT_ID, + title: 'Targeted note', + body: 'This message is only for the selected user.', + target_type: 'users', + target_user_ids: [TARGET_USER_ID], + starts_at: '2026-08-31T12:00:00.000Z', + expires_at: null, + deactivated_at: '2026-08-31T19:30:00.000Z', created_by: '99999999-9999-4999-8999-999999999999', created_at: '2026-08-31T12:00:00.000Z', }, @@ -125,6 +147,13 @@ test('admin creates a targeted announcement and deletes it from history', async targetUserIds: [TARGET_USER_ID], }); + await page.getByRole('button', { name: 'Deactivate announcement: Targeted note' }).click(); + await page.getByRole('button', { name: 'Deactivate announcement' }).click(); + + await expect.poll(() => deactivateBody).toEqual({ announcementId: ANNOUNCEMENT_ID }); + await expect(page.getByRole('heading', { name: 'Targeted note' })).toBeVisible(); + await expect(page.getByText('Deactivated', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Delete announcement: Targeted note' }).click(); await page.getByRole('button', { name: 'Delete announcement' }).click(); diff --git a/package.json b/package.json index ac2ae2630..7ae8f3dbf 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", + "test:edge": "deno test --allow-read --allow-env=EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENAI_API_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:db": "supabase test db", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", "test:e2e:staging": "playwright test --project=staging", "test:e2e:ui": "playwright test --ui", - "verify": "npm run lint && npm run typecheck && npm test -- --coverage && npm run build && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" + "verify": "npm run lint && npm run typecheck && npm test -- --coverage && npm run test:edge && npm run build && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" }, "dependencies": { "@capacitor/app": "^8.1.0", diff --git a/plans/admin-announcements.md b/plans/admin-announcements.md index 9f11eeae1..af743616a 100644 --- a/plans/admin-announcements.md +++ b/plans/admin-announcements.md @@ -15,6 +15,7 @@ Create `admin_announcements`: - `target_user_ids uuid[] null` - `starts_at timestamptz default now()` - `expires_at timestamptz null` +- `deactivated_at timestamptz null` - `created_by uuid not null` - `created_at timestamptz default now()` @@ -30,7 +31,8 @@ Create `user_announcement_dismissals`: 1. Add admin endpoints to existing `admin` edge function: - create announcement. - list announcements. - - deactivate/delete announcement. + - deactivate an announcement without deleting dismissal history. + - explicitly delete an announcement when permanent removal is intended. 2. Add authenticated non-admin endpoint: - fetch active announcements for current user. - dismiss announcement. @@ -45,8 +47,8 @@ Create `user_announcement_dismissals`: ## Tests -- RLS/admin endpoint tests where practical. -- Admin UI tests for create/list/delete. +- RLS/admin endpoint tests, including deactivation and dismissal-history retention. +- Admin UI tests for create/list/deactivate/delete. - Layout test for showing and dismissing an announcement. ## Deployment diff --git a/plans/agent-verification-loop.md b/plans/agent-verification-loop.md index 0e5304033..af917971b 100644 --- a/plans/agent-verification-loop.md +++ b/plans/agent-verification-loop.md @@ -181,8 +181,8 @@ npm run test:e2e:staging The CI staging job may run only from a protected environment on an authorized `main` push or explicitly authorized main-branch dispatch. It must not run on -pull requests or arbitrary branch dispatches. The job should upload the same -failure artifacts without exposing secret-bearing logs. +pull requests or arbitrary branch dispatches. It disables traces, screenshots, +and video and uploads no authenticated browser artifacts. ### 4. Add the verification playbook diff --git a/plans/release-checklist.md b/plans/release-checklist.md index 13c12c715..39cadbe1c 100644 --- a/plans/release-checklist.md +++ b/plans/release-checklist.md @@ -17,7 +17,8 @@ Run every step before merging this branch. Nothing here deploys anything; deploy - [ ] `npm test` — full vitest suite green - [ ] `npm run build` — production build succeeds - [ ] `deno check supabase/functions/ai/index.ts supabase/functions/ai-stream/index.ts supabase/functions/admin/index.ts` -- [ ] `deno test --allow-read --allow-env=EXA_API_KEY --allow-net=127.0.0.1 supabase/functions/` — includes cost calculator, research/quality-floor, prompt-layer, and control-matrix suites +- [ ] `npm run test:edge` — native Deno coverage including the exported handler boundary +- [ ] `supabase db start && npm run test:db` — migration replay and pgTAP policy coverage with Supabase CLI 2.115 or newer ## 2. Migration review (before any deploy) diff --git a/plans/search-quality.md b/plans/search-quality.md index 90cf0f395..e66505428 100644 --- a/plans/search-quality.md +++ b/plans/search-quality.md @@ -54,5 +54,6 @@ Deferred: - None. The remaining search-quality scope was completed on `t3code/search-quality-completion`: - Exa search requests use the documented camelCase fields (`includeDomains`, `excludeDomains`, `category`, `startPublishedDate`, `endPublishedDate`, `numResults`, and `contents.text.maxCharacters`) instead of inline `site:` operators. Domain/category filters retry without the restrictive filter when they return no results. - The exported Edge handler has a Deno boundary test that mocks authentication, quota, Exa search/content, and the model response, then verifies citation quality and consulted-source provenance in the HTTP response. + - `npm run test:edge` executes that boundary under Deno, and the pull-request quality job runs the same command. - Factoids returns `discoveredSources`, autosaves their provenance, and renders the shared consulted-source quality list, including the weak-source fallback warning. - Authenticated Playwright coverage exercises the Factoids flow on isolated desktop and mobile Chromium projects. diff --git a/playwright.config.ts b/playwright.config.ts index ac118021b..605f4ca54 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,12 @@ const stagingValues = [ const stagingEnabled = stagingValues.every((value) => Boolean(value?.trim())); const stagingPartiallyConfigured = stagingValues.some((value) => Boolean(value?.trim())) && !stagingEnabled; +export const STAGING_CAPTURE_POLICY = { + trace: 'off', + screenshot: 'off', + video: 'off', +} as const; + if (stagingPartiallyConfigured) { throw new Error('Protected staging E2E requires STAGING_SUPABASE_URL, STAGING_SUPABASE_ANON_KEY, and STAGING_DEMO_CODE together.'); } @@ -48,7 +54,7 @@ export default defineConfig({ ...(stagingEnabled ? [{ name: 'staging-setup', testMatch: /staging\/auth\.setup\.ts/, - use: { trace: 'off' }, + use: STAGING_CAPTURE_POLICY, }, { name: 'staging', @@ -57,9 +63,8 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: stagingStorageState, - // Traces contain request headers and localStorage. Do not retain a - // staging trace because it would archive the real session token. - trace: 'off', + // Browser captures can archive authenticated staging data. + ...STAGING_CAPTURE_POLICY, }, }] : []), ], diff --git a/public/sw.js b/public/sw.js index 96590170e..bbc72146f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -10,6 +10,7 @@ const APP_SHELL = [ '/app/brainstorm', '/app/factoids', '/app/ask', + '/app/crossfire', '/app/library', '/app/timer', '/app/sparring', diff --git a/src/components/admin/AdminAnnouncements.tsx b/src/components/admin/AdminAnnouncements.tsx index fc01da1a5..8f92c4055 100644 --- a/src/components/admin/AdminAnnouncements.tsx +++ b/src/components/admin/AdminAnnouncements.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; -import { Loader2, Megaphone, Search, Trash2 } from 'lucide-react'; +import { Ban, Loader2, Megaphone, Search, Trash2 } from 'lucide-react'; import { createAnnouncement, + deactivateAnnouncement, deleteAnnouncement, listAdminAnnouncements, type AdminAnnouncement, @@ -45,6 +46,7 @@ function isTargetType(value: string): value is AnnouncementTargetType { function getStatus(announcement: AdminAnnouncement) { const now = Date.now(); + if (announcement.deactivatedAt) return 'Deactivated'; if (new Date(announcement.startsAt).getTime() > now) return 'Scheduled'; if (announcement.expiresAt && new Date(announcement.expiresAt).getTime() <= now) return 'Expired'; return 'Active'; @@ -54,7 +56,9 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { const [announcements, setAnnouncements] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); + const [deactivating, setDeactivating] = useState(false); const [deleting, setDeleting] = useState(false); + const [deactivateTarget, setDeactivateTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [title, setTitle] = useState(''); const [body, setBody] = useState(''); @@ -159,6 +163,27 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { } }; + const handleDeactivate = async () => { + if (!deactivateTarget) return; + try { + setDeactivating(true); + const announcement = await deactivateAnnouncement(deactivateTarget.id); + setAnnouncements((current) => current.map((item) => ( + item.id === announcement.id ? announcement : item + ))); + setDeactivateTarget(null); + toast({ title: 'Announcement deactivated' }); + } catch (error) { + toast({ + title: 'Could not deactivate announcement', + description: error instanceof Error ? error.message : 'Try again.', + variant: 'destructive', + }); + } finally { + setDeactivating(false); + } + }; + return (
@@ -330,17 +355,31 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { {announcement.expiresAt && Expires {formatEuropeanDateTime(announcement.expiresAt)}}
- +
+ {(getStatus(announcement) === 'Active' || getStatus(announcement) === 'Scheduled') && ( + + )} + +
))} @@ -349,6 +388,24 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { + !open && setDeactivateTarget(null)}> + + + Deactivate this announcement? + + Users will stop seeing it. The announcement and its dismissal history will remain available. + + + + Cancel + + {deactivating && + + + + !open && setDeleteTarget(null)}> diff --git a/src/components/layout/AnnouncementBanner.tsx b/src/components/layout/AnnouncementBanner.tsx index 654c81b36..7b2fa8496 100644 --- a/src/components/layout/AnnouncementBanner.tsx +++ b/src/components/layout/AnnouncementBanner.tsx @@ -1,4 +1,5 @@ import { Loader2, Megaphone, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import type { UserAnnouncement } from '@/lib/announcements'; @@ -19,10 +20,12 @@ export function AnnouncementBanner({ error, onDismiss, }: AnnouncementBannerProps) { + const { t } = useTranslation(); + return (
@@ -32,7 +35,7 @@ export function AnnouncementBanner({

{announcement.title}

{total > 1 && ( - {position} of {total} + {t('announcements.position', { position, total })} )}
@@ -48,7 +51,7 @@ export function AnnouncementBanner({ className="h-11 w-11 shrink-0" onClick={onDismiss} disabled={dismissing} - aria-label={`Dismiss announcement: ${announcement.title}`} + aria-label={t('announcements.dismissLabel', { title: announcement.title })} > {dismissing ? (