Compare commits
18 Commits
b7d572ea8e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cc11e5704e | |||
| bb499ebf23 | |||
| 8753e84a03 | |||
| eb37476bdb | |||
| 63323c1ca4 | |||
| b39a676e1a | |||
| b2cd4b53fc | |||
| 273a4f3acf | |||
| 7bd186eace | |||
| d200260497 | |||
| 840f06e68e | |||
| e577c02eb3 | |||
| 79bf7de4f2 | |||
| 607209b35f | |||
| 836e4a7eea | |||
| a3468518b3 | |||
| 87c6b55e81 | |||
| 2a32465050 |
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE_URL=https://admin-api.dev.eventhub.local
|
# Same-origin через Vite proxy (см. vite.config.ts) — иначе CORS с localhost → admin-api.
|
||||||
VITE_WS_URL=wss://admin-ws.dev.eventhub.local
|
VITE_API_BASE_URL=
|
||||||
|
VITE_WS_URL=
|
||||||
VITE_APP_TITLE=EventHub Admin
|
VITE_APP_TITLE=EventHub Admin
|
||||||
+307
-12
@@ -1,14 +1,29 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
|
# PR / push: test + push образа (на push).
|
||||||
|
# push master/main: test → push-image → deploy IFT → E2E IFT → deploy stage → E2E stage (один run).
|
||||||
|
# workflow_dispatch: выбор фазы (см. input pipeline).
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, main]
|
branches: [master, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pipeline:
|
||||||
|
description: "Режим (push master = full автоматически)"
|
||||||
|
required: false
|
||||||
|
default: full
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- full
|
||||||
|
- test-only
|
||||||
|
- deploy-ift
|
||||||
|
- e2e-ift
|
||||||
|
- deploy-stage
|
||||||
|
- e2e-stage
|
||||||
|
|
||||||
# Один runner (DESKTOP-SIR8B3R): параллельные workflow ломают кэш act/actions.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: eventhub-frontadmin-ci-${{ github.ref }}
|
group: eventhub-frontadmin-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -18,6 +33,10 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && (github.event.inputs.pipeline == 'full' || github.event.inputs.pipeline == 'test-only'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
outputs:
|
outputs:
|
||||||
@@ -25,23 +44,56 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# Без cache: npm — act_runner отменяет шаг на распаковке ~546MB (exit -1).
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Bake build identity
|
||||||
|
id: build_meta
|
||||||
|
env:
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
APP_VERSION="$(bash scripts/resolve-product-version.sh)"
|
||||||
|
APP_BUILD="${GITHUB_RUN_NUMBER:-0}"
|
||||||
|
GIT_SHA="${GITHUB_SHA:0:12}"
|
||||||
|
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
echo "Bake: version=${APP_VERSION} build=${APP_BUILD} git_sha=${GIT_SHA}"
|
||||||
|
echo "VITE_APP_VERSION=${APP_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
echo "VITE_APP_BUILD=${APP_BUILD}" >> "$GITHUB_ENV"
|
||||||
|
echo "VITE_GIT_SHA=${GIT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
echo "VITE_BUILT_AT=${BUILT_AT}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: bash scripts/install-playwright-chromium.sh
|
||||||
|
|
||||||
|
- name: E2E mock suite
|
||||||
|
run: |
|
||||||
|
# act hostexecutor: зомби vite preview (cwd deleted) держит порт → HTTP 404 → webServer timeout
|
||||||
|
bash scripts/free-e2e-port.sh
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
- name: Upload dist for docker job
|
- name: Upload dist for docker job
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: admin-ui-dist
|
name: admin-ui-dist
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Mark test complete
|
- name: Mark test complete
|
||||||
id: mark_test
|
id: mark_test
|
||||||
if: success()
|
if: success()
|
||||||
@@ -49,7 +101,14 @@ jobs:
|
|||||||
|
|
||||||
push-image:
|
push-image:
|
||||||
needs: test
|
needs: test
|
||||||
if: github.event_name == 'push' && needs.test.outputs.ci_test_ok == '1'
|
if: |
|
||||||
|
always() &&
|
||||||
|
needs.test.result == 'success' &&
|
||||||
|
needs.test.outputs.ci_test_ok == '1' &&
|
||||||
|
(
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -90,13 +149,22 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Push admin-ui image
|
- name: Push admin-ui image
|
||||||
|
env:
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
APP_VERSION="$(bash scripts/resolve-product-version.sh)"
|
||||||
|
APP_BUILD="${GITHUB_RUN_NUMBER:-0}"
|
||||||
TAG="sha-${GITHUB_SHA:0:12}"
|
TAG="sha-${GITHUB_SHA:0:12}"
|
||||||
|
VER_TAG="${APP_VERSION}.${APP_BUILD}"
|
||||||
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
||||||
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:${VER_TAG}"
|
||||||
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:ift"
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:ift"
|
||||||
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:dev"
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:dev"
|
||||||
|
echo "Push tags: ${TAG} ${VER_TAG} ift dev"
|
||||||
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
||||||
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:${VER_TAG}"
|
||||||
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:ift"
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:ift"
|
||||||
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:dev"
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:dev"
|
||||||
|
|
||||||
@@ -112,27 +180,254 @@ jobs:
|
|||||||
if: success()
|
if: success()
|
||||||
run: echo "ok=1" >> "$GITHUB_OUTPUT"
|
run: echo "ok=1" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
deploy-ift:
|
||||||
|
needs: push-image
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'deploy-ift') ||
|
||||||
|
(
|
||||||
|
needs.push-image.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image tag
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for Docker daemon
|
||||||
|
run: bash scripts/ensure-docker-daemon.sh 240
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: git.sabilin.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Verify admin-ui image from CI
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "Образ eventhub-admin-ui:${TAG} не найден — test/push-image не завершились."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: eventhub-admin-ui:${TAG}"
|
||||||
|
|
||||||
|
- name: Deploy admin on IFT via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.IFT_SSH_HOST }}
|
||||||
|
username: ${{ secrets.IFT_SSH_USER }}
|
||||||
|
key: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
||||||
|
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
||||||
|
bash /opt/eventhub-ift/devops/scripts/deploy-service.sh ift admin ${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
e2e-ift:
|
||||||
|
needs: deploy-ift
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'e2e-ift') ||
|
||||||
|
(
|
||||||
|
needs.deploy-ift.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: bash scripts/install-playwright-chromium.sh
|
||||||
|
|
||||||
|
- name: Load smoke credentials from IFT .env
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.IFT_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.IFT_SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
||||||
|
run: bash scripts/load-stand-smoke-env.sh ift
|
||||||
|
|
||||||
|
- name: E2E IFT (integration, live API)
|
||||||
|
env:
|
||||||
|
E2E_TARGET: ift
|
||||||
|
E2E_IFT_BASE_URL: ${{ secrets.E2E_IFT_BASE_URL }}
|
||||||
|
run: npm run test:e2e:ift
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-ift-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
deploy-stage:
|
||||||
|
needs: e2e-ift
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'deploy-stage') ||
|
||||||
|
(
|
||||||
|
needs.e2e-ift.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image tag
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for Docker daemon
|
||||||
|
run: bash scripts/ensure-docker-daemon.sh 240
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: git.sabilin.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Verify admin-ui image from CI
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "Образ eventhub-admin-ui:${TAG} не найден — test push не завершился."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: eventhub-admin-ui:${TAG}"
|
||||||
|
|
||||||
|
- name: Promote sha → :stage (no rebuild)
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
bash scripts/clone-devops-run.sh scripts/promote-images.sh \
|
||||||
|
"${{ steps.meta.outputs.tag }}" \
|
||||||
|
"${{ steps.meta.outputs.tag }}" \
|
||||||
|
stage \
|
||||||
|
eventhub-admin-ui
|
||||||
|
|
||||||
|
- name: Deploy admin on stage via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.STAGE_SSH_HOST }}
|
||||||
|
username: ${{ secrets.STAGE_SSH_USER }}
|
||||||
|
key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
||||||
|
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
||||||
|
bash /opt/eventhub-stage/devops/scripts/deploy-service.sh stage admin ${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Prune old registry images
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: bash scripts/clone-devops-run.sh scripts/prune-registry-images.sh
|
||||||
|
|
||||||
|
e2e-stage:
|
||||||
|
needs: deploy-stage
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'e2e-stage') ||
|
||||||
|
(
|
||||||
|
needs.deploy-stage.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: bash scripts/install-playwright-chromium.sh
|
||||||
|
|
||||||
|
- name: Load smoke credentials from stage .env
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.STAGE_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.STAGE_SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||||
|
run: bash scripts/load-stand-smoke-env.sh stage
|
||||||
|
|
||||||
|
- name: E2E stage (integration, live API)
|
||||||
|
env:
|
||||||
|
E2E_TARGET: stage
|
||||||
|
E2E_STAGE_BASE_URL: ${{ secrets.E2E_STAGE_BASE_URL }}
|
||||||
|
run: npm run test:e2e:stage
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-stage-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
ci-done:
|
ci-done:
|
||||||
needs: [test, push-image]
|
needs: [test, push-image]
|
||||||
if: always()
|
if: |
|
||||||
|
always() && (
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && (github.event.inputs.pipeline == 'full' || github.event.inputs.pipeline == 'test-only'))
|
||||||
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Enforce CI completion
|
- name: Enforce CI gate (test / push on push & PR)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TEST_OK="${{ needs.test.outputs.ci_test_ok }}"
|
if [ "${{ needs.test.outputs.ci_test_ok }}" != "1" ]; then
|
||||||
if [ "${TEST_OK}" != "1" ]; then
|
echo "::error::test job incomplete"
|
||||||
echo "::error::test job incomplete (runner reported success without output)"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ] || ( [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.pipeline }}" = "full" ] ); then
|
||||||
if [ "${{ needs.push-image.result }}" != "success" ]; then
|
if [ "${{ needs.push-image.result }}" != "success" ]; then
|
||||||
echo "::error::push-image must succeed, got: ${{ needs.push-image.result }}"
|
echo "::error::push-image must succeed, got: ${{ needs.push-image.result }}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "${{ needs.push-image.outputs.ci_push_ok }}" != "1" ]; then
|
if [ "${{ needs.push-image.outputs.ci_push_ok }}" != "1" ]; then
|
||||||
echo "::error::push-image incomplete (output missing)"
|
echo "::error::push-image incomplete"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
name: Deploy IFT (admin)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [CI]
|
|
||||||
types: [completed]
|
|
||||||
branches: [master, main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: eventhub-frontadmin-deploy-ift
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.sabilin.com/eventhub
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-ift-admin:
|
|
||||||
# Gitea: workflow_run.conclusion часто пустой → job skipped. Проверка образа ниже.
|
|
||||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Compute image tag
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
|
||||||
SHA="${{ github.event.workflow_run.head_sha }}"
|
|
||||||
else
|
|
||||||
SHA="${GITHUB_SHA}"
|
|
||||||
fi
|
|
||||||
echo "tag=sha-${SHA:0:12}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Wait for Docker daemon
|
|
||||||
run: bash scripts/ensure-docker-daemon.sh 240
|
|
||||||
|
|
||||||
- name: Login to registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: git.sabilin.com
|
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Verify admin-ui image from CI
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
|
|
||||||
echo "Образ eventhub-admin-ui:${TAG} не найден — CI push не завершился (ложный success runner?)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "OK: eventhub-admin-ui:${TAG}"
|
|
||||||
|
|
||||||
- name: Deploy admin on IFT via SSH
|
|
||||||
uses: appleboy/ssh-action@v0.1.5
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.IFT_SSH_HOST }}
|
|
||||||
username: ${{ secrets.IFT_SSH_USER }}
|
|
||||||
key: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
|
||||||
script: |
|
|
||||||
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
|
||||||
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
|
||||||
bash /opt/eventhub-ift/devops/scripts/deploy-service.sh ift admin ${{ steps.meta.outputs.tag }}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
name: Deploy stage (admin)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
release_tag:
|
|
||||||
description: 'Release tag (например v0.1.1)'
|
|
||||||
required: true
|
|
||||||
default: 'v0.1.1'
|
|
||||||
sha_tag:
|
|
||||||
description: 'Образ sha-* (например sha-3f5dde498652)'
|
|
||||||
required: true
|
|
||||||
default: 'sha-3f5dde498652'
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.sabilin.com/eventhub
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-stage-admin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Compute image tags
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "sha_tag=${{ github.event.inputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_tag=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "sha_tag=sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: git.sabilin.com
|
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Promote admin-ui image (no rebuild)
|
|
||||||
env:
|
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
bash scripts/clone-devops-run.sh scripts/promote-images.sh \
|
|
||||||
"${{ steps.meta.outputs.sha_tag }}" \
|
|
||||||
"${{ steps.meta.outputs.release_tag }}" \
|
|
||||||
stage \
|
|
||||||
eventhub-admin-ui
|
|
||||||
|
|
||||||
- name: Deploy admin on stage via SSH
|
|
||||||
uses: appleboy/ssh-action@v0.1.5
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.STAGE_SSH_HOST }}
|
|
||||||
username: ${{ secrets.STAGE_SSH_USER }}
|
|
||||||
key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
|
||||||
script: |
|
|
||||||
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
|
||||||
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
|
||||||
bash /opt/eventhub-stage/devops/scripts/deploy-service.sh stage admin ${{ steps.meta.outputs.release_tag }}
|
|
||||||
|
|
||||||
- name: Prune old registry images
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: bash scripts/clone-devops-run.sh scripts/prune-registry-images.sh
|
|
||||||
@@ -11,6 +11,10 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
blob-report
|
||||||
|
.playwright
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# E2E testid contract (EventHubFrontAdmin)
|
||||||
|
|
||||||
|
Селекторы: **`data-testid`** + a11y (`getByRole` / `getByLabel`) где имя стабильно.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
| Pattern | Example |
|
||||||
|
|---------|---------|
|
||||||
|
| `page-{name}` | `page-login`, `page-users`, `page-inbox-reports` |
|
||||||
|
| `nav-{slug}` | `nav-users` |
|
||||||
|
| `row-{id}` | dense list / DataTable rows |
|
||||||
|
| `row-activate-{id}` | dense list main click target |
|
||||||
|
| `{entity}-row-{id}` | inbox-specific (`report-row-*`) |
|
||||||
|
| `btn-{action}` | `btn-login`, `btn-save-profile` |
|
||||||
|
| `dialog-{name}` | `dialog-edit-user` |
|
||||||
|
| `filter-{name}` | `filter-users-search` |
|
||||||
|
| `menu-user` / `lang-ru` | avatar dropdown |
|
||||||
|
|
||||||
|
Helper: `src/lib/testid.ts`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- kebab-case; dynamic id = API entity id (or banned word)
|
||||||
|
- Prefer a11y for login fields
|
||||||
|
- Do not decorate decorative wrappers
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
| Команда | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `npm run test:e2e` | mock-suite (CI / PR), API через Playwright route |
|
||||||
|
| `npm run test:e2e:ift` | IFT: полный UI-сьют на живом SPA + smoke API |
|
||||||
|
| `npm run test:e2e:stage` | stage: полный UI-сьют на живом SPA + smoke API |
|
||||||
|
|
||||||
|
### IFT / stage учётки
|
||||||
|
|
||||||
|
Источник истины — env стенда (`EventHubDevOps/ift/.env`, `EventHubDevOps/stage/.env` → на хосте `/opt/eventhub-{ift,stage}/.env`):
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|------------|------------|
|
||||||
|
| `SMOKE_ADMIN_EMAIL` / `SMOKE_ADMIN_PASSWORD` | предпочтительно для smoke/E2E |
|
||||||
|
| `ADMIN_SUPER_EMAIL` / `ADMIN_SUPER_PASSWORD` | fallback, если `SMOKE_ADMIN_*` пустые |
|
||||||
|
|
||||||
|
Локально: `set -a && source /path/to/ift/.env && set +a && npm run test:e2e:ift`.
|
||||||
|
|
||||||
|
Workflow `e2e-ift.yml` / `e2e-stage.yml` читают те же ключи по SSH с `/opt/eventhub-{ift,stage}/.env`. Опц. secrets `E2E_IFT_BASE_URL`, `E2E_STAGE_BASE_URL`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { MOCK_ADMIN } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
export async function loginAsE2EAdmin(page: Page) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await page.waitForURL(/\/(dashboard|inbox)/);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../../src/utils/constants';
|
||||||
|
import { installAdminApiMocks, MOCK_ADMIN, type MockAdmin } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
export async function seedAuthenticatedSession(page: Page, admin: Partial<MockAdmin> = {}) {
|
||||||
|
const user: MockAdmin = { ...MOCK_ADMIN, ...admin };
|
||||||
|
await installAdminApiMocks(page, { user });
|
||||||
|
// Origin + localStorage before app boot (addInitScript alone races under load).
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([accessKey, refreshKey]) => {
|
||||||
|
localStorage.setItem(accessKey, 'e2e-access-token');
|
||||||
|
localStorage.setItem(refreshKey, 'e2e-refresh-token');
|
||||||
|
},
|
||||||
|
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY] as [string, string]
|
||||||
|
);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Учётки superadmin со стенда (CI: load-stand-smoke-env.sh). */
|
||||||
|
export function standAdminEmail(): string | undefined {
|
||||||
|
return process.env.SMOKE_ADMIN_EMAIL || process.env.ADMIN_SUPER_EMAIL || process.env.E2E_ADMIN_EMAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function standAdminPassword(): string | undefined {
|
||||||
|
return (
|
||||||
|
process.env.SMOKE_ADMIN_PASSWORD ||
|
||||||
|
process.env.ADMIN_SUPER_PASSWORD ||
|
||||||
|
process.env.E2E_ADMIN_PASSWORD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStandCredentials(): boolean {
|
||||||
|
return Boolean(standAdminEmail() && standAdminPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реальный login против admin-api стенда (без Playwright route mocks). */
|
||||||
|
export async function loginAsStandAdmin(page: Page) {
|
||||||
|
const email = standAdminEmail();
|
||||||
|
const password = standAdminPassword();
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error('Stand admin credentials missing (SMOKE_ADMIN_* / ADMIN_SUPER_*)');
|
||||||
|
}
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill(password);
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await page.getByTestId('layout-shell').waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { Page, Route } from '@playwright/test';
|
||||||
|
import { createModerationSeed, type ModerationState } from './moderationSeed';
|
||||||
|
import { createContentSeed } from './contentSeed';
|
||||||
|
import { tryHandleContentApi } from './handleContentApi';
|
||||||
|
|
||||||
|
export type MockAdmin = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
language: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_ADMIN: MockAdmin = {
|
||||||
|
id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
nickname: 'E2E Admin',
|
||||||
|
role: 'superadmin',
|
||||||
|
language: 'ru',
|
||||||
|
avatar_url: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function json(route: Route, status: number, body: unknown, headers: Record<string, string> = {}) {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSlice<T>(items: T[], url: URL): { rows: T[]; total: number } {
|
||||||
|
const limit = Number(url.searchParams.get('limit') || 50);
|
||||||
|
const offset = Number(url.searchParams.get('offset') || 0);
|
||||||
|
return { rows: items.slice(offset, offset + limit), total: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockOptions = {
|
||||||
|
user?: MockAdmin;
|
||||||
|
moderation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default admin API mocks for Control Center shell / login / moderation. */
|
||||||
|
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
|
||||||
|
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
|
||||||
|
const moderation: ModerationState | null = options.moderation === false ? null : createModerationSeed();
|
||||||
|
const content = createContentSeed();
|
||||||
|
|
||||||
|
await page.route('**/v1/admin/**', async (route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const method = req.method();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/login')) {
|
||||||
|
const payload = req.postDataJSON() as { email?: string; password?: string };
|
||||||
|
if (payload?.email === MOCK_ADMIN.email && payload?.password === 'e2e-pass') {
|
||||||
|
await json(route, 200, {
|
||||||
|
token: 'e2e-access-token',
|
||||||
|
refresh_token: 'e2e-refresh-token',
|
||||||
|
user: currentUser,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, 401, { error: 'invalid_credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/me')) {
|
||||||
|
await json(route, 200, currentUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PUT' && path.endsWith('/v1/admin/me')) {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<MockAdmin>;
|
||||||
|
currentUser = { ...currentUser, ...payload };
|
||||||
|
await json(route, 200, currentUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/health')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'eventhub',
|
||||||
|
version: '0.1',
|
||||||
|
build: 1,
|
||||||
|
git_sha: 'e2etestsha000',
|
||||||
|
built_at: '2026-07-14T00:00:00Z',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
users_total: 10,
|
||||||
|
events_total: 5,
|
||||||
|
reviews_total: moderation?.reviews.length ?? 0,
|
||||||
|
calendars_total: 3,
|
||||||
|
tickets_total: moderation?.tickets.length ?? 0,
|
||||||
|
tickets_open: moderation?.tickets.filter((t) => t.status === 'open').length ?? 0,
|
||||||
|
reports_total: moderation?.reports.length ?? 0,
|
||||||
|
reports_pending: moderation?.reports.filter((r) => r.status === 'pending').length ?? 0,
|
||||||
|
reports_reviewed: moderation?.reports.filter((r) => r.status === 'reviewed').length ?? 0,
|
||||||
|
avg_report_resolution_h: 1.5,
|
||||||
|
avg_ticket_resolution_h: 2,
|
||||||
|
events_by_day: [],
|
||||||
|
registrations_by_day: [],
|
||||||
|
reviews_by_day: [],
|
||||||
|
reports_by_day: [],
|
||||||
|
tickets_by_day: [],
|
||||||
|
subscriptions_by_day: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||||
|
// Prefer content seed with sample node (monitoring)
|
||||||
|
if (
|
||||||
|
await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, 200, { nodes: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderation) {
|
||||||
|
// ---- reports ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reports/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_reports: moderation.reports.length,
|
||||||
|
reports_by_target_type: {},
|
||||||
|
reports_by_status: {
|
||||||
|
pending: moderation.reports.filter((r) => r.status === 'pending').length,
|
||||||
|
reviewed: moderation.reports.filter((r) => r.status === 'reviewed').length,
|
||||||
|
dismissed: moderation.reports.filter((r) => r.status === 'dismissed').length,
|
||||||
|
},
|
||||||
|
top_targets_by_reports: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reports')) {
|
||||||
|
let rows = [...moderation.reports];
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
if (status) rows = rows.filter((r) => r.status === status);
|
||||||
|
const pageRows = listSlice(rows, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportMatch = path.match(/\/v1\/admin\/reports\/([^/]+)$/);
|
||||||
|
if (reportMatch) {
|
||||||
|
const id = decodeURIComponent(reportMatch[1]);
|
||||||
|
const idx = moderation.reports.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.reports[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as { status?: MockReportStatus };
|
||||||
|
moderation.reports[idx] = {
|
||||||
|
...moderation.reports[idx],
|
||||||
|
...payload,
|
||||||
|
resolved_at: payload.status ? '2026-07-14T13:00:00Z' : moderation.reports[idx].resolved_at,
|
||||||
|
resolved_by: payload.status ? currentUser.id : moderation.reports[idx].resolved_by,
|
||||||
|
};
|
||||||
|
await json(route, 200, moderation.reports[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tickets ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_tickets: moderation.tickets.length,
|
||||||
|
open: moderation.tickets.filter((t) => t.status === 'open').length,
|
||||||
|
in_progress: moderation.tickets.filter((t) => t.status === 'in_progress').length,
|
||||||
|
resolved: moderation.tickets.filter((t) => t.status === 'resolved').length,
|
||||||
|
closed: moderation.tickets.filter((t) => t.status === 'closed').length,
|
||||||
|
total_errors: moderation.tickets.reduce((s, t) => s + t.count, 0),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets')) {
|
||||||
|
const pageRows = listSlice(moderation.tickets, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketMatch = path.match(/\/v1\/admin\/tickets\/([^/]+)$/);
|
||||||
|
if (ticketMatch) {
|
||||||
|
const id = decodeURIComponent(ticketMatch[1]);
|
||||||
|
const idx = moderation.tickets.findIndex((t) => t.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.tickets[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.tickets)[0]>;
|
||||||
|
moderation.tickets[idx] = { ...moderation.tickets[idx], ...payload };
|
||||||
|
await json(route, 200, moderation.tickets[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reviews ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_reviews: moderation.reviews.length,
|
||||||
|
reviews_by_target_type: {},
|
||||||
|
reviews_by_status: {
|
||||||
|
visible: moderation.reviews.filter((r) => r.status === 'visible').length,
|
||||||
|
hidden: moderation.reviews.filter((r) => r.status === 'hidden').length,
|
||||||
|
deleted: moderation.reviews.filter((r) => r.status === 'deleted').length,
|
||||||
|
},
|
||||||
|
top_targets_by_reviews: [],
|
||||||
|
top_targets_by_positive_reviews: [],
|
||||||
|
top_targets_by_negative_reviews: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews')) {
|
||||||
|
const pageRows = listSlice(moderation.reviews, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PATCH' && path.endsWith('/v1/admin/reviews')) {
|
||||||
|
const updates = (req.postDataJSON() || []) as Array<{ id: string; status: string; reason?: string }>;
|
||||||
|
for (const u of updates) {
|
||||||
|
const idx = moderation.reviews.findIndex((r) => r.id === u.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
moderation.reviews[idx] = {
|
||||||
|
...moderation.reviews[idx],
|
||||||
|
status: u.status as (typeof moderation.reviews)[0]['status'],
|
||||||
|
reason: u.reason ?? moderation.reviews[idx].reason,
|
||||||
|
updated_at: '2026-07-14T13:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await json(route, 200, updates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewMatch = path.match(/\/v1\/admin\/reviews\/([^/]+)$/);
|
||||||
|
if (reviewMatch) {
|
||||||
|
const id = decodeURIComponent(reviewMatch[1]);
|
||||||
|
const idx = moderation.reviews.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.reviews[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.reviews)[0]>;
|
||||||
|
moderation.reviews[idx] = {
|
||||||
|
...moderation.reviews[idx],
|
||||||
|
...payload,
|
||||||
|
updated_at: '2026-07-14T13:00:00Z',
|
||||||
|
};
|
||||||
|
await json(route, 200, moderation.reviews[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, [], { 'X-Total-Count': '0' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockReportStatus = 'pending' | 'reviewed' | 'dismissed';
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/** Content/system seed data for mock E2E (users, calendars, events, …). */
|
||||||
|
|
||||||
|
const now = '2026-07-14T12:00:00Z';
|
||||||
|
|
||||||
|
export function createContentSeed() {
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
id: 'user-e2e-1',
|
||||||
|
email: 'alice@eventhub.local',
|
||||||
|
nickname: 'Alice',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
last_login: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-e2e-2',
|
||||||
|
email: 'bob@eventhub.local',
|
||||||
|
nickname: 'Bob',
|
||||||
|
role: 'user',
|
||||||
|
status: 'frozen',
|
||||||
|
language: 'en',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
last_login: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const calendars = [
|
||||||
|
{
|
||||||
|
id: 'cal-e2e-1',
|
||||||
|
title: 'E2E Calendar',
|
||||||
|
description: 'for tests',
|
||||||
|
type: 'personal',
|
||||||
|
category: 'sport',
|
||||||
|
owner_id: 'user-e2e-1',
|
||||||
|
status: 'active',
|
||||||
|
reason: null,
|
||||||
|
tags: [],
|
||||||
|
color: '#3366ff',
|
||||||
|
image_url: '',
|
||||||
|
rating_avg: 4.5,
|
||||||
|
rating_count: 2,
|
||||||
|
short_name: 'e2ecal',
|
||||||
|
confirmation: 'none',
|
||||||
|
settings: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'evt-e2e-1',
|
||||||
|
calendar_id: 'cal-e2e-1',
|
||||||
|
title: 'E2E Workout',
|
||||||
|
description: 'test event',
|
||||||
|
event_type: 'single',
|
||||||
|
start_time: '2026-01-01T10:00:00Z',
|
||||||
|
duration: 60,
|
||||||
|
recurrence: null,
|
||||||
|
master_id: null,
|
||||||
|
is_instance: false,
|
||||||
|
specialist_id: null,
|
||||||
|
location: null,
|
||||||
|
tags: [],
|
||||||
|
capacity: 10,
|
||||||
|
online_link: null,
|
||||||
|
status: 'active',
|
||||||
|
reason: null,
|
||||||
|
rating_avg: 5,
|
||||||
|
rating_count: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const subscriptions = [
|
||||||
|
{
|
||||||
|
id: 'sub-e2e-1',
|
||||||
|
user_id: 'user-e2e-1',
|
||||||
|
plan: 'monthly',
|
||||||
|
status: 'active',
|
||||||
|
trial_used: false,
|
||||||
|
started_at: now,
|
||||||
|
expires_at: '2027-01-01T00:00:00Z',
|
||||||
|
auto_renew: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bannedWords = [
|
||||||
|
{ word: 'spamword', added_by: 'admin-e2e-1', added_at: now },
|
||||||
|
{ word: 'badword', added_by: 'admin-e2e-1', added_at: now },
|
||||||
|
];
|
||||||
|
|
||||||
|
const admins = [
|
||||||
|
{
|
||||||
|
id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
nickname: 'E2E Admin',
|
||||||
|
role: 'superadmin',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'Europe/Moscow',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-e2e-2',
|
||||||
|
email: 'mod@eventhub.local',
|
||||||
|
nickname: 'Mod Admin',
|
||||||
|
role: 'moderator',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const audit = [
|
||||||
|
{
|
||||||
|
id: 'audit-e2e-1',
|
||||||
|
admin_id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
role: 'superadmin',
|
||||||
|
action: 'update_user',
|
||||||
|
entity_type: 'user',
|
||||||
|
entity_id: 'user-e2e-1',
|
||||||
|
timestamp: now,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
reason: 'e2e',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { users, calendars, events, subscriptions, bannedWords, admins, audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentState = ReturnType<typeof createContentSeed>;
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import type { Route } from '@playwright/test';
|
||||||
|
import type { ContentState } from './contentSeed';
|
||||||
|
|
||||||
|
type JsonFn = (
|
||||||
|
route: Route,
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
function listSlice<T>(items: T[], url: URL): { rows: T[]; total: number } {
|
||||||
|
const limit = Number(url.searchParams.get('limit') || 50);
|
||||||
|
const offset = Number(url.searchParams.get('offset') || 0);
|
||||||
|
const q = (url.searchParams.get('q') || '').toLowerCase();
|
||||||
|
let rows = items;
|
||||||
|
if (q) {
|
||||||
|
rows = items.filter((item) => JSON.stringify(item).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
if (status && rows.length && typeof (rows[0] as { status?: string }).status === 'string') {
|
||||||
|
rows = rows.filter((item) => (item as { status?: string }).status === status);
|
||||||
|
}
|
||||||
|
const plan = url.searchParams.get('plan');
|
||||||
|
if (plan) {
|
||||||
|
rows = rows.filter((item) => (item as { plan?: string }).plan === plan);
|
||||||
|
}
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
if (type) {
|
||||||
|
rows = rows.filter((item) => (item as { type?: string }).type === type);
|
||||||
|
}
|
||||||
|
return { rows: rows.slice(offset, offset + limit), total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the request was handled. */
|
||||||
|
export async function tryHandleContentApi(
|
||||||
|
route: Route,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
url: URL,
|
||||||
|
content: ContentState,
|
||||||
|
json: JsonFn,
|
||||||
|
postData: () => unknown
|
||||||
|
): Promise<boolean> {
|
||||||
|
// ---- users ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/users/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_users: content.users.length,
|
||||||
|
users_by_role: {},
|
||||||
|
users_by_status: {},
|
||||||
|
pending_users: 0,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/users')) {
|
||||||
|
const page = listSlice(content.users, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/users\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.users.findIndex((u) => u.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.users[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.users[idx] = { ...content.users[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.users[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.users.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- calendars ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/calendars/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_calendars: content.calendars.length,
|
||||||
|
calendars_by_type: {},
|
||||||
|
calendars_by_status: {},
|
||||||
|
top_calendars_by_reviews: [],
|
||||||
|
top_calendars_by_positive_reviews: [],
|
||||||
|
top_calendars_by_negative_reviews: [],
|
||||||
|
top_calendars_by_rating: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/calendars')) {
|
||||||
|
const page = listSlice(content.calendars, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/calendars\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.calendars.findIndex((c) => c.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.calendars[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.calendars[idx] = { ...content.calendars[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.calendars[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.calendars.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- events ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/events/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_events: content.events.length,
|
||||||
|
events_by_type: {},
|
||||||
|
events_by_status: {},
|
||||||
|
top_events_by_rating: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/events')) {
|
||||||
|
const page = listSlice(content.events, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/events\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.events.findIndex((e) => e.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.events[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.events[idx] = { ...content.events[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.events[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.events.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- subscriptions ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_subscriptions: content.subscriptions.length,
|
||||||
|
subscriptions_by_plan: {},
|
||||||
|
subscriptions_by_status: {},
|
||||||
|
trial_subscriptions: 0,
|
||||||
|
ending_paid_subscriptions: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions')) {
|
||||||
|
const page = listSlice(content.subscriptions, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/subscriptions\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.subscriptions.findIndex((s) => s.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.subscriptions[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.subscriptions[idx] = { ...content.subscriptions[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.subscriptions[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.subscriptions.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- banned words ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/banned-words')) {
|
||||||
|
const page = listSlice(
|
||||||
|
content.bannedWords.map((w) => ({ ...w, id: w.word })),
|
||||||
|
url
|
||||||
|
);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/banned-words')) {
|
||||||
|
const payload = postData() as { word?: string };
|
||||||
|
const word = (payload.word || '').trim();
|
||||||
|
if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', added_at: '2026-07-14T12:00:00Z' });
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/banned-words\/([^/]+)$/);
|
||||||
|
if (m && method === 'DELETE') {
|
||||||
|
const word = decodeURIComponent(m[1]);
|
||||||
|
content.bannedWords = content.bannedWords.filter((w) => w.word !== word);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- automod ----
|
||||||
|
if (!('_automod' in content)) {
|
||||||
|
(content as { _automod?: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod = {
|
||||||
|
settings: {
|
||||||
|
keyword_action: 'flag',
|
||||||
|
match_mode: 'word_boundary',
|
||||||
|
report_threshold: 3,
|
||||||
|
updated_at: '2026-07-14T12:00:00Z',
|
||||||
|
updated_by: 'admin-e2e-1',
|
||||||
|
},
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
id: 'hit-1',
|
||||||
|
entity_type: 'event',
|
||||||
|
entity_id: 'evt-1',
|
||||||
|
trigger: 'keyword',
|
||||||
|
matched_words: ['spam'],
|
||||||
|
action_taken: 'flag',
|
||||||
|
status: 'open',
|
||||||
|
created_at: '2026-07-14T12:00:00Z',
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const automod = (content as { _automod: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod;
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/automod/settings')) {
|
||||||
|
await json(route, 200, automod.settings);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT' && path.endsWith('/v1/admin/automod/settings')) {
|
||||||
|
const payload = postData() as Record<string, unknown>;
|
||||||
|
automod.settings = { ...automod.settings, ...payload };
|
||||||
|
await json(route, 200, automod.settings);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/automod/hits')) {
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
const rows = status
|
||||||
|
? automod.hits.filter((h) => h.status === status)
|
||||||
|
: automod.hits;
|
||||||
|
await json(route, 200, rows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/automod\/hits\/([^/]+)$/);
|
||||||
|
if (m && method === 'PUT') {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const payload = postData() as { status?: string };
|
||||||
|
const idx = automod.hits.findIndex((h) => h.id === id);
|
||||||
|
if (idx >= 0 && payload.status) {
|
||||||
|
automod.hits[idx] = {
|
||||||
|
...automod.hits[idx],
|
||||||
|
status: payload.status,
|
||||||
|
resolved_at: '2026-07-14T13:00:00Z',
|
||||||
|
resolved_by: 'admin-e2e-1',
|
||||||
|
};
|
||||||
|
await json(route, 200, automod.hits[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await json(route, 404, { error: 'not found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admins ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
|
||||||
|
const page = listSlice(content.admins, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/admins')) {
|
||||||
|
const payload = postData() as Record<string, unknown>;
|
||||||
|
const created = {
|
||||||
|
id: `admin-new-${content.admins.length + 1}`,
|
||||||
|
email: String(payload.email || 'new@eventhub.local'),
|
||||||
|
nickname: String(payload.nickname || 'New Admin'),
|
||||||
|
role: String(payload.role || 'admin'),
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
created_at: '2026-07-14T12:00:00Z',
|
||||||
|
updated_at: '2026-07-14T12:00:00Z',
|
||||||
|
last_login: null,
|
||||||
|
};
|
||||||
|
content.admins.push(created);
|
||||||
|
await json(route, 200, created);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/admins\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.admins.findIndex((a) => a.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.admins[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.admins[idx] = { ...content.admins[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.admins[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.admins.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audit ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/audit')) {
|
||||||
|
const page = listSlice(content.audit, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- monitoring ----
|
||||||
|
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||||
|
await json(route, 200, [
|
||||||
|
{
|
||||||
|
active_sessions: 3,
|
||||||
|
io_in: 10,
|
||||||
|
io_out: 20,
|
||||||
|
memory_atom: 1,
|
||||||
|
memory_binary: 2,
|
||||||
|
memory_ets: 3,
|
||||||
|
memory_processes: 4,
|
||||||
|
memory_total: 2048,
|
||||||
|
mnesia_commits: 1,
|
||||||
|
mnesia_failures: 0,
|
||||||
|
node: 'node-a',
|
||||||
|
process_count: 100,
|
||||||
|
run_queue: 0,
|
||||||
|
table_sizes: { users: 10 },
|
||||||
|
timestamp: '2026-07-14T12:00:00Z',
|
||||||
|
ws_connections: 5,
|
||||||
|
cpu_utilization: 12,
|
||||||
|
memory_available: 1024,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/** Stateful moderation fixtures for mock E2E. */
|
||||||
|
|
||||||
|
export type MockReport = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
target_type: 'calendar' | 'event' | 'review';
|
||||||
|
target_id: string;
|
||||||
|
reason: string;
|
||||||
|
status: 'pending' | 'reviewed' | 'dismissed';
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
resolved_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockTicket = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
error_hash: string;
|
||||||
|
error_message: string;
|
||||||
|
stacktrace: string;
|
||||||
|
context: string;
|
||||||
|
count: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
assigned_to: string | null;
|
||||||
|
resolution_note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockReview = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
target_type: 'calendar' | 'event';
|
||||||
|
target_id: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
status: 'visible' | 'hidden' | 'deleted';
|
||||||
|
reason: string | null;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = '2026-07-14T12:00:00Z';
|
||||||
|
|
||||||
|
export function createModerationSeed() {
|
||||||
|
const reports: MockReport[] = [
|
||||||
|
{
|
||||||
|
id: 'rpt-pending-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
target_type: 'event',
|
||||||
|
target_id: 'evt-1',
|
||||||
|
reason: 'Spam content',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpt-pending-2',
|
||||||
|
reporter_id: 'user-2',
|
||||||
|
target_type: 'calendar',
|
||||||
|
target_id: 'cal-1',
|
||||||
|
reason: 'Offensive title',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpt-done-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
target_type: 'review',
|
||||||
|
target_id: 'rev-1',
|
||||||
|
reason: 'Duplicate',
|
||||||
|
status: 'reviewed',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: now,
|
||||||
|
resolved_by: 'admin-e2e-1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tickets: MockTicket[] = [
|
||||||
|
{
|
||||||
|
id: 'tkt-open-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
error_hash: 'hash-open-1',
|
||||||
|
error_message: 'NullPointer in join',
|
||||||
|
stacktrace: 'at joiner:42',
|
||||||
|
context: '{}',
|
||||||
|
count: 3,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
status: 'open',
|
||||||
|
assigned_to: null,
|
||||||
|
resolution_note: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tkt-prog-1',
|
||||||
|
reporter_id: 'user-2',
|
||||||
|
error_hash: 'hash-prog-1',
|
||||||
|
error_message: 'Timeout on sync',
|
||||||
|
stacktrace: 'at sync:10',
|
||||||
|
context: '{}',
|
||||||
|
count: 1,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
status: 'in_progress',
|
||||||
|
assigned_to: 'admin-e2e-1',
|
||||||
|
resolution_note: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const reviews: MockReview[] = [
|
||||||
|
{
|
||||||
|
id: 'rev-vis-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
target_type: 'event',
|
||||||
|
target_id: 'evt-1',
|
||||||
|
rating: 5,
|
||||||
|
comment: 'Great event',
|
||||||
|
status: 'visible',
|
||||||
|
reason: null,
|
||||||
|
likes: 2,
|
||||||
|
dislikes: 0,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rev-vis-2',
|
||||||
|
user_id: 'user-2',
|
||||||
|
target_type: 'calendar',
|
||||||
|
target_id: 'cal-1',
|
||||||
|
rating: 1,
|
||||||
|
comment: 'Bad calendar',
|
||||||
|
status: 'visible',
|
||||||
|
reason: null,
|
||||||
|
likes: 0,
|
||||||
|
dislikes: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { reports, tickets, reviews };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModerationState = ReturnType<typeof createModerationSeed>;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { installAdminApiMocks, MOCK_ADMIN } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
test.describe('auth / login (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installAdminApiMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('успешный вход ведёт на dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
|
||||||
|
await page.getByRole('button', { name: /войти|log in|sign in/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('неверный пароль показывает ошибку', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill('wrong');
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await expect(page.getByText(/неверный email или пароль|invalid/i)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
async function openRowAction(page: Page, id: string, label: RegExp) {
|
||||||
|
await page.getByTestId(`row-actions-${id}`).click();
|
||||||
|
await page.getByRole('menuitem', { name: label }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radix Select in edit dialogs — ensure option chosen so Save enables. */
|
||||||
|
async function pickDialogOption(page: Page, dialogTestId: string, comboboxIndex: number, option: string) {
|
||||||
|
const dialog = page.getByTestId(dialogTestId);
|
||||||
|
await dialog.getByRole('combobox').nth(comboboxIndex).click();
|
||||||
|
await page.getByRole('option', { name: option, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('content explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users: список, поиск, detail, edit, delete', async ({ page }) => {
|
||||||
|
await page.goto('/users');
|
||||||
|
await expect(page.getByTestId('page-users')).toBeVisible();
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-user-e2e-1')).toBeVisible();
|
||||||
|
await page.getByTestId('filter-users-search-input').fill('Alice');
|
||||||
|
await page.getByTestId('filter-users-search').locator('button[type="submit"]').click();
|
||||||
|
await expect(page.getByTestId('row-user-e2e-1')).toBeVisible();
|
||||||
|
|
||||||
|
await openRowAction(page, 'user-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-user')).toBeVisible();
|
||||||
|
await page.getByLabel(/ник|nickname/i).fill('Alice E2E');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-user', 0, 'user');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-user', 1, 'active');
|
||||||
|
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-edit-user')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto('/users/user-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-user-detail')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/users');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await openRowAction(page, 'user-e2e-2', /удал|delete/i);
|
||||||
|
await expect(page.getByTestId('dialog-delete-user')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-delete-confirm').click();
|
||||||
|
await expect(page.getByTestId('row-user-e2e-2')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calendars: edit и detail', async ({ page }) => {
|
||||||
|
await page.goto('/calendars');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-cal-e2e-1')).toBeVisible();
|
||||||
|
await openRowAction(page, 'cal-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-calendar')).toBeVisible();
|
||||||
|
await page.getByTestId('dialog-edit-calendar').locator('input').first().fill('E2E Calendar');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-calendar', 0, 'personal');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-calendar', 1, 'active');
|
||||||
|
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-edit-calendar')).toHaveCount(0);
|
||||||
|
await page.goto('/calendars/cal-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-calendar-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('events: edit и detail', async ({ page }) => {
|
||||||
|
await page.goto('/events');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-evt-e2e-1')).toBeVisible();
|
||||||
|
await openRowAction(page, 'evt-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-event')).toBeVisible();
|
||||||
|
await page.getByTestId('dialog-edit-event').locator('input').first().fill('E2E Workout');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-event', 0, 'single');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-event', 1, 'active');
|
||||||
|
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||||
|
// Tall dialog: footer can sit outside viewport; submit via form API.
|
||||||
|
await page.getByTestId('dialog-edit-event').locator('form').evaluate((form) => {
|
||||||
|
(form as HTMLFormElement).requestSubmit();
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('dialog-edit-event')).toHaveCount(0);
|
||||||
|
await page.goto('/events/evt-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-event-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscriptions: edit и delete', async ({ page }) => {
|
||||||
|
await page.goto('/subscriptions');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-sub-e2e-1')).toBeVisible();
|
||||||
|
await openRowAction(page, 'sub-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-subscription')).toBeVisible();
|
||||||
|
await pickDialogOption(page, 'dialog-edit-subscription', 0, 'monthly');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-subscription', 1, 'active');
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-edit-subscription')).toHaveCount(0);
|
||||||
|
await openRowAction(page, 'sub-e2e-1', /удал|delete/i);
|
||||||
|
await expect(page.getByTestId('dialog-delete-subscription')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-delete-confirm').click();
|
||||||
|
await expect(page.getByTestId('row-sub-e2e-1')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('banned-words: список и add', async ({ page }) => {
|
||||||
|
await page.goto('/banned-words');
|
||||||
|
await expect(page.getByTestId('page-banned-words')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-spamword')).toBeVisible();
|
||||||
|
await page.getByTestId('input-banned-word').fill('newbad');
|
||||||
|
await page.getByTestId('btn-add-banned-word').click();
|
||||||
|
await expect(page.getByTestId('row-newbad')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('inbox reports (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('очередь pending, select и reviewed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await expect(page.getByTestId('page-inbox-reports')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-done-1')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId('report-select-rpt-pending-1').click();
|
||||||
|
await expect(page.getByTestId('inbox-report-detail')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-report-reviewed').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter all показывает reviewed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await page.getByTestId('filter-all').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-done-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk dismissed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await page.getByTestId('report-check-rpt-pending-1').check();
|
||||||
|
await page.getByTestId('report-check-rpt-pending-2').check();
|
||||||
|
await page.getByTestId('btn-bulk-dismissed').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-2')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('inbox tickets (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → in_progress → resolved', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/tickets');
|
||||||
|
await expect(page.getByTestId('page-inbox-tickets')).toBeVisible();
|
||||||
|
await page.getByTestId('ticket-row-tkt-open-1').click();
|
||||||
|
await page.getByTestId('btn-ticket-in-progress').click();
|
||||||
|
await expect(page.getByTestId('btn-ticket-resolved')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-ticket-resolved').click();
|
||||||
|
// resolved leaves active queue
|
||||||
|
await expect(page.getByTestId('ticket-row-tkt-open-1')).toHaveCount(0);
|
||||||
|
await page.getByTestId('filter-all').click();
|
||||||
|
await expect(page.getByTestId('ticket-row-tkt-open-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('reports explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('список и detail reviewed с confirm', async ({ page }) => {
|
||||||
|
await page.goto('/reports');
|
||||||
|
await expect(page.getByTestId('page-reports')).toBeVisible();
|
||||||
|
await page.goto('/reports/rpt-pending-1');
|
||||||
|
await expect(page.getByTestId('page-report-detail')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-report-reviewed').click();
|
||||||
|
await expect(page.getByTestId('dialog-confirm-report-status')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-confirm-status').click();
|
||||||
|
await expect(page.getByTestId('btn-report-reviewed')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('reviews explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('страница reviews и bulk hide', async ({ page }) => {
|
||||||
|
await page.goto('/reviews');
|
||||||
|
await expect(page.getByTestId('page-reviews')).toBeVisible();
|
||||||
|
await page.getByTestId('view-table').click();
|
||||||
|
await expect(page.getByText('Great event')).toBeVisible();
|
||||||
|
await page.getByTestId('review-check-rev-vis-1').check();
|
||||||
|
await page.getByTestId('btn-reviews-bulk-hidden').click();
|
||||||
|
await expect(page.getByTestId('dialog-reviews-bulk-status')).toBeVisible();
|
||||||
|
await page.getByTestId('input-bulk-reason').fill('e2e hide');
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-reviews-bulk-status')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
import { loginAsE2EAdmin } from '../helpers/auth';
|
||||||
|
import { installAdminApiMocks } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
test.describe('shell / layout (mock)', () => {
|
||||||
|
test('после логина видны sidebar, nav dashboard и user menu', async ({ page }) => {
|
||||||
|
await installAdminApiMocks(page);
|
||||||
|
await loginAsE2EAdmin(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('layout-sidebar')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-dashboard')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-users')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-admins')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await expect(page.getByTestId('menu-user-content')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('menu-profile')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('btn-logout')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command palette открывается и навигирует', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('btn-open-palette').click();
|
||||||
|
await expect(page.getByTestId('command-palette')).toBeVisible();
|
||||||
|
await page.getByTestId('palette-input').fill('Пользователи');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page).toHaveURL(/\/users/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout возвращает на login', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await page.getByTestId('btn-logout').click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('shell / roles (mock)', () => {
|
||||||
|
test('moderator: нет nav users, admins запрещён', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page, { role: 'moderator', nickname: 'Mod' });
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('nav-inbox-reports')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-users')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('nav-admins')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-forbidden')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-to-dashboard').click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('support: tickets inbox есть, reports inbox нет', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page, { role: 'support', nickname: 'Support' });
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('nav-inbox-tickets')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-inbox-reports')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await expect(page.getByTestId('page-forbidden')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { hasStandCredentials, loginAsStandAdmin } from '../../helpers/standAuth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграция UI ↔ admin-api на IFT/stage после деплоя.
|
||||||
|
* Без page.route mocks — только живой SPA и API стенда.
|
||||||
|
*/
|
||||||
|
test.describe('stand integration (live API)', () => {
|
||||||
|
test.skip(!hasStandCredentials(), 'SMOKE_ADMIN_* / ADMIN_SUPER_* from stand .env required');
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsStandAdmin(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard после login', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit: страница и таблица без ошибки рендера', async ({ page }) => {
|
||||||
|
await page.goto('/audit');
|
||||||
|
await expect(page.getByTestId('page-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('filter-audit')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Ошибка отображения|Minified React error/i)).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid="page-audit"] table tbody tr').first()).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users: список загружается', async ({ page }) => {
|
||||||
|
await page.goto('/users');
|
||||||
|
await expect(page.getByTestId('page-users')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Ошибка отображения|Minified React error/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admins: superadmin видит список', async ({ page }) => {
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-admins')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('page-forbidden')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monitoring: страница загружается', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
await expect(page.getByTestId('page-monitoring')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout', async ({ page }) => {
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await page.getByTestId('btn-logout').click();
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('system / overview (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admins: список, create dialog, detail', async ({ page }) => {
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-admins')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-admin-e2e-2')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-create-admin').click();
|
||||||
|
await expect(page.getByTestId('dialog-create-admin')).toBeVisible();
|
||||||
|
await page.goto('/admins/admin-e2e-2');
|
||||||
|
await expect(page.getByTestId('page-admin-detail')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('btn-edit-admin')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit: страница и фильтры', async ({ page }) => {
|
||||||
|
await page.goto('/audit');
|
||||||
|
await expect(page.getByTestId('page-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('filter-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-audit-e2e-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monitoring: страница, range и node', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
await expect(page.getByTestId('page-monitoring')).toBeVisible();
|
||||||
|
await page.getByTestId('filter-monitoring-range-30').click();
|
||||||
|
await page.getByTestId('filter-monitoring-node-node-a').click();
|
||||||
|
await expect(page).toHaveURL(/node=node-a/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile: view и edit', async ({ page }) => {
|
||||||
|
await page.goto('/profile');
|
||||||
|
await expect(page.getByTestId('page-profile')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-edit-profile').click();
|
||||||
|
await expect(page.getByTestId('btn-save-profile')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-cancel-profile').click();
|
||||||
|
await expect(page.getByTestId('btn-edit-profile')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard остаётся доступен', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('i18n (mock)', () => {
|
||||||
|
test('переключение языка через меню меняет UI', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await expect(page.getByTestId('menu-profile')).toHaveText(/Мой профиль/i);
|
||||||
|
await page.getByTestId('lang-en').click();
|
||||||
|
// Dropdown may close; reopen after store + i18n sync from PUT /me
|
||||||
|
await expect(page.getByText(/Профиль обновлён|Profile updated/i)).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await expect(page.getByTestId('menu-profile')).toHaveText(/My profile/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-capture фронтовых ошибок Admin SPA → POST /v1/tickets (source=frontend).
|
||||||
|
* Ручной «сообщить о проблеме» — клиентский сценарий, не UI Control Center.
|
||||||
|
*/
|
||||||
|
test.describe('frontend error auto-report (mock)', () => {
|
||||||
|
test('reportClientError шлёт POST /v1/tickets с source=frontend', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
|
||||||
|
let reported: { error_message?: string; source?: string } | null = null;
|
||||||
|
await page.route('**/v1/tickets', async (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
reported = route.request().postDataJSON() as typeof reported;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 't-fe-1',
|
||||||
|
source: 'frontend',
|
||||||
|
status: 'open',
|
||||||
|
count: 1,
|
||||||
|
error_hash: 'abc',
|
||||||
|
error_message: reported?.error_message ?? 'err',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForFunction(() => typeof window.__ehReportClientError === 'function');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.__ehReportClientError!({
|
||||||
|
message: 'e2e-frontend-auto-report',
|
||||||
|
stack: 'Error: e2e-frontend-auto-report\n at e2e:1:1',
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => reported?.source, { timeout: 10_000 }).toBe('frontend');
|
||||||
|
expect(reported?.error_message).toContain('e2e-frontend-auto-report');
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-1
@@ -6,9 +6,10 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'playwright-report', 'test-results', 'e2e']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
ignores: ['playwright.config.ts'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
|
|||||||
Generated
+105
-1446
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -11,7 +11,10 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:ift": "npm run build",
|
"build:ift": "npm run build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test --project=mock",
|
||||||
|
"test:e2e:ift": "E2E_TARGET=ift playwright test --project=ift-ui",
|
||||||
|
"test:e2e:stage": "E2E_TARGET=stage playwright test --project=stage-ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -48,6 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = Number(process.env.E2E_PORT || 4173);
|
||||||
|
const baseURL = process.env.E2E_BASE_URL || `http://127.0.0.1:${port}`;
|
||||||
|
const iftBaseURL = process.env.E2E_IFT_BASE_URL || 'https://admin-ui.ift.eventhub.local';
|
||||||
|
const stageBaseURL = process.env.E2E_STAGE_BASE_URL || 'https://admin-ui.stage.eventhub.local';
|
||||||
|
const liveTarget = process.env.E2E_TARGET;
|
||||||
|
const liveChrome = {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mock-suite (CI / локально): preview + page.route mocks. */
|
||||||
|
const mockProject = {
|
||||||
|
name: 'mock',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testIgnore: [/stand\//, /ift\//],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** IFT/stage после деплоя: живой SPA + admin-api, без mocks. */
|
||||||
|
const standTestMatch = /stand\//;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e/tests',
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
locale: 'ru-RU',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
mockProject,
|
||||||
|
{
|
||||||
|
name: 'ift-ui',
|
||||||
|
use: { ...liveChrome, baseURL: iftBaseURL },
|
||||||
|
testMatch: standTestMatch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stage-ui',
|
||||||
|
use: { ...liveChrome, baseURL: stageBaseURL },
|
||||||
|
testMatch: standTestMatch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: liveTarget
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
// strictPort: не молчать, если порт занят зомби act-preview
|
||||||
|
command: `npm run preview -- --host 127.0.0.1 --port ${port} --strictPort`,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Act hostexecutor делит сеть с хостом: после cancel/fail остаётся vite preview
|
||||||
|
# с cwd (deleted), порт занят, HTTP 404 → Playwright webServer ждёт 2xx до таймаута.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${E2E_PORT:-4173}"
|
||||||
|
|
||||||
|
free_port() {
|
||||||
|
local pids=""
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
fuser -k "${PORT}/tcp" >/dev/null 2>&1 || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
pids="$(lsof -t -iTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||||
|
elif command -v ss >/dev/null 2>&1; then
|
||||||
|
pids="$(ss -ltnp "sport = :${PORT}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true)"
|
||||||
|
fi
|
||||||
|
if [[ -n "${pids}" ]]; then
|
||||||
|
echo "free-e2e-port: killing PIDs on :${PORT}: ${pids}"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill ${pids} 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill -9 ${pids} 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "free-e2e-port: ensuring :${PORT} is free"
|
||||||
|
free_port
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
if ss -ltn | grep -qE ":${PORT}\\s"; then
|
||||||
|
echo "WARN: :${PORT} still in use after free attempt" >&2
|
||||||
|
ss -ltnp | grep ":${PORT}" || true
|
||||||
|
else
|
||||||
|
echo "free-e2e-port: :${PORT} is free"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install Playwright Chromium for CI on self-hosted act_runner/WSL.
|
||||||
|
# --with-deps runs apt and can fail when openssh-server dpkg is half-configured
|
||||||
|
# (systemctl unavailable in WSL job container).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if npx playwright install --with-deps chromium; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::warning::playwright --with-deps failed; installing browser binary only (system libs expected on runner)"
|
||||||
|
npx playwright install chromium
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load SMOKE_ADMIN_* (fallback ADMIN_SUPER_*) from stand .env via SSH into GITHUB_ENV.
|
||||||
|
# Usage: load-stand-smoke-env.sh <ift|stage>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STAND="${1:?stand: ift|stage}"
|
||||||
|
case "${STAND}" in
|
||||||
|
ift)
|
||||||
|
SSH_HOST="${SSH_HOST:?IFT_SSH_HOST required}"
|
||||||
|
SSH_USER="${SSH_USER:?IFT_SSH_USER required}"
|
||||||
|
SSH_KEY="${SSH_KEY:?IFT_SSH_PRIVATE_KEY required}"
|
||||||
|
ENV_FILE="/opt/eventhub-ift/.env"
|
||||||
|
;;
|
||||||
|
stage)
|
||||||
|
SSH_HOST="${SSH_HOST:?STAGE_SSH_HOST required}"
|
||||||
|
SSH_USER="${SSH_USER:?STAGE_SSH_USER required}"
|
||||||
|
SSH_KEY="${SSH_KEY:?STAGE_SSH_PRIVATE_KEY required}"
|
||||||
|
ENV_FILE="/opt/eventhub-stage/.env"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown stand: ${STAND}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
KEY="$(mktemp)"
|
||||||
|
printf '%s\n' "${SSH_KEY}" > "${KEY}"
|
||||||
|
chmod 600 "${KEY}"
|
||||||
|
|
||||||
|
REMOTE="$(ssh -i "${KEY}" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||||
|
"${SSH_USER}@${SSH_HOST}" \
|
||||||
|
"grep -E '^(SMOKE_ADMIN_EMAIL|SMOKE_ADMIN_PASSWORD|ADMIN_SUPER_EMAIL|ADMIN_SUPER_PASSWORD)=' ${ENV_FILE} || true")"
|
||||||
|
rm -f "${KEY}"
|
||||||
|
|
||||||
|
EMAIL="$(printf '%s\n' "${REMOTE}" | sed -n 's/^SMOKE_ADMIN_EMAIL=//p' | head -1)"
|
||||||
|
PASS="$(printf '%s\n' "${REMOTE}" | sed -n 's/^SMOKE_ADMIN_PASSWORD=//p' | head -1)"
|
||||||
|
if [[ -z "${EMAIL}" ]]; then
|
||||||
|
EMAIL="$(printf '%s\n' "${REMOTE}" | sed -n 's/^ADMIN_SUPER_EMAIL=//p' | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${PASS}" ]]; then
|
||||||
|
PASS="$(printf '%s\n' "${REMOTE}" | sed -n 's/^ADMIN_SUPER_PASSWORD=//p' | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${EMAIL}" || -z "${PASS}" ]]; then
|
||||||
|
echo "::error::В ${ENV_FILE} нет SMOKE_ADMIN_* / ADMIN_SUPER_*"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "SMOKE_ADMIN_EMAIL=${EMAIL}" >> "${GITHUB_ENV}"
|
||||||
|
echo "SMOKE_ADMIN_PASSWORD=${PASS}" >> "${GITHUB_ENV}"
|
||||||
|
fi
|
||||||
|
echo "Loaded smoke admin email: ${EMAIL}"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Product version (MAJOR.MINOR) из EventHubSpec/VERSION.
|
||||||
|
# Fallback: локальный VERSION (офлайн / без токена).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${GITEA_BASE_URL:-https://git.sabilin.com}"
|
||||||
|
TOKEN="${GITEA_TOKEN:-${REGISTRY_PASSWORD:-}}"
|
||||||
|
REF="${PRODUCT_VERSION_REF:-master}"
|
||||||
|
FALLBACK_FILE="${PRODUCT_VERSION_FILE:-VERSION}"
|
||||||
|
|
||||||
|
fetch_spec() {
|
||||||
|
local url="${BASE}/api/v1/repos/EventHub/EventHubSpec/raw/VERSION?ref=${REF}"
|
||||||
|
if [[ -n "${TOKEN}" ]]; then
|
||||||
|
curl -fsSL -H "Authorization: token ${TOKEN}" "${url}"
|
||||||
|
else
|
||||||
|
curl -fsSL "${url}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ver=""
|
||||||
|
if raw="$(fetch_spec 2>/dev/null)"; then
|
||||||
|
ver="$(printf '%s' "${raw}" | grep -Eo '[0-9]+\.[0-9]+' | head -1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${ver}" && -f "${FALLBACK_FILE}" ]]; then
|
||||||
|
echo "WARN: EventHubSpec/VERSION недоступен — fallback ${FALLBACK_FILE}" >&2
|
||||||
|
ver="$(grep -Eo '[0-9]+\.[0-9]+' "${FALLBACK_FILE}" | head -1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${ver}" ]]; then
|
||||||
|
echo "ERROR: не удалось получить product version (Spec + local VERSION)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "${ver}" =~ ^[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "ERROR: некорректный VERSION '${ver}' (ожидается MAJOR.MINOR)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${ver}"
|
||||||
@@ -19,6 +19,8 @@ import ReportDetailPage from './pages/reports/ReportDetailPage';
|
|||||||
import ReviewListPage from './pages/reviews/ReviewListPage';
|
import ReviewListPage from './pages/reviews/ReviewListPage';
|
||||||
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
||||||
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
|
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
|
||||||
|
import AutomodSettingsPage from './pages/automod/AutomodSettingsPage';
|
||||||
|
import AutomodInboxPage from './pages/automod/AutomodInboxPage';
|
||||||
import TicketListPage from './pages/tickets/TicketListPage';
|
import TicketListPage from './pages/tickets/TicketListPage';
|
||||||
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
||||||
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
||||||
@@ -65,6 +67,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
|
||||||
<Route path="/inbox/reports" element={<ReportInboxPage />} />
|
<Route path="/inbox/reports" element={<ReportInboxPage />} />
|
||||||
|
<Route path="/inbox/automod" element={<AutomodInboxPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
|
||||||
@@ -79,6 +82,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="/events" element={<EventListPage />} />
|
<Route path="/events" element={<EventListPage />} />
|
||||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||||
<Route path="/banned-words" element={<BannedWordsPage />} />
|
<Route path="/banned-words" element={<BannedWordsPage />} />
|
||||||
|
<Route path="/automod-settings" element={<AutomodSettingsPage />} />
|
||||||
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type KeywordAction = 'reject' | 'flag' | 'censor';
|
||||||
|
export type MatchMode = 'word_boundary' | 'substring';
|
||||||
|
export type AutomodHitStatus = 'open' | 'approved' | 'rejected';
|
||||||
|
export type AutomodTrigger = 'keyword' | 'report_threshold';
|
||||||
|
|
||||||
|
export interface AutomodSettings {
|
||||||
|
keyword_action: KeywordAction;
|
||||||
|
match_mode: MatchMode;
|
||||||
|
report_threshold: number;
|
||||||
|
updated_at?: string;
|
||||||
|
updated_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomodHit {
|
||||||
|
id: string;
|
||||||
|
entity_type: 'calendar' | 'event' | 'review';
|
||||||
|
entity_id: string;
|
||||||
|
trigger: AutomodTrigger;
|
||||||
|
matched_words: string[];
|
||||||
|
action_taken: string;
|
||||||
|
status: AutomodHitStatus;
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
resolved_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomodHitListParams {
|
||||||
|
status?: AutomodHitStatus;
|
||||||
|
trigger?: AutomodTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automodApi = {
|
||||||
|
getSettings: async (): Promise<AutomodSettings> => {
|
||||||
|
const { data } = await apiClient.get('/v1/admin/automod/settings');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
updateSettings: async (payload: Partial<AutomodSettings>): Promise<AutomodSettings> => {
|
||||||
|
const { data } = await apiClient.put('/v1/admin/automod/settings', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
getHits: async (params?: AutomodHitListParams): Promise<AutomodHit[]> => {
|
||||||
|
const { data } = await apiClient.get('/v1/admin/automod/hits', { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
resolveHit: async (id: string, status: 'approved' | 'rejected'): Promise<AutomodHit> => {
|
||||||
|
const { data } = await apiClient.put(`/v1/admin/automod/hits/${id}`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type AdminHealthInfo = {
|
||||||
|
status: string;
|
||||||
|
service?: string;
|
||||||
|
version?: string;
|
||||||
|
build?: number;
|
||||||
|
git_sha?: string;
|
||||||
|
built_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const healthApi = {
|
||||||
|
getAdminHealth: async (): Promise<AdminHealthInfo> => {
|
||||||
|
const { data } = await apiClient.get<AdminHealthInfo>('/v1/admin/health');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
|
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
|
||||||
|
|
||||||
|
export type TicketSource = 'backend' | 'frontend' | 'manual';
|
||||||
|
|
||||||
|
export interface CreateTicketPayload {
|
||||||
|
error_message: string;
|
||||||
|
stacktrace?: string;
|
||||||
|
context?: Record<string, unknown> | string;
|
||||||
|
source?: TicketSource;
|
||||||
|
}
|
||||||
|
|
||||||
export const ticketsApi = {
|
export const ticketsApi = {
|
||||||
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
||||||
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
|
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
|
||||||
@@ -11,6 +20,10 @@ export const ticketsApi = {
|
|||||||
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
|
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
createTicket: async (payload: CreateTicketPayload): Promise<Ticket> => {
|
||||||
|
const { data } = await apiClient.post('/v1/tickets', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
||||||
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
|
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,11 +121,16 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-lg" aria-describedby={undefined}>
|
<DialogContent
|
||||||
|
data-testid="command-palette"
|
||||||
|
className="gap-0 overflow-hidden p-0 sm:max-w-lg"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
||||||
<div className="flex items-center border-b px-3">
|
<div className="flex items-center border-b px-3">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="palette-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
|
import { reportClientError } from '@/lib/errorReporter';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@@ -13,6 +14,15 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
return { error };
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
void reportClientError({
|
||||||
|
message: error.message || 'React render error',
|
||||||
|
stack: error.stack || info.componentStack || undefined,
|
||||||
|
source: 'frontend',
|
||||||
|
context: { component_stack: info.componentStack || undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -126,15 +126,19 @@ export function DataTable<TData, TParams extends ServerTableParams>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : table.getRowModel().rows.length ? (
|
) : table.getRowModel().rows.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => {
|
||||||
<TableRow key={row.id}>
|
const original = row.original as { id?: string; word?: string };
|
||||||
|
const rowKey = original.id ?? original.word;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id} data-testid={rowKey ? `row-${rowKey}` : undefined}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function DenseRowList<TParams extends ServerTableParams>({
|
|||||||
) : (
|
) : (
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id} data-testid={`row-${item.id}`}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-start gap-3 px-4 py-3 transition-colors',
|
'group flex items-start gap-3 px-4 py-3 transition-colors',
|
||||||
@@ -69,6 +69,7 @@ export function DenseRowList<TParams extends ServerTableParams>({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`row-activate-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 flex-1 text-left',
|
'min-w-0 flex-1 text-left',
|
||||||
!item.onActivate && 'cursor-default'
|
!item.onActivate && 'cursor-default'
|
||||||
@@ -91,7 +92,10 @@ export function DenseRowList<TParams extends ServerTableParams>({
|
|||||||
</button>
|
</button>
|
||||||
{item.actions && item.actions.length > 0 && (
|
{item.actions && item.actions.length > 0 && (
|
||||||
<div className="shrink-0 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="shrink-0 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<RowActionsMenu actions={item.actions} />
|
<RowActionsMenu
|
||||||
|
actions={item.actions}
|
||||||
|
data-testid={`row-actions-${item.id}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface ExploreListShellProps {
|
|||||||
onViewModeChange: (mode: ExploreViewMode) => void;
|
onViewModeChange: (mode: ExploreViewMode) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreListShell({
|
export function ExploreListShell({
|
||||||
@@ -41,10 +42,11 @@ export function ExploreListShell({
|
|||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
'data-testid': dataTestId,
|
||||||
}: ExploreListShellProps) {
|
}: ExploreListShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-4', className)}>
|
<div className={cn('space-y-4', className)} data-testid={dataTestId}>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ExploreSearchProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreSearch({
|
export function ExploreSearch({
|
||||||
@@ -20,10 +21,12 @@ export function ExploreSearch({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
'data-testid': dataTestId,
|
||||||
}: ExploreSearchProps) {
|
}: ExploreSearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
data-testid={dataTestId}
|
||||||
className={cn('flex flex-wrap items-center gap-2', className)}
|
className={cn('flex flex-wrap items-center gap-2', className)}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,6 +36,7 @@ export function ExploreSearch({
|
|||||||
<div className="relative min-w-[200px] max-w-md flex-1">
|
<div className="relative min-w-[200px] max-w-md flex-1">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
data-testid={dataTestId ? `${dataTestId}-input` : undefined}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
placeholder={placeholder ?? t('common.searchPlaceholder')}
|
placeholder={placeholder ?? t('common.searchPlaceholder')}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function ExploreViewToggle({ value, onChange, className }: ExploreViewTog
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="view-table"
|
||||||
variant={value === 'table' ? 'secondary' : 'ghost'}
|
variant={value === 'table' ? 'secondary' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 px-2.5"
|
className="h-8 gap-1.5 px-2.5"
|
||||||
@@ -31,6 +32,7 @@ export function ExploreViewToggle({ value, onChange, className }: ExploreViewTog
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="view-rows"
|
||||||
variant={value === 'rows' ? 'secondary' : 'ghost'}
|
variant={value === 'rows' ? 'secondary' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 px-2.5"
|
className="h-8 gap-1.5 px-2.5"
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ export interface RowAction {
|
|||||||
interface RowActionsMenuProps {
|
interface RowActionsMenuProps {
|
||||||
actions: RowAction[];
|
actions: RowAction[];
|
||||||
label?: string;
|
label?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RowActionsMenu({ actions, label }: RowActionsMenuProps) {
|
export function RowActionsMenu({ actions, label, 'data-testid': dataTestId }: RowActionsMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const menuLabel = label ?? t('common.actions');
|
const menuLabel = label ?? t('common.actions');
|
||||||
if (actions.length === 0) return null;
|
if (actions.length === 0) return null;
|
||||||
@@ -33,11 +34,18 @@ export function RowActionsMenu({ actions, label }: RowActionsMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label={menuLabel} title={menuLabel}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={menuLabel}
|
||||||
|
title={menuLabel}
|
||||||
|
data-testid={dataTestId}
|
||||||
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48" data-testid={dataTestId ? `${dataTestId}-menu` : undefined}>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<Fragment key={action.label}>
|
<Fragment key={action.label}>
|
||||||
{action.separatorBefore && <DropdownMenuSeparator />}
|
{action.separatorBefore && <DropdownMenuSeparator />}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
|
|||||||
'/monitoring': undefined,
|
'/monitoring': undefined,
|
||||||
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
|
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
|
||||||
'/inbox/tickets': ['superadmin', 'admin', 'support'],
|
'/inbox/tickets': ['superadmin', 'admin', 'support'],
|
||||||
|
'/inbox/automod': ['superadmin', 'admin', 'moderator'],
|
||||||
'/users': ['superadmin', 'admin'],
|
'/users': ['superadmin', 'admin'],
|
||||||
'/calendars': ['superadmin', 'admin'],
|
'/calendars': ['superadmin', 'admin'],
|
||||||
'/events': ['superadmin', 'admin'],
|
'/events': ['superadmin', 'admin'],
|
||||||
'/reports': ['superadmin', 'admin', 'moderator'],
|
'/reports': ['superadmin', 'admin', 'moderator'],
|
||||||
'/reviews': ['superadmin', 'admin', 'moderator'],
|
'/reviews': ['superadmin', 'admin', 'moderator'],
|
||||||
'/banned-words': ['superadmin', 'admin'],
|
'/banned-words': ['superadmin', 'admin'],
|
||||||
|
'/automod-settings': ['superadmin', 'admin'],
|
||||||
'/tickets': ['superadmin', 'admin', 'support'],
|
'/tickets': ['superadmin', 'admin', 'support'],
|
||||||
'/subscriptions': ['superadmin', 'admin'],
|
'/subscriptions': ['superadmin', 'admin'],
|
||||||
'/admins': ['superadmin'],
|
'/admins': ['superadmin'],
|
||||||
@@ -39,6 +41,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
|
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
|
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
|
||||||
|
{ key: '/inbox/automod', label: 'nav.inboxAutomod', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +69,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ key: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
|
{ key: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
|
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
|
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
|
||||||
|
{ key: '/automod-settings', label: 'nav.automodSettings', roles: ['superadmin', 'admin'] },
|
||||||
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
|
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -129,13 +133,14 @@ export function getMenuSelectedKey(pathname: string): string {
|
|||||||
return pathname;
|
return pathname;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created', string> = {
|
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created' | 'automod_hit', string> = {
|
||||||
report_created: '/inbox/reports',
|
report_created: '/inbox/reports',
|
||||||
ticket_created: '/inbox/tickets',
|
ticket_created: '/inbox/tickets',
|
||||||
|
automod_hit: '/inbox/automod',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function canReceiveWsNotification(
|
export function canReceiveWsNotification(
|
||||||
type: 'report_created' | 'ticket_created',
|
type: 'report_created' | 'ticket_created' | 'automod_hit',
|
||||||
role: string | undefined
|
role: string | undefined
|
||||||
): boolean {
|
): boolean {
|
||||||
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
|
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { automodApi, AutomodHitListParams, AutomodSettings } from '@/api/automodApi';
|
||||||
|
|
||||||
|
export const useAutomodSettings = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['automod-settings'],
|
||||||
|
queryFn: () => automodApi.getSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUpdateAutomodSettings = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: Partial<AutomodSettings>) => automodApi.updateSettings(payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-settings'] }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAutomodHits = (params?: AutomodHitListParams) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['automod-hits', params],
|
||||||
|
queryFn: () => automodApi.getHits(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useResolveAutomodHit = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'rejected' }) =>
|
||||||
|
automodApi.resolveHit(id, status),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-hits'] }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Ticket,
|
Ticket,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
Activity,
|
Activity,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
Rows3,
|
Rows3,
|
||||||
Check,
|
Check,
|
||||||
Languages,
|
Languages,
|
||||||
|
Settings2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
} from '@/config/adminAccess';
|
} from '@/config/adminAccess';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { testid } from '@/lib/testid';
|
||||||
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
|
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -52,17 +55,29 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|||||||
import { CommandPalette } from '@/components/CommandPalette';
|
import { CommandPalette } from '@/components/CommandPalette';
|
||||||
import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey';
|
import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey';
|
||||||
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
|
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
|
||||||
|
import { healthApi } from '@/api/healthApi';
|
||||||
|
|
||||||
|
function formatBuildLabel(version: string, build: number | string | undefined, sha: string): string {
|
||||||
|
const shortSha = sha && sha !== 'unknown' && sha !== 'dev' ? sha.slice(0, 12) : sha;
|
||||||
|
const buildPart =
|
||||||
|
build === undefined || build === null || build === '' || Number(build) === 0
|
||||||
|
? version
|
||||||
|
: `${version}.${build}`;
|
||||||
|
return `${buildPart} (${shortSha})`;
|
||||||
|
}
|
||||||
|
|
||||||
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
|
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
'/dashboard': LayoutDashboard,
|
'/dashboard': LayoutDashboard,
|
||||||
'/inbox/reports': Inbox,
|
'/inbox/reports': Inbox,
|
||||||
'/inbox/tickets': Ticket,
|
'/inbox/tickets': Ticket,
|
||||||
|
'/inbox/automod': ShieldAlert,
|
||||||
'/users': Users,
|
'/users': Users,
|
||||||
'/calendars': Calendar,
|
'/calendars': Calendar,
|
||||||
'/events': CalendarDays,
|
'/events': CalendarDays,
|
||||||
'/reports': AlertTriangle,
|
'/reports': AlertTriangle,
|
||||||
'/reviews': Star,
|
'/reviews': Star,
|
||||||
'/banned-words': Ban,
|
'/banned-words': Ban,
|
||||||
|
'/automod-settings': Settings2,
|
||||||
'/tickets': Ticket,
|
'/tickets': Ticket,
|
||||||
'/subscriptions': CreditCard,
|
'/subscriptions': CreditCard,
|
||||||
'/admins': Shield,
|
'/admins': Shield,
|
||||||
@@ -70,6 +85,10 @@ const navIconByKey: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
'/monitoring': Activity,
|
'/monitoring': Activity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function navTestId(path: string): string {
|
||||||
|
return testid(['nav', ...path.split('/').filter(Boolean)]);
|
||||||
|
}
|
||||||
|
|
||||||
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
|
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
|
||||||
@@ -77,6 +96,7 @@ function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boole
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={item.key}
|
to={item.key}
|
||||||
|
data-testid={navTestId(item.key)}
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||||
@@ -102,10 +122,39 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
|
const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
|
||||||
|
const [apiVersionLabel, setApiVersionLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
const openPalette = useCallback(() => setPaletteOpen(true), []);
|
const openPalette = useCallback(() => setPaletteOpen(true), []);
|
||||||
useCommandPaletteHotkey(openPalette);
|
useCommandPaletteHotkey(openPalette);
|
||||||
|
|
||||||
|
const adminVersionLabel = formatBuildLabel(
|
||||||
|
import.meta.env.VITE_APP_VERSION || '0.0',
|
||||||
|
import.meta.env.VITE_APP_BUILD || '0',
|
||||||
|
import.meta.env.VITE_GIT_SHA || 'dev'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
healthApi
|
||||||
|
.getAdminHealth()
|
||||||
|
.then((info) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setApiVersionLabel(
|
||||||
|
formatBuildLabel(
|
||||||
|
info.version || '0.0',
|
||||||
|
info.build,
|
||||||
|
info.git_sha || 'unknown'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setApiVersionLabel(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectedKey = getMenuSelectedKey(location.pathname);
|
const selectedKey = getMenuSelectedKey(location.pathname);
|
||||||
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
|
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
|
||||||
|
|
||||||
@@ -148,6 +197,24 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} else if (msg.type === 'automod_hit') {
|
||||||
|
if (!canReceiveWsNotification('automod_hit', user?.role)) return;
|
||||||
|
const { entity_type, trigger } = msg.data || {};
|
||||||
|
notify.info(
|
||||||
|
[
|
||||||
|
t('ws.automodHit'),
|
||||||
|
entity_type ? `(${entity_type})` : '',
|
||||||
|
trigger ? `[${trigger}]` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: t('ws.open'),
|
||||||
|
onClick: () => navigate('/inbox/automod'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('admin-ws-message', handler as EventListener);
|
window.addEventListener('admin-ws-message', handler as EventListener);
|
||||||
@@ -176,8 +243,9 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
const initials = displayName.slice(0, 2).toUpperCase();
|
const initials = displayName.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background">
|
<div data-testid="layout-shell" className="flex min-h-screen bg-background">
|
||||||
<aside
|
<aside
|
||||||
|
data-testid="layout-sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
|
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
|
||||||
collapsed ? 'w-16' : 'w-60'
|
collapsed ? 'w-16' : 'w-60'
|
||||||
@@ -231,6 +299,7 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="btn-open-palette"
|
||||||
className="hidden sm:inline-flex gap-2 text-muted-foreground"
|
className="hidden sm:inline-flex gap-2 text-muted-foreground"
|
||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
>
|
>
|
||||||
@@ -259,7 +328,7 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="gap-2 px-2">
|
<Button variant="ghost" className="gap-2 px-2" data-testid="menu-user">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
|
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
@@ -269,7 +338,7 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56" data-testid="menu-user-content">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{displayName}</span>
|
<span>{displayName}</span>
|
||||||
@@ -277,7 +346,7 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
<DropdownMenuItem data-testid="menu-profile" onClick={() => navigate('/profile')}>
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
{t('layout.myProfile')}
|
{t('layout.myProfile')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -287,6 +356,7 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
{t('layout.uiLanguage')}
|
{t('layout.uiLanguage')}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
data-testid="lang-ru"
|
||||||
disabled={updateProfile.isPending}
|
disabled={updateProfile.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if ((user?.language || 'ru') === 'ru') return;
|
if ((user?.language || 'ru') === 'ru') return;
|
||||||
@@ -302,6 +372,7 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
{t('common.langRu')}
|
{t('common.langRu')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
data-testid="lang-en"
|
||||||
disabled={updateProfile.isPending}
|
disabled={updateProfile.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (user?.language === 'en') return;
|
if (user?.language === 'en') return;
|
||||||
@@ -317,7 +388,20 @@ const ControlCenterLayout: React.FC = () => {
|
|||||||
{t('common.langEn')}
|
{t('common.langEn')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
<DropdownMenuLabel className="space-y-0.5 text-xs font-normal text-muted-foreground">
|
||||||
|
<div>{t('layout.adminVersion', { version: adminVersionLabel })}</div>
|
||||||
|
<div>
|
||||||
|
{apiVersionLabel
|
||||||
|
? t('layout.apiVersion', { version: apiVersionLabel })
|
||||||
|
: t('layout.apiVersionUnavailable')}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="btn-logout"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
{t('layout.logout')}
|
{t('layout.logout')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ticketsApi, type TicketSource } from '@/api/ticketsApi';
|
||||||
|
import { ACCESS_TOKEN_KEY } from '@/utils/constants';
|
||||||
|
|
||||||
|
const THROTTLE_MS = 60_000;
|
||||||
|
const recentHashes = new Map<string, number>();
|
||||||
|
let reporting = false;
|
||||||
|
|
||||||
|
function simpleHash(input: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return String(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldThrottle(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = recentHashes.get(key) ?? 0;
|
||||||
|
if (now - last < THROTTLE_MS) return true;
|
||||||
|
recentHashes.set(key, now);
|
||||||
|
// prune old entries
|
||||||
|
if (recentHashes.size > 200) {
|
||||||
|
for (const [k, ts] of recentHashes) {
|
||||||
|
if (now - ts > THROTTLE_MS) recentHashes.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContext(extra?: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
route: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||||
|
build: import.meta.env.VITE_APP_VERSION || '0.0',
|
||||||
|
build_number: import.meta.env.VITE_APP_BUILD || '0',
|
||||||
|
git_sha: import.meta.env.VITE_GIT_SHA || 'dev',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportErrorInput = {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
source?: TicketSource;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an error to POST /v1/tickets. No-ops without auth token,
|
||||||
|
* while reporting is in flight for this call chain, or when throttled.
|
||||||
|
*/
|
||||||
|
export async function reportClientError(input: ReportErrorInput): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
||||||
|
if (reporting) return;
|
||||||
|
|
||||||
|
const source = input.source ?? (input.stack ? 'frontend' : 'manual');
|
||||||
|
const message = (input.message || 'Unknown error').slice(0, 2000);
|
||||||
|
const stack = (input.stack || '').slice(0, 8000);
|
||||||
|
const throttleKey = simpleHash(`${source}|${message}|${stack.slice(0, 500)}`);
|
||||||
|
if (shouldThrottle(throttleKey)) return;
|
||||||
|
|
||||||
|
reporting = true;
|
||||||
|
try {
|
||||||
|
await ticketsApi.createTicket({
|
||||||
|
error_message: message,
|
||||||
|
stacktrace: stack || undefined,
|
||||||
|
source,
|
||||||
|
context: buildContext(input.context),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Never rethrow — avoid error loops from the reporter itself.
|
||||||
|
} finally {
|
||||||
|
reporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGlobalErrorHandlers(): () => void {
|
||||||
|
const onError = (event: ErrorEvent) => {
|
||||||
|
const msg = event.message || 'window.onerror';
|
||||||
|
if (msg.includes('/v1/tickets')) return;
|
||||||
|
void reportClientError({
|
||||||
|
message: msg,
|
||||||
|
stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`,
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
const message =
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: typeof reason === 'string'
|
||||||
|
? reason
|
||||||
|
: 'unhandledrejection';
|
||||||
|
const stack = reason instanceof Error ? reason.stack : undefined;
|
||||||
|
if (message.includes('/v1/tickets')) return;
|
||||||
|
// Skip expected auth failures
|
||||||
|
if (typeof message === 'string' && /\b(401|403)\b/.test(message)) return;
|
||||||
|
void reportClientError({
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', onError);
|
||||||
|
window.addEventListener('unhandledrejection', onRejection);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('error', onError);
|
||||||
|
window.removeEventListener('unhandledrejection', onRejection);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Build stable data-testid values for E2E. */
|
||||||
|
export function testid(parts: Array<string | number | undefined | null>): string {
|
||||||
|
return parts
|
||||||
|
.filter((p) => p !== undefined && p !== null && `${p}`.length > 0)
|
||||||
|
.map((p) => String(p).replace(/\s+/g, '-'))
|
||||||
|
.join('-');
|
||||||
|
}
|
||||||
+47
-2
@@ -150,6 +150,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"inboxReports": "Queue · reports",
|
"inboxReports": "Queue · reports",
|
||||||
"inboxTickets": "Queue · tickets",
|
"inboxTickets": "Queue · tickets",
|
||||||
|
"inboxAutomod": "Queue · automod",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"reports": "Reports · archive",
|
"reports": "Reports · archive",
|
||||||
"reviews": "Reviews",
|
"reviews": "Reviews",
|
||||||
"bannedWords": "Banned words",
|
"bannedWords": "Banned words",
|
||||||
|
"automodSettings": "Automoderation",
|
||||||
"tickets": "Tickets · archive",
|
"tickets": "Tickets · archive",
|
||||||
"admins": "Admins",
|
"admins": "Admins",
|
||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
@@ -192,7 +194,10 @@
|
|||||||
"nodeCpu": "CPU: {{value}}%",
|
"nodeCpu": "CPU: {{value}}%",
|
||||||
"nodeMemory": "Memory: {{used}} / {{total}} MB ({{percent}}%)",
|
"nodeMemory": "Memory: {{used}} / {{total}} MB ({{percent}}%)",
|
||||||
"nodeWas": "was {{value}}%",
|
"nodeWas": "was {{value}}%",
|
||||||
"nodeAria": "{{node}}: CPU {{cpu}}%, memory {{memory}}%"
|
"nodeAria": "{{node}}: CPU {{cpu}}%, memory {{memory}}%",
|
||||||
|
"adminVersion": "Admin UI {{version}}",
|
||||||
|
"apiVersion": "API {{version}}",
|
||||||
|
"apiVersionUnavailable": "API — unavailable"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"viewAria": "List view",
|
"viewAria": "List view",
|
||||||
@@ -321,6 +326,7 @@
|
|||||||
"onTarget": "on {{type}}",
|
"onTarget": "on {{type}}",
|
||||||
"newTicket": "New ticket created",
|
"newTicket": "New ticket created",
|
||||||
"ticketCreated": "Ticket #{{id}} created",
|
"ticketCreated": "Ticket #{{id}} created",
|
||||||
|
"automodHit": "Automoderation hit",
|
||||||
"open": "Open"
|
"open": "Open"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -423,7 +429,12 @@
|
|||||||
"detailTitle": "Ticket {{id}}",
|
"detailTitle": "Ticket {{id}}",
|
||||||
"deleteTitle": "Delete ticket?",
|
"deleteTitle": "Delete ticket?",
|
||||||
"notFound": "Ticket not found",
|
"notFound": "Ticket not found",
|
||||||
"statsHint": "Explore · closed {{closed}}, total errors {{errors}}"
|
"statsHint": "Explore · closed {{closed}}, total errors {{errors}}",
|
||||||
|
"source": "Source",
|
||||||
|
"sourceAll": "All sources",
|
||||||
|
"sourceBackend": "Backend",
|
||||||
|
"sourceFrontend": "Frontend",
|
||||||
|
"sourceManual": "Manual"
|
||||||
},
|
},
|
||||||
"subscriptions": {
|
"subscriptions": {
|
||||||
"title": "Subscriptions",
|
"title": "Subscriptions",
|
||||||
@@ -463,6 +474,40 @@
|
|||||||
"addedBy": "Added by",
|
"addedBy": "Added by",
|
||||||
"addedAt": "Added at"
|
"addedAt": "Added at"
|
||||||
},
|
},
|
||||||
|
"automodSettings": {
|
||||||
|
"title": "Automoderation settings",
|
||||||
|
"description": "Keyword policy, matching mode, and report threshold",
|
||||||
|
"keywordAction": "Action on banned word",
|
||||||
|
"actionFlag": "Flag (queue + soft-hide)",
|
||||||
|
"actionReject": "Reject save",
|
||||||
|
"actionCensor": "Replace with ***",
|
||||||
|
"matchMode": "Match mode",
|
||||||
|
"matchWordBoundary": "Word boundary",
|
||||||
|
"matchSubstring": "Substring",
|
||||||
|
"reportThreshold": "Pending reports threshold",
|
||||||
|
"thresholdInvalid": "Threshold must be ≥ 1",
|
||||||
|
"saved": "Settings saved",
|
||||||
|
"saveFailed": "Failed to save settings"
|
||||||
|
},
|
||||||
|
"automodInbox": {
|
||||||
|
"title": "Automoderation queue",
|
||||||
|
"description": "Hits from keywords and report threshold",
|
||||||
|
"createdAt": "When",
|
||||||
|
"trigger": "Trigger",
|
||||||
|
"entity": "Entity",
|
||||||
|
"words": "Words",
|
||||||
|
"action": "Action",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Decision",
|
||||||
|
"approve": "Approve",
|
||||||
|
"reject": "Reject",
|
||||||
|
"filterOpen": "Open",
|
||||||
|
"filterAll": "All",
|
||||||
|
"filterApproved": "Approved",
|
||||||
|
"filterRejected": "Rejected",
|
||||||
|
"resolved": "Decision saved",
|
||||||
|
"resolveFailed": "Failed to save decision"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
|||||||
+47
-2
@@ -150,6 +150,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"inboxReports": "Очередь · жалобы",
|
"inboxReports": "Очередь · жалобы",
|
||||||
"inboxTickets": "Очередь · тикеты",
|
"inboxTickets": "Очередь · тикеты",
|
||||||
|
"inboxAutomod": "Очередь · автомодерация",
|
||||||
"dashboard": "Дашборд",
|
"dashboard": "Дашборд",
|
||||||
"monitoring": "Мониторинг",
|
"monitoring": "Мониторинг",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"reports": "Жалобы · архив",
|
"reports": "Жалобы · архив",
|
||||||
"reviews": "Отзывы",
|
"reviews": "Отзывы",
|
||||||
"bannedWords": "Бан-слова",
|
"bannedWords": "Бан-слова",
|
||||||
|
"automodSettings": "Автомодерация",
|
||||||
"tickets": "Тикеты · архив",
|
"tickets": "Тикеты · архив",
|
||||||
"admins": "Администраторы",
|
"admins": "Администраторы",
|
||||||
"audit": "Аудит",
|
"audit": "Аудит",
|
||||||
@@ -192,7 +194,10 @@
|
|||||||
"nodeCpu": "CPU: {{value}}%",
|
"nodeCpu": "CPU: {{value}}%",
|
||||||
"nodeMemory": "Память: {{used}} / {{total}} МБ ({{percent}}%)",
|
"nodeMemory": "Память: {{used}} / {{total}} МБ ({{percent}}%)",
|
||||||
"nodeWas": "было {{value}}%",
|
"nodeWas": "было {{value}}%",
|
||||||
"nodeAria": "{{node}}: CPU {{cpu}}%, память {{memory}}%"
|
"nodeAria": "{{node}}: CPU {{cpu}}%, память {{memory}}%",
|
||||||
|
"adminVersion": "Admin UI {{version}}",
|
||||||
|
"apiVersion": "API {{version}}",
|
||||||
|
"apiVersionUnavailable": "API — нет данных"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"viewAria": "Вид списка",
|
"viewAria": "Вид списка",
|
||||||
@@ -321,6 +326,7 @@
|
|||||||
"onTarget": "на {{type}}",
|
"onTarget": "на {{type}}",
|
||||||
"newTicket": "Создан новый тикет",
|
"newTicket": "Создан новый тикет",
|
||||||
"ticketCreated": "Тикет #{{id}} создан",
|
"ticketCreated": "Тикет #{{id}} создан",
|
||||||
|
"automodHit": "Срабатывание автомодерации",
|
||||||
"open": "Открыть"
|
"open": "Открыть"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -423,7 +429,12 @@
|
|||||||
"detailTitle": "Тикет {{id}}",
|
"detailTitle": "Тикет {{id}}",
|
||||||
"deleteTitle": "Удалить тикет?",
|
"deleteTitle": "Удалить тикет?",
|
||||||
"notFound": "Тикет не найден",
|
"notFound": "Тикет не найден",
|
||||||
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}"
|
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}",
|
||||||
|
"source": "Источник",
|
||||||
|
"sourceAll": "Все источники",
|
||||||
|
"sourceBackend": "Backend",
|
||||||
|
"sourceFrontend": "Frontend",
|
||||||
|
"sourceManual": "Ручной"
|
||||||
},
|
},
|
||||||
"subscriptions": {
|
"subscriptions": {
|
||||||
"title": "Подписки",
|
"title": "Подписки",
|
||||||
@@ -463,6 +474,40 @@
|
|||||||
"addedBy": "Кем добавлено",
|
"addedBy": "Кем добавлено",
|
||||||
"addedAt": "Дата добавления"
|
"addedAt": "Дата добавления"
|
||||||
},
|
},
|
||||||
|
"automodSettings": {
|
||||||
|
"title": "Настройки автомодерации",
|
||||||
|
"description": "Политика по бан-словам, matching и порог жалоб",
|
||||||
|
"keywordAction": "Действие при бан-слове",
|
||||||
|
"actionFlag": "Флаг (очередь + soft-hide)",
|
||||||
|
"actionReject": "Отклонить сохранение",
|
||||||
|
"actionCensor": "Заменить на ***",
|
||||||
|
"matchMode": "Режим совпадения",
|
||||||
|
"matchWordBoundary": "Граница слова",
|
||||||
|
"matchSubstring": "Подстрока",
|
||||||
|
"reportThreshold": "Порог pending-жалоб",
|
||||||
|
"thresholdInvalid": "Порог должен быть ≥ 1",
|
||||||
|
"saved": "Настройки сохранены",
|
||||||
|
"saveFailed": "Не удалось сохранить настройки"
|
||||||
|
},
|
||||||
|
"automodInbox": {
|
||||||
|
"title": "Очередь автомодерации",
|
||||||
|
"description": "Срабатывания по словам и порогу жалоб",
|
||||||
|
"createdAt": "Когда",
|
||||||
|
"trigger": "Триггер",
|
||||||
|
"entity": "Сущность",
|
||||||
|
"words": "Слова",
|
||||||
|
"action": "Действие",
|
||||||
|
"status": "Статус",
|
||||||
|
"actions": "Решение",
|
||||||
|
"approve": "Одобрить",
|
||||||
|
"reject": "Отклонить",
|
||||||
|
"filterOpen": "Открытые",
|
||||||
|
"filterAll": "Все",
|
||||||
|
"filterApproved": "Одобренные",
|
||||||
|
"filterRejected": "Отклонённые",
|
||||||
|
"resolved": "Решение сохранено",
|
||||||
|
"resolveFailed": "Не удалось сохранить решение"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Дашборд",
|
"dashboard": "Дашборд",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
|
|||||||
@@ -6,8 +6,20 @@ import './index.css';
|
|||||||
import './i18n';
|
import './i18n';
|
||||||
import LocaleProvider from './components/LocaleProvider';
|
import LocaleProvider from './components/LocaleProvider';
|
||||||
import { initTableDensity } from './lib/density';
|
import { initTableDensity } from './lib/density';
|
||||||
|
import { installGlobalErrorHandlers, reportClientError } from './lib/errorReporter';
|
||||||
|
|
||||||
initTableDensity();
|
initTableDensity();
|
||||||
|
installGlobalErrorHandlers();
|
||||||
|
|
||||||
|
// E2E / debug hook for auto-report smoke (mock suite)
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__ehReportClientError?: typeof reportClientError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__ehReportClientError = reportClientError;
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const AdminDetailPage: React.FC = () => {
|
|||||||
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
|
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="page-admin-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('admins.detailTitle', { name: titleName })}
|
title={t('admins.detailTitle', { name: titleName })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -127,11 +127,16 @@ const AdminDetailPage: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditModal(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="btn-edit-admin"
|
||||||
|
onClick={() => setEditModal(true)}
|
||||||
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => navigate('/admins')}>
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/admins')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -301,7 +306,7 @@ const AdminDetailPage: React.FC = () => {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -236,12 +236,13 @@ const AdminListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-admins"
|
||||||
title={t('admins.title')}
|
title={t('admins.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => setCreateModal(true)}>
|
<Button data-testid="btn-create-admin" onClick={() => setCreateModal(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t('admins.add')}
|
{t('admins.add')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -261,7 +262,7 @@ const AdminListPage: React.FC = () => {
|
|||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={createModal} onOpenChange={setCreateModal}>
|
<Dialog open={createModal} onOpenChange={setCreateModal}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-create-admin">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
|
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ const entityTypeBadgeVariant = (type: string) => {
|
|||||||
const isBadValue = (val: unknown) =>
|
const isBadValue = (val: unknown) =>
|
||||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||||
|
|
||||||
|
const asAuditText = (val: unknown): string => {
|
||||||
|
if (isBadValue(val)) return '-';
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
if (typeof val === 'object' && val !== null && 'reason' in val) {
|
||||||
|
const nested = (val as { reason?: unknown }).reason;
|
||||||
|
return typeof nested === 'string' ? nested : '-';
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const asEntityId = (val: unknown): string => {
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
if (typeof val === 'object' && val !== null && 'id' in val) {
|
||||||
|
const id = (val as { id?: unknown }).id;
|
||||||
|
return typeof id === 'string' ? id : String(id ?? '');
|
||||||
|
}
|
||||||
|
return String(val ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
const toDateInput = (iso: string | undefined): string => {
|
const toDateInput = (iso: string | undefined): string => {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
return iso.slice(0, 10);
|
return iso.slice(0, 10);
|
||||||
@@ -167,7 +186,10 @@ const AuditPage: React.FC = () => {
|
|||||||
header: t('audit.name'),
|
header: t('audit.name'),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<EntityNameCell entityType={row.original.entity_type} entityId={row.original.entity_id} />
|
<EntityNameCell
|
||||||
|
entityType={row.original.entity_type}
|
||||||
|
entityId={asEntityId(row.original.entity_id)}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ accessorKey: 'timestamp', header: t('common.date'), enableSorting: true },
|
{ accessorKey: 'timestamp', header: t('common.date'), enableSorting: true },
|
||||||
@@ -177,7 +199,7 @@ const AuditPage: React.FC = () => {
|
|||||||
header: t('common.reason'),
|
header: t('common.reason'),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const reason = getValue<string>();
|
const reason = asAuditText(getValue());
|
||||||
return <span className="line-clamp-2">{reason}</span>;
|
return <span className="line-clamp-2">{reason}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -186,13 +208,14 @@ const AuditPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-audit" className="space-y-6">
|
||||||
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
|
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4" data-testid="filter-audit">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('audit.searchAdmin')}</Label>
|
<Label>{t('audit.searchAdmin')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="filter-audit-admin-search"
|
||||||
placeholder={t('audit.searchAdminPlaceholder')}
|
placeholder={t('audit.searchAdminPlaceholder')}
|
||||||
value={adminSearch}
|
value={adminSearch}
|
||||||
onChange={(e) => setAdminSearch(e.target.value)}
|
onChange={(e) => setAdminSearch(e.target.value)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const LoginPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
<div data-testid="page-login" className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_hsl(221_83%_53%_/_0.18),_transparent_55%),linear-gradient(160deg,_hsl(220_20%_97%)_0%,_hsl(210_40%_96%)_45%,_hsl(221_40%_92%)_100%)]"
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_hsl(221_83%_53%_/_0.18),_transparent_55%),linear-gradient(160deg,_hsl(220_20%_97%)_0%,_hsl(210_40%_96%)_45%,_hsl(221_40%_92%)_100%)]"
|
||||||
@@ -93,7 +93,7 @@ const LoginPage: React.FC = () => {
|
|||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" data-testid="btn-login" className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? '…' : t('login.submit')}
|
{isSubmitting ? '…' : t('login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ListPageHeader } from '@/components/ListPageHeader';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { useAutomodHits, useResolveAutomodHit } from '@/hooks/useAutomod';
|
||||||
|
import type { AutomodHit, AutomodHitStatus } from '@/api/automodApi';
|
||||||
|
import { formatDateTime } from '@/utils/datetime';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
|
||||||
|
function entityLink(hit: AutomodHit): string {
|
||||||
|
if (hit.entity_type === 'event') return `/events/${hit.entity_id}`;
|
||||||
|
if (hit.entity_type === 'calendar') return `/calendars/${hit.entity_id}`;
|
||||||
|
return `/reviews/${hit.entity_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutomodInboxPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [status, setStatus] = useState<AutomodHitStatus | 'all'>('open');
|
||||||
|
const params = useMemo(
|
||||||
|
() => (status === 'all' ? undefined : { status }),
|
||||||
|
[status]
|
||||||
|
);
|
||||||
|
const { data, isLoading } = useAutomodHits(params);
|
||||||
|
const resolve = useResolveAutomodHit();
|
||||||
|
const rows = data ?? [];
|
||||||
|
|
||||||
|
const onResolve = (id: string, next: 'approved' | 'rejected') => {
|
||||||
|
resolve.mutate(
|
||||||
|
{ id, status: next },
|
||||||
|
{
|
||||||
|
onSuccess: () => notify.success(t('automodInbox.resolved')),
|
||||||
|
onError: () => notify.error(t('automodInbox.resolveFailed')),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="page-automod-inbox">
|
||||||
|
<ListPageHeader
|
||||||
|
title={t('automodInbox.title')}
|
||||||
|
description={t('automodInbox.description')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={status} onValueChange={(v) => setStatus(v as AutomodHitStatus | 'all')}>
|
||||||
|
<SelectTrigger className="w-48" data-testid="filter-automod-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="open">{t('automodInbox.filterOpen')}</SelectItem>
|
||||||
|
<SelectItem value="all">{t('automodInbox.filterAll')}</SelectItem>
|
||||||
|
<SelectItem value="approved">{t('automodInbox.filterApproved')}</SelectItem>
|
||||||
|
<SelectItem value="rejected">{t('automodInbox.filterRejected')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('automodInbox.createdAt')}</TableHead>
|
||||||
|
<TableHead>{t('automodInbox.trigger')}</TableHead>
|
||||||
|
<TableHead>{t('automodInbox.entity')}</TableHead>
|
||||||
|
<TableHead>{t('automodInbox.words')}</TableHead>
|
||||||
|
<TableHead>{t('automodInbox.action')}</TableHead>
|
||||||
|
<TableHead>{t('automodInbox.status')}</TableHead>
|
||||||
|
<TableHead>{t('automodInbox.actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-muted-foreground">
|
||||||
|
{t('common.noData')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((hit) => (
|
||||||
|
<TableRow key={hit.id} data-testid={`row-automod-hit-${hit.id}`}>
|
||||||
|
<TableCell>{formatDateTime(hit.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{hit.trigger}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link className="underline" to={entityLink(hit)}>
|
||||||
|
{hit.entity_type}:{hit.entity_id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{hit.matched_words.join(', ') || '—'}</TableCell>
|
||||||
|
<TableCell>{hit.action_taken}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge>{hit.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{hit.status === 'open' ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
data-testid={`btn-approve-hit-${hit.id}`}
|
||||||
|
onClick={() => onResolve(hit.id, 'approved')}
|
||||||
|
disabled={resolve.isPending}
|
||||||
|
>
|
||||||
|
{t('automodInbox.approve')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
data-testid={`btn-reject-hit-${hit.id}`}
|
||||||
|
onClick={() => onResolve(hit.id, 'rejected')}
|
||||||
|
disabled={resolve.isPending}
|
||||||
|
>
|
||||||
|
{t('automodInbox.reject')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutomodInboxPage;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ListPageHeader } from '@/components/ListPageHeader';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useAutomodSettings, useUpdateAutomodSettings } from '@/hooks/useAutomod';
|
||||||
|
import type { KeywordAction, MatchMode } from '@/api/automodApi';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
|
||||||
|
const AutomodSettingsPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useAutomodSettings();
|
||||||
|
const update = useUpdateAutomodSettings();
|
||||||
|
const [keywordAction, setKeywordAction] = useState<KeywordAction>('flag');
|
||||||
|
const [matchMode, setMatchMode] = useState<MatchMode>('word_boundary');
|
||||||
|
const [threshold, setThreshold] = useState('3');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
setKeywordAction(data.keyword_action);
|
||||||
|
setMatchMode(data.match_mode);
|
||||||
|
setThreshold(String(data.report_threshold));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
const n = parseInt(threshold, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 1) {
|
||||||
|
notify.error(t('automodSettings.thresholdInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update.mutate(
|
||||||
|
{
|
||||||
|
keyword_action: keywordAction,
|
||||||
|
match_mode: matchMode,
|
||||||
|
report_threshold: n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => notify.success(t('automodSettings.saved')),
|
||||||
|
onError: () => notify.error(t('automodSettings.saveFailed')),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="page-automod-settings">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="page-automod-settings">
|
||||||
|
<ListPageHeader
|
||||||
|
title={t('automodSettings.title')}
|
||||||
|
description={t('automodSettings.description')}
|
||||||
|
/>
|
||||||
|
<div className="max-w-lg space-y-4 rounded-lg border bg-card p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="keyword-action">{t('automodSettings.keywordAction')}</Label>
|
||||||
|
<Select
|
||||||
|
value={keywordAction}
|
||||||
|
onValueChange={(v) => setKeywordAction(v as KeywordAction)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="keyword-action" data-testid="select-keyword-action">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="flag">{t('automodSettings.actionFlag')}</SelectItem>
|
||||||
|
<SelectItem value="reject">{t('automodSettings.actionReject')}</SelectItem>
|
||||||
|
<SelectItem value="censor">{t('automodSettings.actionCensor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="match-mode">{t('automodSettings.matchMode')}</Label>
|
||||||
|
<Select value={matchMode} onValueChange={(v) => setMatchMode(v as MatchMode)}>
|
||||||
|
<SelectTrigger id="match-mode" data-testid="select-match-mode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="word_boundary">
|
||||||
|
{t('automodSettings.matchWordBoundary')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="substring">{t('automodSettings.matchSubstring')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="threshold">{t('automodSettings.reportThreshold')}</Label>
|
||||||
|
<Input
|
||||||
|
id="threshold"
|
||||||
|
data-testid="input-report-threshold"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button data-testid="btn-save-automod-settings" onClick={onSave} disabled={update.isPending}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutomodSettingsPage;
|
||||||
@@ -118,7 +118,7 @@ const BannedWordsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-banned-words" className="space-y-6">
|
||||||
<ListPageHeader
|
<ListPageHeader
|
||||||
title={t('bannedWords.title')}
|
title={t('bannedWords.title')}
|
||||||
description={t('bannedWords.description')}
|
description={t('bannedWords.description')}
|
||||||
@@ -126,24 +126,36 @@ const BannedWordsPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="input-banned-word"
|
||||||
placeholder={t('bannedWords.placeholder')}
|
placeholder={t('bannedWords.placeholder')}
|
||||||
value={newWord}
|
value={newWord}
|
||||||
onChange={(e) => setNewWord(e.target.value)}
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
className="w-[200px]"
|
className="w-[200px]"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleAdd} disabled={addWord.isPending || !newWord.trim()}>
|
<Button
|
||||||
|
data-testid="btn-add-banned-word"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={addWord.isPending || !newWord.trim()}
|
||||||
|
>
|
||||||
{t('common.add')}
|
{t('common.add')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="filter-banned-words-search"
|
||||||
placeholder={t('explore.searchWord')}
|
placeholder={t('explore.searchWord')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
className="w-[200px]"
|
className="w-[200px]"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleSearch}>
|
<Button
|
||||||
|
data-testid="btn-search-banned-words"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const CalendarDetailPage: React.FC = () => {
|
|||||||
const titleLabel = calendar.title || calendar.id;
|
const titleLabel = calendar.title || calendar.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
const { t } = useTranslation();
|
<div data-testid="page-calendar-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('calendars.detailTitle', { name: titleLabel })}
|
title={t('calendars.detailTitle', { name: titleLabel })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -58,7 +58,7 @@ const CalendarDetailPage: React.FC = () => {
|
|||||||
{ label: String(titleLabel) },
|
{ label: String(titleLabel) },
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/calendars')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -151,7 +151,7 @@ const CalendarDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ const CalendarListPage: React.FC = () => {
|
|||||||
const original = originalCalendarRef.current;
|
const original = originalCalendarRef.current;
|
||||||
const cleanedValues: Record<string, unknown> = {};
|
const cleanedValues: Record<string, unknown> = {};
|
||||||
|
|
||||||
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
|
// Only form fields — do not null-out entity keys absent from the form (e.g. id, owner_id).
|
||||||
allKeys.forEach((key) => {
|
Object.keys(values).forEach((key) => {
|
||||||
const newVal = (values as Record<string, unknown>)[key];
|
const newVal = (values as Record<string, unknown>)[key];
|
||||||
if (newVal === '' || newVal === undefined || newVal === null) {
|
if (newVal === '' || newVal === undefined || newVal === null) {
|
||||||
const origVal = (original as unknown as Record<string, unknown>)[key];
|
const origVal = (original as unknown as Record<string, unknown>)[key];
|
||||||
@@ -146,7 +146,7 @@ const CalendarListPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingCalendar && editModal.open) {
|
if (!editModal.open || loadingCalendar || !editingCalendar) return;
|
||||||
originalCalendarRef.current = editingCalendar;
|
originalCalendarRef.current = editingCalendar;
|
||||||
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
||||||
editForm.reset({
|
editForm.reset({
|
||||||
@@ -157,8 +157,7 @@ const CalendarListPage: React.FC = () => {
|
|||||||
category: clean(editingCalendar.category) as string,
|
category: clean(editingCalendar.category) as string,
|
||||||
reason: clean(editingCalendar.reason) as string,
|
reason: clean(editingCalendar.reason) as string,
|
||||||
});
|
});
|
||||||
}
|
}, [editingCalendar, editModal.open, loadingCalendar, editForm]);
|
||||||
}, [editingCalendar, editModal.open, editForm]);
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id });
|
const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id });
|
||||||
|
|
||||||
@@ -286,12 +285,14 @@ const CalendarListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-calendars"
|
||||||
title={t('calendars.title')}
|
title={t('calendars.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<ExploreSearch
|
<ExploreSearch
|
||||||
|
data-testid="filter-calendars-search"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={setSearchDraft}
|
onChange={setSearchDraft}
|
||||||
onSubmit={applySearch}
|
onSubmit={applySearch}
|
||||||
@@ -397,7 +398,7 @@ const CalendarListPage: React.FC = () => {
|
|||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, calendarId: null })}>
|
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, calendarId: null })}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg" data-testid="dialog-edit-calendar">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('calendars.editTitle')}</DialogTitle>
|
<DialogTitle>{t('calendars.editTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -422,7 +423,7 @@ const CalendarListPage: React.FC = () => {
|
|||||||
name="type"
|
name="type"
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectType')} />
|
<SelectValue placeholder={t('common.selectType')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -441,7 +442,7 @@ const CalendarListPage: React.FC = () => {
|
|||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectStatus')} />
|
<SelectValue placeholder={t('common.selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -468,7 +469,12 @@ const CalendarListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setEditModal({ open: false, calendarId: null })}>
|
<Button variant="outline" onClick={() => setEditModal({ open: false, calendarId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="edit-calendar-form" disabled={editHasErrors || updateCalendar.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="edit-calendar-form"
|
||||||
|
data-testid="btn-save"
|
||||||
|
disabled={editHasErrors || updateCalendar.isPending}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -476,7 +482,7 @@ const CalendarListPage: React.FC = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, calendarId: null })}>
|
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, calendarId: null })}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-delete-calendar">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('calendars.deleteTitle')}</DialogTitle>
|
<DialogTitle>{t('calendars.deleteTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -485,7 +491,12 @@ const CalendarListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, calendarId: null })}>
|
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, calendarId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteCalendar.isPending}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="btn-delete-confirm"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteCalendar.isPending}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div data-testid="page-dashboard" className="space-y-4">
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
@@ -90,7 +90,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
if (role === 'moderator') {
|
if (role === 'moderator') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-dashboard" className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.moderation')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.moderation')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleModeration')}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleModeration')}</p>
|
||||||
@@ -121,7 +121,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
if (role === 'support') {
|
if (role === 'support') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-dashboard" className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.support')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.support')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleSupport')}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleSupport')}</p>
|
||||||
@@ -155,7 +155,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
const eventsSparkline = data.events_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
|
const eventsSparkline = data.events_by_day?.map((item) => ({ date: item.date, value: item.count })) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div data-testid="page-dashboard" className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.controlCenter')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.controlCenter')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleAdmin')}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('dashboard.subtitleAdmin')}</p>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ForbiddenPage: React.FC = () => {
|
|||||||
const sectionLabel = sectionItem ? t(sectionItem.label) : t('common.notFound');
|
const sectionLabel = sectionItem ? t(sectionItem.label) : t('common.notFound');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] items-center justify-center">
|
<div data-testid="page-forbidden" className="flex min-h-[50vh] items-center justify-center">
|
||||||
<Card className="max-w-md w-full text-center">
|
<Card className="max-w-md w-full text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-4xl font-bold">{t('errors.forbiddenTitle')}</CardTitle>
|
<CardTitle className="text-4xl font-bold">{t('errors.forbiddenTitle')}</CardTitle>
|
||||||
@@ -23,7 +23,9 @@ const ForbiddenPage: React.FC = () => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button onClick={() => navigate('/dashboard')}>{t('common.toDashboard')}</Button>
|
<Button data-testid="btn-to-dashboard" onClick={() => navigate('/dashboard')}>
|
||||||
|
{t('common.toDashboard')}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const EventDetailPage: React.FC = () => {
|
|||||||
const titleLabel = event.title || event.id;
|
const titleLabel = event.title || event.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="page-event-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('events.detailTitle', { name: titleLabel })}
|
title={t('events.detailTitle', { name: titleLabel })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -69,7 +69,7 @@ const EventDetailPage: React.FC = () => {
|
|||||||
{ label: String(titleLabel) },
|
{ label: String(titleLabel) },
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" onClick={() => navigate('/events')}>
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/events')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -190,7 +190,7 @@ const EventDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ const EventListPage: React.FC = () => {
|
|||||||
capacity: values.capacity === '' || values.capacity === undefined ? undefined : Number(values.capacity),
|
capacity: values.capacity === '' || values.capacity === undefined ? undefined : Number(values.capacity),
|
||||||
};
|
};
|
||||||
|
|
||||||
const allKeys = new Set([...Object.keys(processedValues), ...Object.keys(original)]);
|
// Only form fields — do not null-out entity keys absent from the form (e.g. id).
|
||||||
allKeys.forEach((key) => {
|
Object.keys(processedValues).forEach((key) => {
|
||||||
const newVal = processedValues[key];
|
const newVal = processedValues[key];
|
||||||
if (newVal === '' || newVal === undefined || newVal === null) {
|
if (newVal === '' || newVal === undefined || newVal === null) {
|
||||||
const origVal = (original as unknown as Record<string, unknown>)[key];
|
const origVal = (original as unknown as Record<string, unknown>)[key];
|
||||||
@@ -193,7 +193,7 @@ const EventListPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingEvent && editModal.open) {
|
if (!editModal.open || loadingEvent || !editingEvent) return;
|
||||||
originalEventRef.current = editingEvent;
|
originalEventRef.current = editingEvent;
|
||||||
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
||||||
editForm.reset({
|
editForm.reset({
|
||||||
@@ -209,8 +209,7 @@ const EventListPage: React.FC = () => {
|
|||||||
online_link: clean(editingEvent.online_link) as string,
|
online_link: clean(editingEvent.online_link) as string,
|
||||||
tags: (editingEvent.tags || []).join(', '),
|
tags: (editingEvent.tags || []).join(', '),
|
||||||
});
|
});
|
||||||
}
|
}, [editingEvent, editModal.open, loadingEvent, editForm]);
|
||||||
}, [editingEvent, editModal.open, editForm]);
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => setDeleteConfirm({ open: true, eventId: id });
|
const handleDelete = (id: string) => setDeleteConfirm({ open: true, eventId: id });
|
||||||
|
|
||||||
@@ -376,6 +375,7 @@ const EventListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-events"
|
||||||
title={t('events.title')}
|
title={t('events.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -384,6 +384,7 @@ const EventListPage: React.FC = () => {
|
|||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<ExploreSearch
|
<ExploreSearch
|
||||||
|
data-testid="filter-events-search"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={setSearchDraft}
|
onChange={setSearchDraft}
|
||||||
onSubmit={applySearch}
|
onSubmit={applySearch}
|
||||||
@@ -471,7 +472,7 @@ const EventListPage: React.FC = () => {
|
|||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, eventId: null })}>
|
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, eventId: null })}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg" data-testid="dialog-edit-event">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('events.editTitle')}</DialogTitle>
|
<DialogTitle>{t('events.editTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -497,7 +498,7 @@ const EventListPage: React.FC = () => {
|
|||||||
name="event_type"
|
name="event_type"
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectType')} />
|
<SelectValue placeholder={t('common.selectType')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -516,7 +517,7 @@ const EventListPage: React.FC = () => {
|
|||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectStatus')} />
|
<SelectValue placeholder={t('common.selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -565,7 +566,12 @@ const EventListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setEditModal({ open: false, eventId: null })}>
|
<Button variant="outline" onClick={() => setEditModal({ open: false, eventId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="edit-event-form" disabled={editHasErrors || updateEvent.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="edit-event-form"
|
||||||
|
data-testid="btn-save"
|
||||||
|
disabled={editHasErrors || updateEvent.isPending}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -573,7 +579,7 @@ const EventListPage: React.FC = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, eventId: null })}>
|
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, eventId: null })}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-delete-event">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('events.deleteTitle')}</DialogTitle>
|
<DialogTitle>{t('events.deleteTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -582,7 +588,12 @@ const EventListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, eventId: null })}>
|
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, eventId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteEvent.isPending}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="btn-delete-confirm"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteEvent.isPending}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
<div data-testid="page-inbox-reports" className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
||||||
@@ -164,6 +164,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="btn-bulk-reviewed"
|
||||||
disabled={bulkPending}
|
disabled={bulkPending}
|
||||||
onClick={() => void handleBulk('reviewed')}
|
onClick={() => void handleBulk('reviewed')}
|
||||||
>
|
>
|
||||||
@@ -172,6 +173,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
data-testid="btn-bulk-dismissed"
|
||||||
disabled={bulkPending}
|
disabled={bulkPending}
|
||||||
onClick={() => void handleBulk('dismissed')}
|
onClick={() => void handleBulk('dismissed')}
|
||||||
>
|
>
|
||||||
@@ -182,6 +184,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant={filter === 'pending' ? 'default' : 'outline'}
|
variant={filter === 'pending' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="filter-pending"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter('pending');
|
setFilter('pending');
|
||||||
setCheckedIds(new Set());
|
setCheckedIds(new Set());
|
||||||
@@ -192,6 +195,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant={filter === 'all' ? 'default' : 'outline'}
|
variant={filter === 'all' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="filter-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter('all');
|
setFilter('all');
|
||||||
setCheckedIds(new Set());
|
setCheckedIds(new Set());
|
||||||
@@ -233,6 +237,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-testid={`report-row-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/60',
|
'flex w-full items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/60',
|
||||||
selectedId === item.id && 'bg-muted'
|
selectedId === item.id && 'bg-muted'
|
||||||
@@ -240,6 +245,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
data-testid={`report-check-${item.id}`}
|
||||||
className="size-3.5 shrink-0 accent-primary"
|
className="size-3.5 shrink-0 accent-primary"
|
||||||
checked={checkedIds.has(item.id)}
|
checked={checkedIds.has(item.id)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -251,6 +257,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`report-select-${item.id}`}
|
||||||
onClick={() => selectReport(item.id)}
|
onClick={() => selectReport(item.id)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
>
|
>
|
||||||
@@ -284,7 +291,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
) : report ? (
|
) : report ? (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader data-testid="inbox-report-detail">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-lg">{t('inbox.reportCard', { id: report.id })}</CardTitle>
|
<CardTitle className="text-lg">{t('inbox.reportCard', { id: report.id })}</CardTitle>
|
||||||
<Badge variant={reportBadgeVariant(report.status)}>
|
<Badge variant={reportBadgeVariant(report.status)}>
|
||||||
@@ -346,10 +353,19 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
{report.status === 'pending' && (
|
{report.status === 'pending' && (
|
||||||
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
||||||
<Button onClick={() => handleStatus('reviewed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-reviewed"
|
||||||
|
onClick={() => handleStatus('reviewed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.reviewed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
{t('common.reviewed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => handleStatus('dismissed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-dismissed"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleStatus('dismissed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.dismissed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
{t('common.dismissed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -127,20 +127,35 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
<div data-testid="page-inbox-tickets" className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.ticketsTitle')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.ticketsTitle')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t('inbox.ticketsHints')}</p>
|
<p className="text-sm text-muted-foreground">{t('inbox.ticketsHints')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex flex-wrap gap-2">
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
<Button variant={filter === 'active' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('active')}>
|
<Button
|
||||||
|
variant={filter === 'active' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
data-testid="filter-active"
|
||||||
|
onClick={() => setFilter('active')}
|
||||||
|
>
|
||||||
{t('inbox.active')}
|
{t('inbox.active')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={filter === 'mine' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('mine')}>
|
<Button
|
||||||
|
variant={filter === 'mine' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
data-testid="filter-mine"
|
||||||
|
onClick={() => setFilter('mine')}
|
||||||
|
>
|
||||||
{t('inbox.mine')}
|
{t('inbox.mine')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
|
<Button
|
||||||
|
variant={filter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
data-testid="filter-all"
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
{t('inbox.all')}
|
{t('inbox.all')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +182,7 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`ticket-row-${item.id}`}
|
||||||
onClick={() => selectTicket(item.id)}
|
onClick={() => selectTicket(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/60',
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/60',
|
||||||
@@ -253,11 +269,20 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
{(ticket.status === 'open' || ticket.status === 'in_progress') && (
|
{(ticket.status === 'open' || ticket.status === 'in_progress') && (
|
||||||
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
||||||
{ticket.status === 'open' && (
|
{ticket.status === 'open' && (
|
||||||
<Button variant="secondary" onClick={handleInProgress} disabled={updateTicket.isPending}>
|
<Button
|
||||||
|
data-testid="btn-ticket-in-progress"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleInProgress}
|
||||||
|
disabled={updateTicket.isPending}
|
||||||
|
>
|
||||||
{t('common.inProgress')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
{t('common.inProgress')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleResolve} disabled={updateTicket.isPending}>
|
<Button
|
||||||
|
data-testid="btn-ticket-resolved"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={updateTicket.isPending}
|
||||||
|
>
|
||||||
{t('common.resolved')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
{t('common.resolved')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ const MonitoringPage: React.FC = () => {
|
|||||||
const healthy = summary.online > 0 && summary.cpuPeak < 85;
|
const healthy = summary.online > 0 && summary.cpuPeak < 85;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-monitoring" className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -303,11 +303,12 @@ const MonitoringPage: React.FC = () => {
|
|||||||
{t('monitoring.subtitle', { minutes })}
|
{t('monitoring.subtitle', { minutes })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5" data-testid="filter-monitoring-range">
|
||||||
{TIME_RANGE_KEYS.map((r) => (
|
{TIME_RANGE_KEYS.map((r) => (
|
||||||
<Button
|
<Button
|
||||||
key={r.value}
|
key={r.value}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid={`filter-monitoring-range-${r.value}`}
|
||||||
variant={minutes === r.value ? 'default' : 'outline'}
|
variant={minutes === r.value ? 'default' : 'outline'}
|
||||||
onClick={() => setMinutes(r.value)}
|
onClick={() => setMinutes(r.value)}
|
||||||
>
|
>
|
||||||
@@ -364,6 +365,7 @@ const MonitoringPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={node}
|
key={node}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`filter-monitoring-node-${node}`}
|
||||||
onClick={() => selectNode(node)}
|
onClick={() => selectNode(node)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors',
|
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors',
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
const emDash = t('common.emDash');
|
const emDash = t('common.emDash');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-2xl">
|
<Card data-testid="page-profile" className="max-w-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('profile.title')}</CardTitle>
|
<CardTitle>{t('profile.title')}</CardTitle>
|
||||||
<CardDescription>{t('profile.description')}</CardDescription>
|
<CardDescription>{t('profile.description')}</CardDescription>
|
||||||
@@ -232,7 +232,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<Button onClick={startEditing}>
|
<Button data-testid="btn-edit-profile" onClick={startEditing}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
{t('profile.edit')}
|
{t('profile.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -309,10 +309,10 @@ const ProfilePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" disabled={updateProfile.isPending}>
|
<Button type="submit" data-testid="btn-save-profile" disabled={updateProfile.isPending}>
|
||||||
{updateProfile.isPending ? t('common.saving') : t('common.save')}
|
{updateProfile.isPending ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
<Button type="button" data-testid="btn-cancel-profile" variant="outline" onClick={() => setEditing(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
: t('common.dismissed');
|
: t('common.dismissed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="page-report-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('reports.detailTitle', { id: report.id })}
|
title={t('reports.detailTitle', { id: report.id })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -171,10 +171,19 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
{report.status === 'pending' && (
|
{report.status === 'pending' && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => setConfirmStatus('reviewed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-reviewed"
|
||||||
|
onClick={() => setConfirmStatus('reviewed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.reviewed')}
|
{t('common.reviewed')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setConfirmStatus('dismissed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-dismissed"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmStatus('dismissed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.dismissed')}
|
{t('common.dismissed')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +193,7 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={confirmStatus !== null} onOpenChange={(open) => !open && setConfirmStatus(null)}>
|
<Dialog open={confirmStatus !== null} onOpenChange={(open) => !open && setConfirmStatus(null)}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-confirm-report-status">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('common.confirmMarkAs', { status: statusLabel })}</DialogTitle>
|
<DialogTitle>{t('common.confirmMarkAs', { status: statusLabel })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -192,13 +201,13 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setConfirmStatus(null)}>
|
<Button variant="outline" onClick={() => setConfirmStatus(null)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirm} disabled={updateReport.isPending}>
|
<Button data-testid="btn-confirm-status" onClick={handleConfirm} disabled={updateReport.isPending}>
|
||||||
{updateReport.isPending ? t('common.saving') : t('common.yes')}
|
{updateReport.isPending ? t('common.saving') : t('common.yes')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ const ReportListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-reports"
|
||||||
title={t('reports.title')}
|
title={t('reports.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -342,6 +343,7 @@ const ReportListPage: React.FC = () => {
|
|||||||
{detailModal.report.status === 'pending' && (
|
{detailModal.report.status === 'pending' && (
|
||||||
<DialogFooter className="mt-4 sm:justify-start">
|
<DialogFooter className="mt-4 sm:justify-start">
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-report-reviewed"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateReport.mutate(
|
updateReport.mutate(
|
||||||
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
|
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
|
||||||
@@ -353,6 +355,7 @@ const ReportListPage: React.FC = () => {
|
|||||||
{t('common.reviewed')}
|
{t('common.reviewed')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-report-dismissed"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateReport.mutate(
|
updateReport.mutate(
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
data-testid={`review-check-${row.original.id}`}
|
||||||
className="h-4 w-4 rounded border"
|
className="h-4 w-4 rounded border"
|
||||||
checked={selectedRowKeys.includes(row.original.id)}
|
checked={selectedRowKeys.includes(row.original.id)}
|
||||||
onChange={() => toggleRow(row.original.id)}
|
onChange={() => toggleRow(row.original.id)}
|
||||||
@@ -367,6 +368,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-reviews"
|
||||||
title={t('reviews.title')}
|
title={t('reviews.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -418,6 +420,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-reviews-bulk-hidden"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openBulkStatusModal('hidden')}
|
onClick={() => openBulkStatusModal('hidden')}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
@@ -425,6 +428,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
{t('reviews.hideSelected')}
|
{t('reviews.hideSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-reviews-bulk-visible"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openBulkStatusModal('visible')}
|
onClick={() => openBulkStatusModal('visible')}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
@@ -432,6 +436,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
{t('reviews.showSelected')}
|
{t('reviews.showSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-reviews-bulk-deleted"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => openBulkStatusModal('deleted')}
|
onClick={() => openBulkStatusModal('deleted')}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
@@ -526,7 +531,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
open={bulkStatusModal.open}
|
open={bulkStatusModal.open}
|
||||||
onOpenChange={(open) => !open && setBulkStatusModal({ open: false, status: 'hidden' })}
|
onOpenChange={(open) => !open && setBulkStatusModal({ open: false, status: 'hidden' })}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-reviews-bulk-status">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t('reviews.bulkStatusTitle', {
|
{t('reviews.bulkStatusTitle', {
|
||||||
@@ -539,6 +544,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('common.reason')}</Label>
|
<Label>{t('common.reason')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
data-testid="input-bulk-reason"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={t('reviews.bulkReasonPlaceholder')}
|
placeholder={t('reviews.bulkReasonPlaceholder')}
|
||||||
{...bulkStatusForm.register('reason')}
|
{...bulkStatusForm.register('reason')}
|
||||||
@@ -557,8 +563,12 @@ const ReviewListPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="bulk-status-form" disabled={bulkUpdate.isPending}>
|
<Button
|
||||||
{t('common.apply')}
|
type="submit"
|
||||||
|
form="bulk-status-form"
|
||||||
|
data-testid="btn-save"
|
||||||
|
disabled={bulkUpdate.isPending}
|
||||||
|
> {t('common.apply')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -141,15 +141,14 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingSubscription && editModal.open) {
|
if (!editModal.open || loadingSubscription || !editingSubscription) return;
|
||||||
form.reset({
|
form.reset({
|
||||||
plan: editingSubscription.plan,
|
plan: editingSubscription.plan,
|
||||||
status: editingSubscription.status,
|
status: editingSubscription.status,
|
||||||
trial_used: editingSubscription.trial_used,
|
trial_used: editingSubscription.trial_used,
|
||||||
expires_at: toDatetimeLocal(editingSubscription.expires_at),
|
expires_at: toDatetimeLocal(editingSubscription.expires_at),
|
||||||
});
|
});
|
||||||
}
|
}, [editingSubscription, editModal.open, loadingSubscription, form]);
|
||||||
}, [editingSubscription, editModal.open, form]);
|
|
||||||
|
|
||||||
const handleEdit = (sub: Subscription) => setEditModal({ open: true, subscriptionId: sub.id });
|
const handleEdit = (sub: Subscription) => setEditModal({ open: true, subscriptionId: sub.id });
|
||||||
|
|
||||||
@@ -277,12 +276,13 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-subscriptions"
|
||||||
title={t('subscriptions.title')}
|
title={t('subscriptions.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2" data-testid="filter-subscriptions">
|
||||||
<Select
|
<Select
|
||||||
value={params.plan ?? ALL}
|
value={params.plan ?? ALL}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) =>
|
||||||
@@ -363,7 +363,7 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
open={editModal.open}
|
open={editModal.open}
|
||||||
onOpenChange={(open) => !open && setEditModal({ open: false, subscriptionId: null })}
|
onOpenChange={(open) => !open && setEditModal({ open: false, subscriptionId: null })}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-edit-subscription">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('subscriptions.editTitle')}</DialogTitle>
|
<DialogTitle>{t('subscriptions.editTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -381,7 +381,7 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectPlan')} />
|
<SelectValue placeholder={t('common.selectPlan')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -403,7 +403,7 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectStatus')} />
|
<SelectValue placeholder={t('common.selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -447,8 +447,12 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setEditModal({ open: false, subscriptionId: null })}>
|
<Button variant="outline" onClick={() => setEditModal({ open: false, subscriptionId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="edit-subscription-form" disabled={updateSubscription.isPending}>
|
<Button
|
||||||
{t('common.save')}
|
type="submit"
|
||||||
|
form="edit-subscription-form"
|
||||||
|
data-testid="btn-save"
|
||||||
|
disabled={updateSubscription.isPending}
|
||||||
|
> {t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -458,7 +462,7 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
open={deleteConfirm.open}
|
open={deleteConfirm.open}
|
||||||
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, subscriptionId: null })}
|
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, subscriptionId: null })}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-delete-subscription">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('subscriptions.deleteTitle')}</DialogTitle>
|
<DialogTitle>{t('subscriptions.deleteTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -467,7 +471,12 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, subscriptionId: null })}>
|
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, subscriptionId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteSubscription.isPending}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="btn-delete-confirm"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteSubscription.isPending}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ const TicketDetailPage: React.FC = () => {
|
|||||||
<dt className="text-muted-foreground">{t('common.sender')}</dt>
|
<dt className="text-muted-foreground">{t('common.sender')}</dt>
|
||||||
<dd>{getUserLink()}</dd>
|
<dd>{getUserLink()}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
|
<dt className="text-muted-foreground">{t('tickets.source')}</dt>
|
||||||
|
<dd>
|
||||||
|
{ticket.source === 'frontend'
|
||||||
|
? t('tickets.sourceFrontend')
|
||||||
|
: ticket.source === 'manual'
|
||||||
|
? t('tickets.sourceManual')
|
||||||
|
: t('tickets.sourceBackend')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
|
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
|
||||||
<dd>{ticket.error_hash}</dd>
|
<dd>{ticket.error_hash}</dd>
|
||||||
|
|||||||
@@ -23,9 +23,17 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
const TABLE_KEY = 'tickets';
|
const TABLE_KEY = 'tickets';
|
||||||
|
const SOURCE_ALL = '__all__';
|
||||||
|
|
||||||
const isBadValue = (val: unknown) =>
|
const isBadValue = (val: unknown) =>
|
||||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||||
@@ -37,6 +45,12 @@ const ticketStatusVariant = (status: string) => {
|
|||||||
return 'outline' as const;
|
return 'outline' as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sourceLabel(source: string | undefined, t: TFunction): string {
|
||||||
|
if (source === 'frontend') return t('tickets.sourceFrontend');
|
||||||
|
if (source === 'manual') return t('tickets.sourceManual');
|
||||||
|
return t('tickets.sourceBackend');
|
||||||
|
}
|
||||||
|
|
||||||
function ticketActions(
|
function ticketActions(
|
||||||
handlers: { onOpen: () => void; onDelete: () => void },
|
handlers: { onOpen: () => void; onDelete: () => void },
|
||||||
t: TFunction
|
t: TFunction
|
||||||
@@ -111,6 +125,13 @@ const TicketListPage: React.FC = () => {
|
|||||||
return isBadValue(text) ? '-' : text;
|
return isBadValue(text) ? '-' : text;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'source',
|
||||||
|
header: t('tickets.source'),
|
||||||
|
size: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => sourceLabel(getValue<string | undefined>(), t),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: t('common.status'),
|
header: t('common.status'),
|
||||||
@@ -139,10 +160,13 @@ const TicketListPage: React.FC = () => {
|
|||||||
const record = row.original;
|
const record = row.original;
|
||||||
return (
|
return (
|
||||||
<RowActionsMenu
|
<RowActionsMenu
|
||||||
actions={ticketActions({
|
actions={ticketActions(
|
||||||
|
{
|
||||||
onOpen: () => navigate(`/tickets/${record.id}`),
|
onOpen: () => navigate(`/tickets/${record.id}`),
|
||||||
onDelete: () => handleDelete(record.id),
|
onDelete: () => handleDelete(record.id),
|
||||||
}, t)}
|
},
|
||||||
|
t
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -163,16 +187,22 @@ const TicketListPage: React.FC = () => {
|
|||||||
title,
|
title,
|
||||||
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
|
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
|
||||||
badges: (
|
badges: (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">{sourceLabel(record.source, t)}</Badge>
|
||||||
<Badge variant={ticketStatusVariant(record.status)}>
|
<Badge variant={ticketStatusVariant(record.status)}>
|
||||||
{formatStatusLabel(record.status)}
|
{formatStatusLabel(record.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
meta: `×${record.count} · ${record.last_seen}`,
|
meta: `×${record.count} · ${record.last_seen}`,
|
||||||
onActivate: () => navigate(`/tickets/${record.id}`),
|
onActivate: () => navigate(`/tickets/${record.id}`),
|
||||||
actions: ticketActions({
|
actions: ticketActions(
|
||||||
|
{
|
||||||
onOpen: () => navigate(`/tickets/${record.id}`),
|
onOpen: () => navigate(`/tickets/${record.id}`),
|
||||||
onDelete: () => handleDelete(record.id),
|
onDelete: () => handleDelete(record.id),
|
||||||
}, t),
|
},
|
||||||
|
t
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [data?.data, navigate, t]);
|
}, [data?.data, navigate, t]);
|
||||||
@@ -189,6 +219,32 @@ const TicketListPage: React.FC = () => {
|
|||||||
to: '/inbox/tickets',
|
to: '/inbox/tickets',
|
||||||
actionLabel: t('common.openInbox'),
|
actionLabel: t('common.openInbox'),
|
||||||
}}
|
}}
|
||||||
|
toolbar={
|
||||||
|
<Select
|
||||||
|
value={params.source ?? SOURCE_ALL}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = { ...prev, offset: 0 };
|
||||||
|
if (value === SOURCE_ALL) {
|
||||||
|
delete next.source;
|
||||||
|
} else {
|
||||||
|
next.source = value as TicketListParams['source'];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]" data-testid="filter-ticket-source">
|
||||||
|
<SelectValue placeholder={t('tickets.source')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={SOURCE_ALL}>{t('tickets.sourceAll')}</SelectItem>
|
||||||
|
<SelectItem value="backend">{t('tickets.sourceBackend')}</SelectItem>
|
||||||
|
<SelectItem value="frontend">{t('tickets.sourceFrontend')}</SelectItem>
|
||||||
|
<SelectItem value="manual">{t('tickets.sourceManual')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ExploreDataView
|
<ExploreDataView
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -203,14 +259,20 @@ const TicketListPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
|
<Dialog
|
||||||
|
open={deleteConfirm.open}
|
||||||
|
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
|
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
|
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteConfirm({ open: false, ticketId: null })}
|
||||||
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
|
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const UserDetailPage: React.FC = () => {
|
|||||||
const emDash = t('common.emDash');
|
const emDash = t('common.emDash');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="page-user-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={String(titleName)}
|
title={String(titleName)}
|
||||||
description={`ID ${user.id}`}
|
description={`ID ${user.id}`}
|
||||||
@@ -71,7 +71,7 @@ const UserDetailPage: React.FC = () => {
|
|||||||
{ label: String(titleName) },
|
{ label: String(titleName) },
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" onClick={() => navigate('/users')}>
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/users')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ const UserListPage: React.FC = () => {
|
|||||||
const original = originalUserRef.current;
|
const original = originalUserRef.current;
|
||||||
const cleanedValues: Record<string, unknown> = {};
|
const cleanedValues: Record<string, unknown> = {};
|
||||||
|
|
||||||
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
|
// Only form fields — do not null-out entity keys absent from the form (e.g. id, timestamps).
|
||||||
allKeys.forEach((key) => {
|
Object.keys(values).forEach((key) => {
|
||||||
const newVal = (values as Record<string, unknown>)[key];
|
const newVal = (values as Record<string, unknown>)[key];
|
||||||
if (newVal === '' || newVal === undefined || newVal === null) {
|
if (newVal === '' || newVal === undefined || newVal === null) {
|
||||||
const origVal = (original as unknown as Record<string, unknown>)[key];
|
const origVal = (original as unknown as Record<string, unknown>)[key];
|
||||||
@@ -167,7 +167,7 @@ const UserListPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingUser && editModal.open) {
|
if (!editModal.open || loadingUser || !editingUser) return;
|
||||||
originalUserRef.current = editingUser;
|
originalUserRef.current = editingUser;
|
||||||
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
|
||||||
editForm.reset({
|
editForm.reset({
|
||||||
@@ -177,8 +177,7 @@ const UserListPage: React.FC = () => {
|
|||||||
status: clean(editingUser.status) as string,
|
status: clean(editingUser.status) as string,
|
||||||
reason: clean(editingUser.reason) as string,
|
reason: clean(editingUser.reason) as string,
|
||||||
});
|
});
|
||||||
}
|
}, [editingUser, editModal.open, loadingUser, editForm]);
|
||||||
}, [editingUser, editModal.open, editForm]);
|
|
||||||
|
|
||||||
const handleStatusChange = (user: User) => {
|
const handleStatusChange = (user: User) => {
|
||||||
if (user.status === 'deleted') return;
|
if (user.status === 'deleted') return;
|
||||||
@@ -329,12 +328,14 @@ const UserListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-users"
|
||||||
title={t('users.title')}
|
title={t('users.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<ExploreSearch
|
<ExploreSearch
|
||||||
|
data-testid="filter-users-search"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={setSearchDraft}
|
onChange={setSearchDraft}
|
||||||
onSubmit={applySearch}
|
onSubmit={applySearch}
|
||||||
@@ -356,7 +357,7 @@ const UserListPage: React.FC = () => {
|
|||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
|
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-edit-user">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('users.editTitle')}</DialogTitle>
|
<DialogTitle>{t('users.editTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -372,8 +373,8 @@ const UserListPage: React.FC = () => {
|
|||||||
<Input {...editForm.register('email')} />
|
<Input {...editForm.register('email')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('common.nickname')}</Label>
|
<Label htmlFor="edit-user-nickname">{t('common.nickname')}</Label>
|
||||||
<Input {...editForm.register('nickname')} />
|
<Input id="edit-user-nickname" {...editForm.register('nickname')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('common.role')}</Label>
|
<Label>{t('common.role')}</Label>
|
||||||
@@ -382,7 +383,7 @@ const UserListPage: React.FC = () => {
|
|||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectRole')} />
|
<SelectValue placeholder={t('common.selectRole')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -401,7 +402,7 @@ const UserListPage: React.FC = () => {
|
|||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
rules={{ required: true }}
|
rules={{ required: true }}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('common.selectStatus')} />
|
<SelectValue placeholder={t('common.selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -424,7 +425,12 @@ const UserListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setEditModal({ open: false, userId: null })}>
|
<Button variant="outline" onClick={() => setEditModal({ open: false, userId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="edit-user-form" disabled={editHasErrors || updateUser.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="edit-user-form"
|
||||||
|
data-testid="btn-save"
|
||||||
|
disabled={editHasErrors || updateUser.isPending}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -437,7 +443,7 @@ const UserListPage: React.FC = () => {
|
|||||||
!open && setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
|
!open && setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-user-status">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('users.statusTitle', { status: statusModal.newStatus })}</DialogTitle>
|
<DialogTitle>{t('users.statusTitle', { status: statusModal.newStatus })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -468,7 +474,7 @@ const UserListPage: React.FC = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, userId: null })}>
|
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, userId: null })}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-delete-user">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('users.deleteTitle')}</DialogTitle>
|
<DialogTitle>{t('users.deleteTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -477,7 +483,12 @@ const UserListPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, userId: null })}>
|
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, userId: null })}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteUser.isPending}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="btn-delete-confirm"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteUser.isPending}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -239,10 +239,12 @@ export interface Ticket {
|
|||||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
assigned_to: string | null;
|
assigned_to: string | null;
|
||||||
resolution_note: string | null;
|
resolution_note: string | null;
|
||||||
|
source?: 'backend' | 'frontend' | 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketListParams {
|
export interface TicketListParams {
|
||||||
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
|
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
source?: 'backend' | 'frontend' | 'manual';
|
||||||
assigned_to?: string;
|
assigned_to?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -6,8 +6,19 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'https://admin-api.dev.eventhub.local';
|
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'https://admin-api.dev.eventhub.local';
|
||||||
const wsUrl = process.env.VITE_WS_URL ?? 'wss://admin-ws.dev.eventhub.local';
|
const wsUrl = process.env.VITE_WS_URL ?? 'wss://admin-ws.dev.eventhub.local';
|
||||||
|
|
||||||
|
const appVersion = process.env.VITE_APP_VERSION ?? '0.0';
|
||||||
|
const appBuild = process.env.VITE_APP_BUILD ?? '0';
|
||||||
|
const gitSha = process.env.VITE_GIT_SHA ?? 'dev';
|
||||||
|
const builtAt = process.env.VITE_BUILT_AT ?? '';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
|
||||||
|
'import.meta.env.VITE_APP_BUILD': JSON.stringify(appBuild),
|
||||||
|
'import.meta.env.VITE_GIT_SHA': JSON.stringify(gitSha),
|
||||||
|
'import.meta.env.VITE_BUILT_AT': JSON.stringify(builtAt),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user