Compare commits
19 Commits
worktree-c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636900110a | ||
|
|
d78ee4397b | ||
|
|
ebdb36b7d0 | ||
|
|
93ff6cfb67 | ||
|
|
ed4c4a52eb | ||
|
|
2ca8428be4 | ||
|
|
6fffc06c28 | ||
|
|
ffcb901376 | ||
|
|
30469e74c9 | ||
|
|
5646e739c2 | ||
|
|
bbbdf8cd09 | ||
|
|
f727429699 | ||
|
|
e268e2dbca | ||
|
|
3de0d2f0fe | ||
|
|
0abbc147e2 | ||
|
|
6210480952 | ||
|
|
e17f4110f1 | ||
|
|
fa46492759 | ||
|
|
3965541879 |
@@ -2,6 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -32,6 +33,10 @@ jobs:
|
|||||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Sync SvelteKit
|
||||||
|
run: npx svelte-kit sync
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -56,6 +61,26 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Assert no (upload|download)-artifact past v3
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Self-test: verify the regex catches v4+ and does not catch v3.
|
||||||
|
tmp=$(mktemp)
|
||||||
|
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
|
||||||
|
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
|
||||||
|
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
|
||||||
|
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
|
||||||
|
rm "$tmp"
|
||||||
|
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
|
||||||
|
# Both upload-artifact and download-artifact share the same incompatibility.
|
||||||
|
# Pin to @v3. See ADR-014 / #557.
|
||||||
|
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
|
||||||
|
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run unit and component tests with coverage
|
- name: Run unit and component tests with coverage
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -77,9 +102,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload coverage reports
|
- name: Upload coverage reports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-reports
|
name: coverage-reports
|
||||||
path: |
|
path: |
|
||||||
@@ -113,9 +139,10 @@ jobs:
|
|||||||
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
|
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
|
||||||
echo "PASS: only /hilfe/transkription.html prerendered."
|
echo "PASS: only /hilfe/transkription.html prerendered."
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: unit-test-screenshots
|
name: unit-test-screenshots
|
||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
@@ -170,6 +197,14 @@ jobs:
|
|||||||
./mvnw clean test
|
./mvnw clean test
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: Upload surefire reports
|
||||||
|
if: always()
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: surefire-reports
|
||||||
|
path: backend/target/surefire-reports/
|
||||||
|
|
||||||
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
|
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
|
||||||
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
|
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
|
||||||
# the JSON keys would silently break it (fail2ban-regex would return
|
# the JSON keys would silently break it (fail2ban-regex would return
|
||||||
@@ -269,6 +304,7 @@ jobs:
|
|||||||
MAIL_HOST=mailpit
|
MAIL_HOST=mailpit
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
APP_MAIL_FROM=noreply@local
|
APP_MAIL_FROM=noreply@local
|
||||||
|
IMPORT_HOST_DIR=/tmp/dummy-import
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Bring up minio
|
- name: Bring up minio
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload coverage log on failure
|
- name: Upload coverage log on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-log-run-${{ matrix.run }}
|
name: coverage-log-run-${{ matrix.run }}
|
||||||
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||||
|
|||||||
@@ -273,6 +273,16 @@
|
|||||||
</profiles>
|
</profiles>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
2
backend/src/test/resources/application.properties
Normal file
2
backend/src/test/resources/application.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
logging.level.root=WARN
|
||||||
|
logging.level.org.raddatz=INFO
|
||||||
122
docs/adr/014-upload-artifact-v3-pin.md
Normal file
122
docs/adr/014-upload-artifact-v3-pin.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# ADR 014 — Pin actions/upload-artifact to v3 (Gitea act_runner v4 protocol incompatibility)
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-05-14
|
||||||
|
**Issues:** [#557 — re-regression](https://git.raddatz.cloud/marcel/familienarchiv/issues/557) · [#14 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/14)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`actions/upload-artifact` is available in two incompatible major versions. The v4 client
|
||||||
|
uploads via a GitHub-specific artifact API that is **not implemented** in Gitea's
|
||||||
|
`act_runner` (the self-hosted CI substrate established by ADR-011). When a workflow step
|
||||||
|
uses `actions/upload-artifact@v4` on this runner, `act_runner` returns a non-zero exit
|
||||||
|
code from the v4 client even when all tests pass, producing:
|
||||||
|
|
||||||
|
> green test suite — red job status — no artifact uploaded
|
||||||
|
|
||||||
|
The failure lands in the upload step, _after_ the test output, making it hard to diagnose
|
||||||
|
from the build log.
|
||||||
|
|
||||||
|
### Incident history
|
||||||
|
|
||||||
|
| Date | Commit | Event |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-03-19 | `9f3f022e` | Original downgrade: `upload-artifact@v4 → v3` |
|
||||||
|
| 2026-03-19 | `4142c7cd` | Rationale committed; closes #14 |
|
||||||
|
| 2026-05-05 | `410b91e2` | Re-regression: upgraded back to v4 without referencing #14 |
|
||||||
|
| 2026-05-14 | this PR | Second downgrade + ADR + grep guard |
|
||||||
|
|
||||||
|
The root cause of the re-regression was institutional-memory failure: the original
|
||||||
|
rationale was captured only in a commit body, invisible at the point of change (the
|
||||||
|
`uses:` line). This ADR, the inline comments, and the grep guard are the three
|
||||||
|
defence layers that replace that missing breadcrumb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Pin all `actions/upload-artifact` and `actions/download-artifact` call sites to `@v3`.**
|
||||||
|
|
||||||
|
Both action families share the same v4 protocol incompatibility with `act_runner`.
|
||||||
|
Pinning to the major tag (`@v3`) keeps us on the latest v3 patch without Renovate noise.
|
||||||
|
|
||||||
|
Three call sites are pinned:
|
||||||
|
- `.gitea/workflows/ci.yml` — "Upload coverage reports" step
|
||||||
|
- `.gitea/workflows/ci.yml` — "Upload screenshots" step
|
||||||
|
- `.gitea/workflows/coverage-flake-probe.yml` — "Upload coverage log on failure" step
|
||||||
|
|
||||||
|
Each pinned `uses:` line carries a load-bearing inline comment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
```
|
||||||
|
|
||||||
|
A CI grep guard enforces the constraint automatically (see below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Enforcement layers (defence in depth)
|
||||||
|
|
||||||
|
1. **Inline comments** on every `uses:` line — visible at the point of change.
|
||||||
|
2. **CI grep guard** in `.gitea/workflows/ci.yml` ("Assert no (upload|download)-artifact
|
||||||
|
past v3") — fails the build if a future commit re-introduces `@v4` or higher on any
|
||||||
|
workflow file. Anchored to YAML `uses:` lines to avoid false positives on embedded
|
||||||
|
shell strings. Includes a self-test that proves the regex catches v4+ before scanning
|
||||||
|
the repo.
|
||||||
|
3. **This ADR** — canonical rationale; cross-referenced by comments and guard message.
|
||||||
|
|
||||||
|
### How to spot the symptom
|
||||||
|
|
||||||
|
- Test suite output shows green (vitest, surefire, pytest all exit 0)
|
||||||
|
- CI job status shows red
|
||||||
|
- Artifacts section of the run is empty
|
||||||
|
- Build log shows a non-zero exit from the `Upload …` step immediately after green tests
|
||||||
|
|
||||||
|
### `@v3` maintenance-mode status
|
||||||
|
|
||||||
|
GitHub placed `actions/upload-artifact@v3` in maintenance mode (no new features) but it
|
||||||
|
has not been removed and carries no known unpatched CVE as of this writing. If GitHub
|
||||||
|
publishes a v3-specific security advisory, that is an additional trigger to re-evaluate
|
||||||
|
(see upgrade conditions below).
|
||||||
|
|
||||||
|
### When to remove this pin
|
||||||
|
|
||||||
|
Re-evaluate pinning **when either condition is met:**
|
||||||
|
|
||||||
|
1. `gitea/act_runner` ships a release with v4 artifact protocol support. Track upstream:
|
||||||
|
<https://gitea.com/gitea/act_runner>
|
||||||
|
2. `actions/upload-artifact@v3` acquires an unpatched CVE that cannot be mitigated
|
||||||
|
at the runner level.
|
||||||
|
|
||||||
|
When upgrading: remove the grep guard step, update all three `uses:` lines, remove the
|
||||||
|
inline comments, and update this ADR's status to Superseded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
### SHA pinning (`uses: actions/upload-artifact@<sha>`)
|
||||||
|
|
||||||
|
More secure against action repository compromise, but adds Renovate update friction
|
||||||
|
and is disproportionate for a self-hosted, single-tenant Gitea instance with one
|
||||||
|
trusted contributor (ADR-011). Rejected.
|
||||||
|
|
||||||
|
### Minor/patch pinning (`@v3.4.0`)
|
||||||
|
|
||||||
|
Avoids Renovate PRs but freezes us on a specific patch. The v3 major track is in
|
||||||
|
maintenance mode — minor pinning has no benefit and would require manual updates
|
||||||
|
for any v3 security patches. Rejected.
|
||||||
|
|
||||||
|
### Renovate `packageRules` bypass
|
||||||
|
|
||||||
|
Would prevent automated PRs from proposing v4. Not needed while Renovate is not
|
||||||
|
configured for this repository. Revisit if Renovate is introduced.
|
||||||
|
|
||||||
|
### Migrating the runner to a v4-compatible Gitea release
|
||||||
|
|
||||||
|
Out of scope for this issue. A separate decision; tracked in #557's non-goals.
|
||||||
@@ -200,7 +200,7 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||||
with:
|
with:
|
||||||
name: unit-test-screenshots
|
name: unit-test-screenshots
|
||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
@@ -227,7 +227,7 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||||
with:
|
with:
|
||||||
name: backend-test-results
|
name: backend-test-results
|
||||||
path: backend/target/surefire-reports/
|
path: backend/target/surefire-reports/
|
||||||
@@ -329,7 +329,7 @@ jobs:
|
|||||||
E2E_BACKEND_URL: http://localhost:8080
|
E2E_BACKEND_URL: http://localhost:8080
|
||||||
- name: Upload E2E results
|
- name: Upload E2E results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||||
with:
|
with:
|
||||||
name: e2e-results
|
name: e2e-results
|
||||||
path: frontend/test-results/e2e/
|
path: frontend/test-results/e2e/
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ npm run check # svelte-check (type checking)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test # Vitest unit + server tests (headless)
|
npm run test # Vitest unit + server tests (headless)
|
||||||
npm run test:coverage # Coverage report (server project only)
|
npm run test:coverage # Coverage report (server + client)
|
||||||
npm run test:e2e # Playwright E2E tests
|
npm run test:e2e # Playwright E2E tests
|
||||||
npm run test:e2e:headed # Playwright E2E with visible browser
|
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||||
npm run test:e2e:ui # Playwright UI mode
|
npm run test:e2e:ui # Playwright UI mode
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
|
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
const availableStandard = $derived([
|
const availableStandard = $derived([
|
||||||
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
||||||
@@ -18,17 +19,7 @@ const availableAdmin = $derived([
|
|||||||
|
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
@@ -58,23 +49,8 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
@@ -85,11 +61,11 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
<form
|
<form
|
||||||
id="new-group-form"
|
id="new-group-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance={() => async ({ result, update }) => {
|
||||||
oninput={() => {
|
if (result.type === 'redirect') unsaved.clearOnSuccess();
|
||||||
isDirty = true;
|
await update();
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
}}
|
||||||
|
oninput={unsaved.markDirty}
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<!-- Name card -->
|
<!-- Name card -->
|
||||||
|
|||||||
125
frontend/src/routes/admin/groups/new/page.svelte.spec.ts
Normal file
125
frontend/src/routes/admin/groups/new/page.svelte.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: (_el: HTMLFormElement, fn?: unknown) => {
|
||||||
|
enhanceCaptureRef.submitFn = fn;
|
||||||
|
return { destroy: vi.fn() };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type SubmitFn = () => Promise<
|
||||||
|
(opts: {
|
||||||
|
result: { type: string; [key: string]: unknown };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Admin new group page – unsaved-changes guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
enhanceCaptureRef.submitFn = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show unsaved warning initially', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels navigation and shows banner when form is dirty', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cancel navigation when form is clean', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard button calls goto with the target URL', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /verwerfen/i }).click();
|
||||||
|
|
||||||
|
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/groups');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears banner when enhance callback receives a redirect result', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'redirect', location: '/admin/groups', status: 303 },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps banner when enhance callback receives a failure result', async () => {
|
||||||
|
render(Page, { props: { form: null } });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="name"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'failure', status: 400, data: { error: 'Name bereits vergeben' } },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import UserProfileSection from '$lib/user/UserProfileSection.svelte';
|
import UserProfileSection from '$lib/user/UserProfileSection.svelte';
|
||||||
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
||||||
import AccountSection from './AccountSection.svelte';
|
import AccountSection from './AccountSection.svelte';
|
||||||
|
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
@@ -44,23 +35,8 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
@@ -71,11 +47,11 @@ beforeNavigate(({ cancel, to }) => {
|
|||||||
<form
|
<form
|
||||||
id="new-user-form"
|
id="new-user-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance={() => async ({ result, update }) => {
|
||||||
oninput={() => {
|
if (result.type === 'redirect') unsaved.clearOnSuccess();
|
||||||
isDirty = true;
|
await update();
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
}}
|
||||||
|
oninput={unsaved.markDirty}
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: (_el: HTMLFormElement, fn?: unknown) => {
|
||||||
|
enhanceCaptureRef.submitFn = fn;
|
||||||
|
return { destroy: vi.fn() };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
const groups = [
|
const groups = [
|
||||||
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
|
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
|
||||||
@@ -20,6 +30,13 @@ const baseData = {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type SubmitFn = () => Promise<
|
||||||
|
(opts: {
|
||||||
|
result: { type: string; [key: string]: unknown };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
>;
|
||||||
|
|
||||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Admin new user page – rendering', () => {
|
describe('Admin new user page – rendering', () => {
|
||||||
@@ -66,3 +83,103 @@ describe('Admin new user page – error display', () => {
|
|||||||
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Admin new user page – unsaved-changes guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
enhanceCaptureRef.submitFn = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show unsaved warning initially', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels navigation and shows banner when form is dirty', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cancel navigation when form is clean', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard button calls goto with the target URL', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /verwerfen/i }).click();
|
||||||
|
|
||||||
|
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears banner when enhance callback receives a redirect result', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'redirect', location: '/admin/users', status: 303 },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps banner when enhance callback receives a failure result', async () => {
|
||||||
|
render(Page, { data: baseData, form: null });
|
||||||
|
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector<HTMLInputElement>('input[name="email"]')!
|
||||||
|
.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
|
||||||
|
await innerFn({
|
||||||
|
result: { type: 'failure', status: 400, data: { error: 'E-Mail bereits vergeben' } },
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancel = vi.fn();
|
||||||
|
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { createRawSnippet } from 'svelte';
|
import { createRawSnippet } from 'svelte';
|
||||||
|
|
||||||
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
||||||
@@ -96,13 +96,13 @@ describe('Layout – user dropdown', () => {
|
|||||||
|
|
||||||
it('opens dropdown on button click', async () => {
|
it('opens dropdown on button click', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('profile link points to /profile', async () => {
|
it('profile link points to /profile', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /Profil/i }))
|
.element(page.getByRole('link', { name: /Profil/i }))
|
||||||
.toHaveAttribute('href', '/profile');
|
.toHaveAttribute('href', '/profile');
|
||||||
@@ -110,16 +110,16 @@ describe('Layout – user dropdown', () => {
|
|||||||
|
|
||||||
it('logout button is in the dropdown', async () => {
|
it('logout button is in the dropdown', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes dropdown when Escape is pressed', async () => {
|
it('closes dropdown when Escape is pressed', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
const btn = page.getByRole('button', { name: /MM/ });
|
const btnEl = (await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement;
|
||||||
await btn.click();
|
btnEl.click();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||||
await userEvent.keyboard('{Escape}');
|
btnEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export default defineConfig({
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 15_000,
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user