mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-21 06:55:38 +09:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dfd346448 | ||
|
|
b8d43af191 | ||
|
|
1a772daa47 | ||
|
|
a6bde3aae4 | ||
|
|
c51800a8d6 | ||
|
|
f9e52da7b8 | ||
|
|
8a7c791e4f | ||
|
|
0eb4e1733e | ||
|
|
527b9fadba | ||
|
|
f2d146c9e4 | ||
|
|
bf6ac57c6d | ||
|
|
3740c418ec | ||
|
|
ab7feb42e6 | ||
|
|
13af47c00d | ||
|
|
92321aa330 | ||
|
|
455f2d576b | ||
|
|
589f33e74d | ||
|
|
1f47e70ebe | ||
|
|
739d215a98 | ||
|
|
51ef7393c0 | ||
|
|
7e4892301e | ||
|
|
49a1ffabbe | ||
|
|
2d8d130be1 | ||
|
|
d564e01518 | ||
|
|
557efe1c7d | ||
|
|
0b1ddc2695 | ||
|
|
c195e2b5de | ||
|
|
9e15194d6f | ||
|
|
275cc73f7a | ||
|
|
3224f2113a | ||
|
|
b9ea837c75 | ||
|
|
41da6b5985 | ||
|
|
9000a9f4c7 | ||
|
|
61a4e3895a | ||
|
|
86c78427d8 | ||
|
|
8beccdfe54 | ||
|
|
8f4c966d3a | ||
|
|
c424656bad | ||
|
|
4b8ce8bf63 | ||
|
|
4f88872a3c | ||
|
|
f5c30fdf8f | ||
|
|
da1842ecab | ||
|
|
8c38e06a15 | ||
|
|
c454add5c8 | ||
|
|
1f4f3318dc | ||
|
|
105bd49c9e | ||
|
|
9213a765b1 | ||
|
|
7c4180c2be | ||
|
|
fe4bb4386e | ||
|
|
674b4d26d1 | ||
|
|
3a74a6f97a | ||
|
|
a7ba56b140 | ||
|
|
a39410623e | ||
|
|
a4802c6004 | ||
|
|
812919759a | ||
|
|
b3f373c94c | ||
|
|
36095ab66f | ||
|
|
52c19e67cb | ||
|
|
4e3299a91c | ||
|
|
99be186fa1 | ||
|
|
00249f9b4d | ||
|
|
7c16d424ce | ||
|
|
5f5e26a8d4 | ||
|
|
8c05497c37 | ||
|
|
d2f19660e5 | ||
|
|
d5ca5d8293 | ||
|
|
e403fb6cce | ||
|
|
e8dff6a8a1 | ||
|
|
2b656a1186 | ||
|
|
381699623e | ||
|
|
d7bbe933fa | ||
|
|
069b13c382 | ||
|
|
538b5d69bb | ||
|
|
3c8fe74660 | ||
|
|
c0f825ae2a | ||
|
|
8d8a29377c | ||
|
|
abcb5cb814 | ||
|
|
97b2ac91ca |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- fluxer-desktop-macos-arm64
|
||||
- fluxer-electron-build
|
||||
|
||||
config-variables: null
|
||||
@@ -0,0 +1,108 @@
|
||||
name: build desktop Electron
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prebuilt_release_tag:
|
||||
description: Draft release containing prebuilt Electron distributions. Leave empty to compile Electron on the build runner instead.
|
||||
required: false
|
||||
default: desktop-electron-runtime-44.4.1-fluxer.1
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-electron-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build patched Electron (linux-${{ matrix.arch }})
|
||||
if: inputs.prebuilt_release_tag == ''
|
||||
runs-on: [self-hosted, Linux, X64, fluxer-electron-build]
|
||||
timeout-minutes: 1440
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Build patched Electron distribution
|
||||
env:
|
||||
CONTAINER_ENGINE: podman
|
||||
CONTAINER_CPUS: "8"
|
||||
CONTAINER_MEMORY: 48g
|
||||
BUILD_JOBS: "8"
|
||||
run: >-
|
||||
bash fluxer_desktop/electron/build-linux.sh
|
||||
--arch "${{ matrix.arch }}"
|
||||
--build-root /srv/fluxer-electron-build/work
|
||||
--output-dir "${{ runner.temp }}/fluxer-electron-artifacts"
|
||||
|
||||
- name: Upload patched Electron distribution
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-electron-linux-${{ matrix.arch }}
|
||||
path: |
|
||||
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.zip
|
||||
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.fluxer.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
import:
|
||||
name: Import patched Electron (linux-${{ matrix.arch }})
|
||||
if: inputs.prebuilt_release_tag != ''
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Download and verify build artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREBUILT_RELEASE_TAG: ${{ inputs.prebuilt_release_tag }}
|
||||
ELECTRON_ARCH: ${{ matrix.arch }}
|
||||
FLUXER_ELECTRON_DIST: ${{ runner.temp }}/fluxer-electron-artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release view "$PREBUILT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json isDraft --jq '.isDraft' | grep -qx true
|
||||
gh release download "$PREBUILT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "electron-v44.4.1-linux-$ELECTRON_ARCH.zip" \
|
||||
--pattern "electron-v44.4.1-linux-$ELECTRON_ARCH.fluxer.json" \
|
||||
--dir "$FLUXER_ELECTRON_DIST"
|
||||
node <<'NODE'
|
||||
const {verifyLinuxElectronArchive} = require('./fluxer_desktop/scripts/custom-electron.cjs');
|
||||
verifyLinuxElectronArchive(process.env.FLUXER_ELECTRON_DIST, process.env.ELECTRON_ARCH)
|
||||
.then(() => console.log('Patched Electron artifact verified.'))
|
||||
.catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
NODE
|
||||
|
||||
- name: Upload patched Electron distribution
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: desktop-electron-linux-${{ matrix.arch }}
|
||||
path: |
|
||||
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.zip
|
||||
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.fluxer.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
@@ -21,6 +21,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
electron_build_run_id:
|
||||
description: Successful manual main-branch build desktop Electron workflow run supplying the patched Linux Electron distributions. Required when building Linux.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
shell_contract:
|
||||
description: "Did the shell contract change since the oldest shell these modules still reach? unchanged refuses if it did. compatible keeps older shells on these modules because the renderer still works on them. raise stops sending these modules to shells without the new contract: Linux and managed shells keep their current renderer, Windows and macOS update their shell first."
|
||||
required: true
|
||||
@@ -51,6 +56,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
@@ -64,6 +70,30 @@ jobs:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate patched Electron build
|
||||
env:
|
||||
ELECTRON_BUILD_RUN_ID: ${{ inputs.electron_build_run_id }}
|
||||
SKIP_TARGETS: ${{ inputs.skip_targets }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skipped=",$(printf '%s' "$SKIP_TARGETS" | tr '[:space:]' ','),"
|
||||
if [[ "$skipped" == *,linux,* ]] || \
|
||||
{ [[ "$skipped" == *,linux-x64,* ]] && [[ "$skipped" == *,linux-arm64,* ]]; }; then
|
||||
exit 0
|
||||
fi
|
||||
if ! [[ "$ELECTRON_BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo 'Provide electron_build_run_id from a successful build desktop Electron workflow.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/actions/runs/$ELECTRON_BUILD_RUN_ID" \
|
||||
--jq 'select(.status == "completed" and .conclusion == "success" and .event == "workflow_dispatch" and .head_branch == "main" and .repository.full_name != null and .head_repository.full_name == .repository.full_name and .path == ".github/workflows/build-desktop-electron.yaml") | .id' \
|
||||
| grep -qx "$ELECTRON_BUILD_RUN_ID"; then
|
||||
echo 'Use a successful manual build desktop Electron run from this repository on main.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
@@ -613,12 +643,22 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step analyse_velopack_paths
|
||||
|
||||
- name: Download patched Electron distribution (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.electron_build_run_id }}
|
||||
name: desktop-electron-linux-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/fluxer-electron-dist
|
||||
|
||||
- name: Build Electron app (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
|
||||
env:
|
||||
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
|
||||
USE_SYSTEM_FPM: true
|
||||
FLUXER_ELECTRON_DIST: ${{ runner.temp }}/fluxer-electron-dist
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_app_linux
|
||||
|
||||
Generated
+20
-7
@@ -49,9 +49,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.2.3"
|
||||
version = "4.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
|
||||
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
|
||||
dependencies = [
|
||||
"cssparser 0.38.0",
|
||||
"html5ever 0.40.1",
|
||||
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2986,6 +2986,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nkeys"
|
||||
version = "0.4.5"
|
||||
@@ -3890,9 +3902,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -5763,14 +5775,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
version = "9.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
@@ -130,6 +130,8 @@ secrets:
|
||||
# FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ""
|
||||
# SMTP password for outgoing email.
|
||||
# FLUXER_EMAIL_SMTP_PASSWORD: ""
|
||||
# SMTP password of the email fallback provider.
|
||||
# FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ""
|
||||
# Secret that authenticates email provider webhooks.
|
||||
# FLUXER_EMAIL_WEBHOOK_SECRET: ""
|
||||
# Stripe secret key.
|
||||
|
||||
@@ -325,6 +325,18 @@ FLUXER_EMAIL_SMTP_TLS_MODE=
|
||||
# The older switch, read only while the mode above is empty. true is implicit
|
||||
# and false is opportunistic.
|
||||
#FLUXER_EMAIL_SMTP_SECURE=false
|
||||
# A second SMTP provider for mail to the listed recipient domains, comma
|
||||
# separated and matched exactly. Everything else goes through the provider
|
||||
# above. The port falls back to 587 and the mode works like the one above.
|
||||
#FLUXER_EMAIL_FALLBACK_DOMAINS=gmail.com,googlemail.com
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_HOST=
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_PORT=587
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_USERNAME=
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD=
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE=
|
||||
# Extra headers on every fallback message, as a JSON object of header name to
|
||||
# string value. Use single quotes so the shell keeps the JSON intact.
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_HEADERS='{"X-Example-Tracking":"off"}'
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
@@ -353,12 +365,10 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_AUTO_JOIN_INVITE_CODE=
|
||||
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
|
||||
|
||||
# Report retention. A daily job deletes each report this many days after it was
|
||||
# filed, with its evidence copies and search entry, unless a legal hold is set.
|
||||
# Report retention. A daily job deletes each resolved report this many days
|
||||
# after it was resolved, with its evidence copies and search entry, unless a
|
||||
# legal hold is set. Pending reports are kept until they are resolved.
|
||||
#FLUXER_REPORT_RETENTION_DAYS=365
|
||||
# Delete resolved reports this many days after they were resolved, when that
|
||||
# comes first. Unset leaves them to FLUXER_REPORT_RETENTION_DAYS.
|
||||
#FLUXER_RESOLVED_REPORT_RETENTION_DAYS=
|
||||
# true only logs what the job would delete. Deleted evidence cannot be
|
||||
# restored, so check the "Processed report retention" log line of the worker
|
||||
# in a dry run first if you are unsure.
|
||||
|
||||
@@ -126,6 +126,13 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
FLUXER_EMAIL_FALLBACK_DOMAINS: ${FLUXER_EMAIL_FALLBACK_DOMAINS:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_HOST: ${FLUXER_EMAIL_FALLBACK_SMTP_HOST:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_PORT: ${FLUXER_EMAIL_FALLBACK_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: ${FLUXER_EMAIL_FALLBACK_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ${FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: ${FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: ${FLUXER_EMAIL_FALLBACK_SMTP_HEADERS:-}
|
||||
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-}
|
||||
@@ -159,7 +166,6 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
|
||||
FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-}
|
||||
FLUXER_REPORT_RETENTION_DAYS: ${FLUXER_REPORT_RETENTION_DAYS:-}
|
||||
FLUXER_RESOLVED_REPORT_RETENTION_DAYS: ${FLUXER_RESOLVED_REPORT_RETENTION_DAYS:-}
|
||||
FLUXER_REPORT_RETENTION_DRY_RUN: ${FLUXER_REPORT_RETENTION_DRY_RUN:-}
|
||||
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-}
|
||||
|
||||
@@ -211,12 +217,11 @@ x-fluxer-app-healthcheck: &fluxer-app-healthcheck
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
|
||||
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
|
||||
start_interval: 1s
|
||||
|
||||
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
<<: *fluxer-app-healthcheck
|
||||
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8090 && printf \"GET /_health HTTP/1.0\\r\\n\\r\\n\" >&3 && head -n 1 <&3 | grep -q \" 200 \"'"]
|
||||
|
||||
services:
|
||||
edge:
|
||||
@@ -470,7 +475,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_API_PORT: "8080"
|
||||
FLUXER_SETUP_TOKEN: ${FLUXER_SETUP_TOKEN:-}
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}${FLUXER_API_NODE_OPTIONS:+ $FLUXER_API_NODE_OPTIONS}
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=${FLUXER_API_NODE_HEAP_MB:-}}${FLUXER_API_NODE_OPTIONS:+ ${FLUXER_API_NODE_OPTIONS:-}}
|
||||
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_API_POSTGRES_MAX_CONNECTIONS:-25}"
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "${FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED:-true}"
|
||||
@@ -504,13 +509,13 @@ services:
|
||||
command: ["node", "dist/WorkerEntrypoint.js"]
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}${FLUXER_WORKER_NODE_OPTIONS:+ $FLUXER_WORKER_NODE_OPTIONS}
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=${FLUXER_WORKER_NODE_HEAP_MB:-}}${FLUXER_WORKER_NODE_OPTIONS:+ ${FLUXER_WORKER_NODE_OPTIONS:-}}
|
||||
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS:-25}"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
|
||||
test: ["CMD-SHELL", "node -e \"const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}\""]
|
||||
<<: *fluxer-app-healthcheck
|
||||
retries: ${FLUXER_WORKER_HEALTHCHECK_RETRIES:-3}
|
||||
depends_on:
|
||||
@@ -868,7 +873,7 @@ services:
|
||||
FLUXER_API_ENDPOINT: http://api:8080
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /_health HTTP/1.0\\r\\n\\r\\n\" >&3 && head -n 1 <&3 | grep -q \" 200 \"'"]
|
||||
<<: *fluxer-app-healthcheck
|
||||
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
|
||||
depends_on:
|
||||
|
||||
Generated
+1784
-252
File diff suppressed because it is too large.
Load diff
@@ -45,6 +45,8 @@ pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
|
||||
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
|
||||
pub const DISCOVERY_REMOVE: &str = "discovery:remove";
|
||||
pub const DISCOVERY_REVIEW: &str = "discovery:review";
|
||||
pub const ENFORCEMENT_ANNOTATE: &str = "enforcement:annotate";
|
||||
pub const ENFORCEMENT_VIEW: &str = "enforcement:view";
|
||||
pub const GATEWAY_MEMORY_STATS: &str = "gateway:memory_stats";
|
||||
pub const GATEWAY_RELOAD_ALL: &str = "gateway:reload_all";
|
||||
pub const GIFT_CODES_GENERATE: &str = "gift_codes:generate";
|
||||
@@ -70,6 +72,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
|
||||
pub const MESSAGE_DELETE: &str = "message:delete";
|
||||
pub const MESSAGE_LOOKUP: &str = "message:lookup";
|
||||
pub const MESSAGE_SHRED: &str = "message:shred";
|
||||
pub const REGISTRATION_REVIEW: &str = "registration:review";
|
||||
pub const REPORT_DELETE: &str = "report:delete";
|
||||
pub const REPORT_RESOLVE: &str = "report:resolve";
|
||||
pub const REPORT_VIEW: &str = "report:view";
|
||||
@@ -152,6 +155,8 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BULK_UPDATE_USER_FLAGS,
|
||||
DISCOVERY_REMOVE,
|
||||
DISCOVERY_REVIEW,
|
||||
ENFORCEMENT_ANNOTATE,
|
||||
ENFORCEMENT_VIEW,
|
||||
GATEWAY_MEMORY_STATS,
|
||||
GATEWAY_RELOAD_ALL,
|
||||
GIFT_CODES_GENERATE,
|
||||
@@ -177,6 +182,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
MESSAGE_DELETE,
|
||||
MESSAGE_LOOKUP,
|
||||
MESSAGE_SHRED,
|
||||
REGISTRATION_REVIEW,
|
||||
REPORT_DELETE,
|
||||
REPORT_RESOLVE,
|
||||
REPORT_VIEW,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types::EnforcementKindType;
|
||||
|
||||
pub struct U64Flag {
|
||||
pub name: &'static str,
|
||||
pub value: u64,
|
||||
@@ -121,6 +123,21 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
},
|
||||
];
|
||||
|
||||
const RESTRICTION_USER_FLAGS: u64 = user_flag_bits::DISABLED
|
||||
| user_flag_bits::DELETED
|
||||
| user_flag_bits::SPAMMER
|
||||
| user_flag_bits::REPORT_BANNED;
|
||||
const PURCHASE_DISABLED: i32 = 1 << 6;
|
||||
|
||||
pub fn user_flag_is_editable(flag: u64, current_flags: u64, suspended: bool) -> bool {
|
||||
let self_disabled = flag == user_flag_bits::DISABLED && current_flags & flag != 0 && !suspended;
|
||||
flag & RESTRICTION_USER_FLAGS == 0 || self_disabled
|
||||
}
|
||||
|
||||
pub fn premium_flag_is_editable(flag: i32) -> bool {
|
||||
flag & PURCHASE_DISABLED == 0
|
||||
}
|
||||
|
||||
pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
I32Flag {
|
||||
name: "DISCRIMINATOR",
|
||||
@@ -148,7 +165,7 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
},
|
||||
I32Flag {
|
||||
name: "PURCHASE_DISABLED",
|
||||
value: 1 << 6,
|
||||
value: PURCHASE_DISABLED,
|
||||
},
|
||||
I32Flag {
|
||||
name: "ENABLED_OVERRIDE",
|
||||
@@ -166,7 +183,7 @@ pub const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("3", "Spam"),
|
||||
("4", "Cheating or exploitation"),
|
||||
("5", "Coordinated raiding or manipulation"),
|
||||
("6", "Automation or self-bot usage"),
|
||||
("6", "Disruptive automation"),
|
||||
("7", "Nonconsensual sexual content"),
|
||||
("8", "Scam or social engineering"),
|
||||
("9", "Child sexual content"),
|
||||
@@ -183,8 +200,157 @@ pub const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("20", "Hate speech or extremist content"),
|
||||
("21", "Malicious links or malware distribution"),
|
||||
("22", "Impersonation or fake identity"),
|
||||
("23", "Threats or violent speech"),
|
||||
("24", "Violent extremism"),
|
||||
("25", "Self-harm promotion"),
|
||||
("26", "Sexual content outside 18+ spaces"),
|
||||
("27", "Illegal goods or activity"),
|
||||
("28", "Harmful deception"),
|
||||
("29", "Reporting abuse"),
|
||||
("30", "Under the minimum age"),
|
||||
("31", "Copyright"),
|
||||
];
|
||||
|
||||
pub fn deletion_reason_label(code: i32) -> String {
|
||||
let value = code.to_string();
|
||||
DELETION_REASONS
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == value)
|
||||
.map_or_else(
|
||||
|| format!("Reason {code}"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EnforcementHandling {
|
||||
Standard,
|
||||
Protected,
|
||||
Restricted,
|
||||
}
|
||||
|
||||
pub struct EnforcementReason {
|
||||
pub code: i32,
|
||||
pub handling: EnforcementHandling,
|
||||
pub kinds: Option<&'static [EnforcementKindType]>,
|
||||
}
|
||||
|
||||
impl EnforcementReason {
|
||||
pub fn allows(&self, kind: EnforcementKindType) -> bool {
|
||||
self.kinds.is_none_or(|kinds| kinds.contains(&kind))
|
||||
&& (kind != EnforcementKindType::FeatureLimit
|
||||
|| ENFORCEMENT_LIMITS
|
||||
.iter()
|
||||
.any(|limit| limit.codes.contains(&self.code)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EnforcementLimitOption {
|
||||
pub value: &'static str,
|
||||
pub label: &'static str,
|
||||
pub codes: &'static [i32],
|
||||
}
|
||||
|
||||
pub const ENFORCEMENT_LIMITS: &[EnforcementLimitOption] = &[
|
||||
EnforcementLimitOption {
|
||||
value: "direct_messages",
|
||||
label: "Direct messages",
|
||||
codes: &[3, 6, 8, 18, 21],
|
||||
},
|
||||
EnforcementLimitOption {
|
||||
value: "reports",
|
||||
label: "Reports",
|
||||
codes: &[29],
|
||||
},
|
||||
EnforcementLimitOption {
|
||||
value: "purchases",
|
||||
label: "Purchases",
|
||||
codes: &[12, 14],
|
||||
},
|
||||
];
|
||||
|
||||
pub const COPYRIGHT_REASON_CODE: i32 = 31;
|
||||
|
||||
const RESTRICTED_KINDS: &[EnforcementKindType] = &[
|
||||
EnforcementKindType::Suspension,
|
||||
EnforcementKindType::ContentRemoval,
|
||||
];
|
||||
const PAYMENT_KINDS: &[EnforcementKindType] = &[
|
||||
EnforcementKindType::Warning,
|
||||
EnforcementKindType::Suspension,
|
||||
EnforcementKindType::Closure,
|
||||
EnforcementKindType::FeatureLimit,
|
||||
];
|
||||
|
||||
const fn reason(
|
||||
code: i32,
|
||||
handling: EnforcementHandling,
|
||||
kinds: Option<&'static [EnforcementKindType]>,
|
||||
) -> EnforcementReason {
|
||||
EnforcementReason {
|
||||
code,
|
||||
handling,
|
||||
kinds,
|
||||
}
|
||||
}
|
||||
|
||||
pub const ENFORCEMENT_REASONS: &[EnforcementReason] = {
|
||||
use EnforcementHandling::{Protected, Restricted, Standard};
|
||||
use EnforcementKindType::{
|
||||
Closure, CommunityRemoval, ContentRemoval, FeatureLimit, ProfileReset, Suspension, Warning,
|
||||
};
|
||||
&[
|
||||
reason(3, Standard, None),
|
||||
reason(4, Standard, None),
|
||||
reason(5, Standard, None),
|
||||
reason(6, Standard, None),
|
||||
reason(7, Protected, None),
|
||||
reason(8, Standard, None),
|
||||
reason(9, Restricted, Some(RESTRICTED_KINDS)),
|
||||
reason(10, Protected, None),
|
||||
reason(11, Protected, None),
|
||||
reason(12, Standard, Some(PAYMENT_KINDS)),
|
||||
reason(13, Restricted, Some(RESTRICTED_KINDS)),
|
||||
reason(14, Standard, Some(PAYMENT_KINDS)),
|
||||
reason(15, Protected, None),
|
||||
reason(16, Standard, None),
|
||||
reason(17, Standard, Some(&[Warning, Suspension, Closure])),
|
||||
reason(18, Standard, None),
|
||||
reason(20, Standard, None),
|
||||
reason(21, Standard, None),
|
||||
reason(22, Standard, None),
|
||||
reason(23, Protected, None),
|
||||
reason(24, Restricted, Some(RESTRICTED_KINDS)),
|
||||
reason(
|
||||
25,
|
||||
Standard,
|
||||
Some(&[
|
||||
Warning,
|
||||
Suspension,
|
||||
ContentRemoval,
|
||||
ProfileReset,
|
||||
CommunityRemoval,
|
||||
]),
|
||||
),
|
||||
reason(26, Standard, None),
|
||||
reason(27, Standard, None),
|
||||
reason(28, Standard, None),
|
||||
reason(29, Standard, Some(&[Warning, FeatureLimit, Suspension])),
|
||||
reason(30, Standard, Some(&[Closure])),
|
||||
reason(
|
||||
31,
|
||||
Standard,
|
||||
Some(&[Warning, ContentRemoval, ProfileReset, CommunityRemoval]),
|
||||
),
|
||||
]
|
||||
};
|
||||
|
||||
pub fn enforcement_reason(code: i32) -> Option<&'static EnforcementReason> {
|
||||
ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.find(|reason| reason.code == code)
|
||||
}
|
||||
|
||||
pub const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
@@ -226,7 +392,196 @@ pub const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::USER_FLAGS;
|
||||
use super::{
|
||||
DELETION_REASONS, ENFORCEMENT_LIMITS, ENFORCEMENT_REASONS, EnforcementHandling, USER_FLAGS,
|
||||
};
|
||||
use crate::api::generated::types::DeletionReasonCode;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const CORE_TS: &str = include_str!("../../packages/constants/src/Core.ts");
|
||||
const ENFORCEMENT_REASONS_TS: &str =
|
||||
include_str!("../../packages/constants/src/EnforcementReasons.ts");
|
||||
|
||||
fn quoted_values(list: &str) -> Vec<String> {
|
||||
list.split('\'')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn deletion_reason_codes() -> HashMap<String, i32> {
|
||||
let block = CORE_TS
|
||||
.split("export const DeletionReasons = {")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('}').next())
|
||||
.expect("DeletionReasons block");
|
||||
block
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (name, code) = line.trim().trim_end_matches(',').split_once(": ")?;
|
||||
Some((name.to_owned(), code.parse().ok()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kind_lists() -> HashMap<String, Vec<String>> {
|
||||
ENFORCEMENT_REASONS_TS
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let rest = line.strip_prefix("const ")?;
|
||||
let (name, value) = rest.split_once(": ReadonlyArray<EnforcementKind> = ")?;
|
||||
Some((name.to_owned(), quoted_values(value)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn typescript_reasons() -> Vec<(i32, String, Option<Vec<String>>)> {
|
||||
let codes = deletion_reason_codes();
|
||||
let lists = kind_lists();
|
||||
let block = ENFORCEMENT_REASONS_TS
|
||||
.split("export const ENFORCEMENT_REASONS")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n};").next())
|
||||
.expect("ENFORCEMENT_REASONS block");
|
||||
block
|
||||
.split("[DeletionReasons.")
|
||||
.skip(1)
|
||||
.map(|entry| {
|
||||
let name = entry.split(']').next().expect("reason name");
|
||||
let handling = entry
|
||||
.split("handling: '")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('\'').next())
|
||||
.expect("handling");
|
||||
let kinds = entry.split("kinds: ").nth(1).expect("kinds").trim_start();
|
||||
let kinds = if kinds.starts_with("'any'") {
|
||||
None
|
||||
} else if kinds.starts_with('[') {
|
||||
Some(quoted_values(kinds.split(']').next().expect("kind list")))
|
||||
} else {
|
||||
let list = kinds
|
||||
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
|
||||
.next()
|
||||
.expect("kind list name");
|
||||
Some(lists.get(list).expect("named kind list").clone())
|
||||
};
|
||||
(
|
||||
*codes.get(name).expect("reason code"),
|
||||
handling.to_owned(),
|
||||
kinds,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn typescript_reason_rules() -> HashMap<String, Vec<i32>> {
|
||||
let codes = deletion_reason_codes();
|
||||
let block = ENFORCEMENT_REASONS_TS
|
||||
.split("export const ENFORCEMENT_REASONS")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n};").next())
|
||||
.expect("ENFORCEMENT_REASONS block");
|
||||
let mut rules: HashMap<String, Vec<i32>> = HashMap::new();
|
||||
for entry in block.split("[DeletionReasons.").skip(1) {
|
||||
let name = entry.split(']').next().expect("reason name");
|
||||
let rule = entry
|
||||
.split("rule: '")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('\'').next())
|
||||
.expect("rule");
|
||||
rules
|
||||
.entry(rule.to_owned())
|
||||
.or_default()
|
||||
.push(*codes.get(name).expect("reason code"));
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
fn typescript_limit_rules(limit: &str) -> Vec<String> {
|
||||
let block = ENFORCEMENT_REASONS_TS
|
||||
.split("const ENFORCEMENT_LIMIT_RULES")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n};").next())
|
||||
.expect("ENFORCEMENT_LIMIT_RULES block");
|
||||
let value = block
|
||||
.split(&format!("{limit}: "))
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(",\n").next())
|
||||
.expect("limit rules");
|
||||
if value.starts_with("ENFORCEMENT_SPAMMER_LIMIT_RULES") {
|
||||
let spammer = ENFORCEMENT_REASONS_TS
|
||||
.split("const ENFORCEMENT_SPAMMER_LIMIT_RULES: ")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("]);").next())
|
||||
.expect("spammer rules");
|
||||
return quoted_values(spammer);
|
||||
}
|
||||
quoted_values(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforcement_limits_match_the_shared_constants() {
|
||||
let rules = typescript_reason_rules();
|
||||
assert_eq!(ENFORCEMENT_LIMITS.len(), 3);
|
||||
for limit in ENFORCEMENT_LIMITS {
|
||||
let mut expected: Vec<i32> = typescript_limit_rules(limit.value)
|
||||
.iter()
|
||||
.flat_map(|rule| rules.get(rule).cloned().unwrap_or_default())
|
||||
.collect();
|
||||
expected.sort_unstable();
|
||||
let mut actual = limit.codes.to_vec();
|
||||
actual.sort_unstable();
|
||||
assert_eq!(actual, expected, "codes of the {} limit", limit.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_limits_are_offered_only_for_reasons_with_a_limit() {
|
||||
use crate::api::generated::types::EnforcementKindType::FeatureLimit;
|
||||
let offered: Vec<i32> = ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.filter(|reason| reason.allows(FeatureLimit))
|
||||
.map(|reason| reason.code)
|
||||
.collect();
|
||||
assert_eq!(offered, vec![3, 6, 8, 12, 14, 18, 21, 29]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restriction_flags_are_read_only_except_a_self_disabled_account() {
|
||||
use super::{premium_flag_is_editable, user_flag_bits as bits, user_flag_is_editable};
|
||||
for flag in [bits::DELETED, bits::SPAMMER, bits::REPORT_BANNED] {
|
||||
assert!(!user_flag_is_editable(flag, 0, false));
|
||||
assert!(!user_flag_is_editable(flag, flag, false));
|
||||
}
|
||||
assert!(!user_flag_is_editable(bits::DISABLED, 0, false));
|
||||
assert!(!user_flag_is_editable(bits::DISABLED, bits::DISABLED, true));
|
||||
assert!(user_flag_is_editable(bits::DISABLED, bits::DISABLED, false));
|
||||
assert!(user_flag_is_editable(bits::ACCOUNT_LIMITED, 0, true));
|
||||
assert!(user_flag_is_editable(bits::STAFF, bits::STAFF, false));
|
||||
assert!(!premium_flag_is_editable(1 << 6));
|
||||
assert!(premium_flag_is_editable(1 << 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforcement_reasons_match_the_shared_constants() {
|
||||
let expected = typescript_reasons();
|
||||
assert_eq!(expected.len(), ENFORCEMENT_REASONS.len());
|
||||
for (code, handling, kinds) in expected {
|
||||
let reason = super::enforcement_reason(code)
|
||||
.unwrap_or_else(|| panic!("{code} is missing from ENFORCEMENT_REASONS"));
|
||||
let rust_handling = match reason.handling {
|
||||
EnforcementHandling::Standard => "standard",
|
||||
EnforcementHandling::Protected => "protected",
|
||||
EnforcementHandling::Restricted => "restricted",
|
||||
};
|
||||
assert_eq!(rust_handling, handling, "handling of {code}");
|
||||
let rust_kinds = reason
|
||||
.kinds
|
||||
.map(|kinds| kinds.iter().map(ToString::to_string).collect::<Vec<_>>());
|
||||
assert_eq!(rust_kinds, kinds, "kinds of {code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_flags_cover_every_flag_in_the_admin_spec() {
|
||||
@@ -251,4 +606,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_reasons_match_the_generated_reason_codes() {
|
||||
let spec: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
|
||||
let mut spec_codes: Vec<i32> = spec["components"]["schemas"]["DeletionReasonCode"]["enum"]
|
||||
.as_array()
|
||||
.expect("DeletionReasonCode values")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
i32::try_from(value.as_i64().expect("numeric reason code")).expect("i32 code")
|
||||
})
|
||||
.collect();
|
||||
spec_codes.sort_unstable();
|
||||
let mut label_codes: Vec<i32> = DELETION_REASONS
|
||||
.iter()
|
||||
.map(|(code, _)| code.parse().expect("numeric reason code"))
|
||||
.collect();
|
||||
label_codes.sort_unstable();
|
||||
assert_eq!(label_codes, spec_codes);
|
||||
for code in label_codes {
|
||||
assert!(
|
||||
DeletionReasonCode::try_from(code).is_ok(),
|
||||
"{code} is not a generated DeletionReasonCode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,11 @@ impl AdminApiClient {
|
||||
pub async fn bulk_delete_user_messages(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
basis: generated_types::BulkModerationBasis,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
basis,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.send(
|
||||
@@ -91,26 +93,18 @@ impl AdminApiClient {
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
reason_code: u32,
|
||||
days_until_deletion: u32,
|
||||
public_reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
basis: generated_types::BulkModerationBasis,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||
basis,
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.send(
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{AdminUser, UserMutationResponse};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RestrictionResponse {
|
||||
pub user: AdminUser,
|
||||
pub skipped_ips: Vec<generated_types::SkippedNetworkBlock>,
|
||||
}
|
||||
|
||||
pub struct DecisionSubmission {
|
||||
pub decision: generated_types::EnforcementDecisionInput,
|
||||
pub internal_note: String,
|
||||
}
|
||||
|
||||
pub struct ClosureRequest {
|
||||
pub basis: generated_types::ModerationBasis,
|
||||
pub days_until_deletion: u32,
|
||||
pub remove_evidence: bool,
|
||||
pub block_networks: Option<generated_types::NetworkBlockRequest>,
|
||||
pub replace_pending_deletion_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub internal_note: Option<String>,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn suspend_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
duration_hours: u32,
|
||||
remove_evidence: bool,
|
||||
block_networks: Option<generated_types::NetworkBlockRequest>,
|
||||
submission: &DecisionSubmission,
|
||||
) -> ApiResult<RestrictionResponse> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
block_networks,
|
||||
decision: submission.decision.clone(),
|
||||
duration_hours: i64::from(duration_hours),
|
||||
remove_evidence: remove_evidence.then_some(true),
|
||||
};
|
||||
self.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
Some(&submission.internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_network_block_candidates(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<generated_types::NetworkBlockCandidatesResponse> {
|
||||
self.get(
|
||||
&format!(
|
||||
"/admin/users/{}/network-block-candidates",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn warn_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
submission: &DecisionSubmission,
|
||||
) -> ApiResult<generated_types::AdminUserWarningResponse> {
|
||||
let body = generated_types::AdminUserWarningRequest {
|
||||
decision: submission.decision.clone(),
|
||||
};
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!("/admin/users/{}/warnings", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
Some(&submission.internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn close_account(
|
||||
&self,
|
||||
user_id: &str,
|
||||
request: &ClosureRequest,
|
||||
) -> ApiResult<RestrictionResponse> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
basis: request.basis.clone(),
|
||||
block_networks: request.block_networks.clone(),
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
request.days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
remove_evidence: request.remove_evidence.then_some(true),
|
||||
replace_pending_deletion_at: request.replace_pending_deletion_at,
|
||||
};
|
||||
self.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
request.internal_note.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn limit_features(
|
||||
&self,
|
||||
user_id: &str,
|
||||
limit: generated_types::AdminUserFeatureLimitRequestLimit,
|
||||
ends_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
submission: &DecisionSubmission,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserFeatureLimitRequest {
|
||||
decision: submission.decision.clone(),
|
||||
ends_at,
|
||||
limit,
|
||||
};
|
||||
let response: UserMutationResponse = self
|
||||
.send(
|
||||
Method::PUT,
|
||||
&format!(
|
||||
"/admin/users/{}/feature-limits",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
Some(&submission.internal_note),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.user)
|
||||
}
|
||||
|
||||
pub async fn list_review_queue(
|
||||
&self,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionListResponse> {
|
||||
self.get(
|
||||
"/admin/enforcement-decisions",
|
||||
Some(&[("review_queue", "true")]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_user_decisions(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionListResponse> {
|
||||
self.get(
|
||||
"/admin/enforcement-decisions",
|
||||
Some(&[("subject_type", "user"), ("subject_id", user_id)]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_decision(
|
||||
&self,
|
||||
decision_id: &str,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionResponse> {
|
||||
self.get(
|
||||
&format!(
|
||||
"/admin/enforcement-decisions/{}",
|
||||
urlencoding::encode(decision_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn end_decision(
|
||||
&self,
|
||||
decision_id: &str,
|
||||
outcome: generated_types::EnforcementDecisionEndRequestOutcome,
|
||||
statement: Option<String>,
|
||||
internal_note: &str,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionResponse> {
|
||||
let body = generated_types::EnforcementDecisionEndRequest { outcome, statement };
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/admin/enforcement-decisions/{}/end",
|
||||
urlencoding::encode(decision_id)
|
||||
),
|
||||
Some(&body),
|
||||
Some(internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn annotate_decision(
|
||||
&self,
|
||||
decision_id: &str,
|
||||
body: String,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionNoteResponse> {
|
||||
let body = generated_types::EnforcementDecisionNoteRequest { body };
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/admin/enforcement-decisions/{}/notes",
|
||||
urlencoding::encode(decision_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_error_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http { status, message } = error else {
|
||||
return None;
|
||||
};
|
||||
if !matches!(status, 400 | 403 | 404 | 409) {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = serde_json::from_str(message).ok()?;
|
||||
let detail = body["errors"]
|
||||
.as_array()
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(|error| error["message"].as_str());
|
||||
detail
|
||||
.or_else(|| body["message"].as_str())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::api_error_message;
|
||||
use crate::api::client::ApiError;
|
||||
|
||||
fn http(status: u16, body: &str) -> ApiError {
|
||||
ApiError::Http {
|
||||
status,
|
||||
message: body.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_message_prefers_the_first_field_error() {
|
||||
let body = r#"{"code":"INVALID_FORM_BODY","message":"Invalid form body","errors":[{"path":"decision.statement","code":"ENFORCEMENT_STATEMENT_NOT_ALLOWED","message":"A note to the user is not allowed for this reason"}]}"#;
|
||||
assert_eq!(
|
||||
api_error_message(&http(400, body)).as_deref(),
|
||||
Some("A note to the user is not allowed for this reason")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_message_reads_conflicts_and_ignores_server_errors() {
|
||||
let body = r#"{"code":"ENFORCEMENT_DECISION_REPLACED","message":"A later decision replaced this one"}"#;
|
||||
assert_eq!(
|
||||
api_error_message(&http(409, body)).as_deref(),
|
||||
Some("A later decision replaced this one")
|
||||
);
|
||||
assert_eq!(api_error_message(&http(500, body)), None);
|
||||
assert_eq!(api_error_message(&http(409, "not json")), None);
|
||||
assert_eq!(
|
||||
api_error_message(&ApiError::Network("down".to_owned())),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,14 +56,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
|
||||
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
|
||||
}
|
||||
|
||||
pub(crate) fn deletion_reason_code(
|
||||
value: i32,
|
||||
field: &str,
|
||||
) -> Result<types::DeletionReasonCode, String> {
|
||||
types::DeletionReasonCode::try_from(value)
|
||||
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
|
||||
}
|
||||
|
||||
pub(crate) fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -69,13 +69,20 @@ impl AdminApiClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
pub async fn delete_guild(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
basis: generated_types::CommunityRemovalBasis,
|
||||
internal_note: Option<&str>,
|
||||
) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::AdminGuildDeleteRequest { basis };
|
||||
self.send(
|
||||
Method::DELETE,
|
||||
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
|
||||
Some(&body),
|
||||
internal_note,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn transfer_guild_ownership(
|
||||
|
||||
@@ -4,9 +4,11 @@ use reqwest::Method;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
||||
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
AccountIdentitySettings, BrandingAssetKind, BrandingAssetUploadRequest,
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceAccountIdentityDiscovery,
|
||||
InstanceConfigResponse, InstanceConfigUpdateRequest, InstanceEmailSmtpTestResponse,
|
||||
InstancePremiumDiscovery, PendingRegistrationListResponse, PendingRegistrationResponse,
|
||||
PendingRegistrationStatus,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -36,6 +38,20 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_branding_asset(
|
||||
&self,
|
||||
kind: BrandingAssetKind,
|
||||
image: Option<String>,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/instance/config/branding-assets",
|
||||
Some(&BrandingAssetUploadRequest { kind, image }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn test_saved_instance_smtp_config(
|
||||
&self,
|
||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||
@@ -74,35 +90,30 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.decide_pending_registration(user_id, "approved").await
|
||||
pub async fn list_pending_registrations(&self) -> ApiResult<Vec<PendingRegistrationResponse>> {
|
||||
let response: PendingRegistrationListResponse = self
|
||||
.get("/admin/instance/pending-registrations", None)
|
||||
.await?;
|
||||
Ok(response.pending_registrations)
|
||||
}
|
||||
|
||||
pub async fn reject_pending_registration(
|
||||
pub async fn decide_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.decide_pending_registration(user_id, "rejected").await
|
||||
}
|
||||
|
||||
async fn decide_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
status: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let body = serde_json::json!({"status": status});
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/instance/pending-registrations/{}",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
status: PendingRegistrationStatus,
|
||||
) -> ApiResult<Vec<PendingRegistrationResponse>> {
|
||||
let body = serde_json::json!({"status": status.as_str()});
|
||||
let response: PendingRegistrationListResponse = self
|
||||
.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/instance/pending-registrations/{}",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.pending_registrations)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use crate::templates::components::enforcement_fields::BasisSubmission;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
|
||||
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
|
||||
BrowseChannelResponse, LookupMessageResponse, MessageShredStatusResponse,
|
||||
SearchChannelMessagesResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -15,8 +17,10 @@ impl AdminApiClient {
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
basis: generated_types::MessageDeletionBasis,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::AdminMessageDeleteRequest { basis };
|
||||
let _: serde_json::Value = self
|
||||
.send(
|
||||
Method::DELETE,
|
||||
@@ -25,7 +29,7 @@ impl AdminApiClient {
|
||||
urlencoding::encode(channel_id),
|
||||
urlencoding::encode(message_id)
|
||||
),
|
||||
None::<&serde_json::Value>,
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await?;
|
||||
@@ -54,35 +58,44 @@ impl AdminApiClient {
|
||||
pub async fn queue_message_shred(
|
||||
&self,
|
||||
user_id: &str,
|
||||
entries: &[serde_json::Value],
|
||||
) -> ApiResult<MessageShredResponse> {
|
||||
let entries = entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::AdminUserMessageShredRequest { entries };
|
||||
let response = self
|
||||
.generated()
|
||||
.shred_admin_user_messages(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
entries: Vec<generated_types::AdminUserMessageShredRequestEntriesItem>,
|
||||
basis: &BasisSubmission,
|
||||
) -> ApiResult<generated_types::MessageShredResponse> {
|
||||
let body = generated_types::AdminUserMessageShredRequest {
|
||||
basis: basis.moderation_basis(),
|
||||
entries,
|
||||
};
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/admin/users/{}/message-shreds",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
basis.internal_note(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_user_messages(
|
||||
&self,
|
||||
user_id: &str,
|
||||
dry_run: bool,
|
||||
) -> ApiResult<DeleteAllUserMessagesResponse> {
|
||||
let dry_run = if dry_run { "true" } else { "false" };
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
basis: Option<&BasisSubmission>,
|
||||
) -> ApiResult<generated_types::DeleteAllUserMessagesResponse> {
|
||||
let body = generated_types::AdminUserMessageDeleteRequest {
|
||||
basis: basis.map(BasisSubmission::moderation_basis),
|
||||
};
|
||||
let dry_run = if basis.is_some() { "false" } else { "true" };
|
||||
self.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/users/{}/messages?dry_run={dry_run}",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
basis.and_then(BasisSubmission::internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_message_shred_status(
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod bulk;
|
||||
pub mod client;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
pub mod enforcement;
|
||||
pub mod guild_assets;
|
||||
pub mod guild_threads;
|
||||
pub mod guilds;
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
|
||||
use crate::api::generated::{
|
||||
snowflake,
|
||||
types::{
|
||||
ReportNoteRequest, ReportNoteResponse, UpdateReportRequest, UpdateReportRequestOutcome,
|
||||
UpdateReportRequestResolution, UpdateReportRequestStatus,
|
||||
},
|
||||
};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -44,20 +50,24 @@ impl AdminApiClient {
|
||||
&self,
|
||||
report_id: &str,
|
||||
resolution: UpdateReportRequestResolution,
|
||||
outcome: Option<UpdateReportRequestOutcome>,
|
||||
duplicate_of: Option<&str>,
|
||||
public_comment: Option<&str>,
|
||||
notify_reporter: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
|
||||
let body = UpdateReportRequest {
|
||||
duplicate_of: duplicate_of.map(snowflake),
|
||||
notify_reporter,
|
||||
outcome,
|
||||
public_comment: public_comment.map(str::to_owned),
|
||||
resolution,
|
||||
status: UpdateReportRequestStatus::Resolved,
|
||||
};
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -84,6 +94,20 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn annotate_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
body: String,
|
||||
) -> ApiResult<ReportNoteResponse> {
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!("/admin/reports/{}/notes", urlencoding::encode(report_id)),
|
||||
Some(&ReportNoteRequest { body }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminUserMeResponse {
|
||||
pub user: AdminUser,
|
||||
pub pending_registration_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -117,6 +118,8 @@ pub struct AdminUser {
|
||||
pub deletion_scheduled_by: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deletion_scheduled_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enforcement_decision_id: Option<String>,
|
||||
pub last_active_at: Option<String>,
|
||||
pub last_active_ip: Option<String>,
|
||||
pub last_active_ip_reverse: Option<String>,
|
||||
@@ -252,6 +255,8 @@ pub struct BanCheckResult {
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub decision_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
|
||||
@@ -701,8 +701,6 @@ pub struct InstanceRegistrationResponse {
|
||||
pub admin_registration_urls_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub urls: Vec<RegistrationUrlResponse>,
|
||||
#[serde(default)]
|
||||
pub pending_registrations: Vec<PendingRegistrationResponse>,
|
||||
}
|
||||
|
||||
impl Default for InstanceRegistrationResponse {
|
||||
@@ -711,7 +709,6 @@ impl Default for InstanceRegistrationResponse {
|
||||
mode: RegistrationMode::Open,
|
||||
admin_registration_urls_enabled: true,
|
||||
urls: Vec::new(),
|
||||
pending_registrations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +764,92 @@ pub struct PendingRegistrationResponse {
|
||||
pub client_ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PendingRegistrationStatus {
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl PendingRegistrationStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Approved => "approved",
|
||||
Self::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PendingRegistrationListResponse {
|
||||
pub pending_registrations: Vec<PendingRegistrationResponse>,
|
||||
}
|
||||
|
||||
pub const BRANDING_ASSET_MAX_IMAGE_CHARS: usize = 16_000_000;
|
||||
|
||||
pub const BRANDING_ASSET_ACCEPT: &str = ".png,.jpeg,.webp,.gif,.apng,.avif,.heic,.heif,.jxl,.svg,\
|
||||
image/png,image/jpeg,image/webp,image/gif,image/apng,image/avif,image/heic,image/heif,\
|
||||
image/jxl,image/svg+xml";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrandingAssetKind {
|
||||
Icon,
|
||||
Symbol,
|
||||
Logo,
|
||||
Wordmark,
|
||||
Favicon,
|
||||
}
|
||||
|
||||
impl BrandingAssetKind {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Icon,
|
||||
Self::Symbol,
|
||||
Self::Logo,
|
||||
Self::Wordmark,
|
||||
Self::Favicon,
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Icon => "icon",
|
||||
Self::Symbol => "symbol",
|
||||
Self::Logo => "logo",
|
||||
Self::Wordmark => "wordmark",
|
||||
Self::Favicon => "favicon",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|kind| kind.as_str() == value)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Icon => "Icon",
|
||||
Self::Symbol => "Symbol",
|
||||
Self::Logo => "Logo",
|
||||
Self::Wordmark => "Wordmark",
|
||||
Self::Favicon => "Favicon",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(self, branding: &AppBrandingConfigResponse) -> Option<&str> {
|
||||
match self {
|
||||
Self::Icon => branding.icon_url.as_deref(),
|
||||
Self::Symbol => branding.symbol_url.as_deref(),
|
||||
Self::Logo => branding.logo_url.as_deref(),
|
||||
Self::Wordmark => branding.wordmark_url.as_deref(),
|
||||
Self::Favicon => branding.favicon_url.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BrandingAssetUploadRequest {
|
||||
pub kind: BrandingAssetKind,
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -8,32 +8,12 @@ pub struct LookupMessageResponse {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct MessageShredResponse {
|
||||
pub success: Option<bool>,
|
||||
pub job_id: Option<String>,
|
||||
pub requested: Option<u64>,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct MessageShredStatusResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeleteAllUserMessagesResponse {
|
||||
pub success: Option<bool>,
|
||||
pub dry_run: bool,
|
||||
pub channel_count: u64,
|
||||
pub message_count: u64,
|
||||
pub job_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BrowseChannelResponse {
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -171,6 +171,16 @@ pub struct ReportEntry {
|
||||
pub legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Vec<ReportNoteEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportNoteEntry {
|
||||
pub note_id: String,
|
||||
pub author_id: String,
|
||||
pub body: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
|
||||
@@ -6,6 +6,8 @@ use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use crate::templates::components::enforcement_fields::BasisSubmission;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
|
||||
@@ -282,6 +284,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
fields: &[String],
|
||||
basis: &BasisSubmission,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let fields = fields
|
||||
.iter()
|
||||
@@ -290,14 +293,22 @@ impl AdminApiClient {
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::AdminUserClearFieldsRequest { fields };
|
||||
let response = self
|
||||
.generated()
|
||||
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
let body = generated_types::AdminUserClearFieldsRequest {
|
||||
basis: basis.moderation_basis(),
|
||||
fields,
|
||||
};
|
||||
let response: UserMutationResponse = self
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/users/{}/profile-fields",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
basis.internal_note(),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
@@ -355,93 +366,28 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn temp_ban_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
duration_hours: u32,
|
||||
reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
duration_hours: i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
notify_user,
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
private_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn unban_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
public_reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserUnbanRequest {
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let response = self
|
||||
.generated_with_reason(private_reason)?
|
||||
.unban_admin_user(&snowflake(user_id), &body)
|
||||
.unban_admin_user(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn schedule_deletion(
|
||||
&self,
|
||||
user_id: &str,
|
||||
reason_code: i32,
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
replace_pending_deletion_at: None,
|
||||
};
|
||||
let response = self
|
||||
.generated_with_reason(audit_log_reason)?
|
||||
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|error| self.generated_error(error))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn cancel_deletion(
|
||||
&self,
|
||||
user_id: &str,
|
||||
expected_pending_deletion_at: &str,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = serde_json::json!({
|
||||
"expected_pending_deletion_at": expected_pending_deletion_at,
|
||||
"notify_user": notify_user,
|
||||
});
|
||||
let resp: UserMutationResponse = self
|
||||
.send(
|
||||
@@ -454,25 +400,6 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn annotate_ban(
|
||||
&self,
|
||||
user_id: &str,
|
||||
ban_audit_log_id: &str,
|
||||
note: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = serde_json::json!({
|
||||
"ban_audit_log_id": ban_audit_log_id,
|
||||
"note": note,
|
||||
});
|
||||
self.send_void(
|
||||
Method::POST,
|
||||
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDobUpdateRequest {
|
||||
date_of_birth: dob.to_owned(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{AdminUser, PremiumBranding},
|
||||
api::types::{AdminUser, AdminUserMeResponse, PremiumBranding},
|
||||
middleware::flash,
|
||||
session::{self, Session},
|
||||
state::AppState,
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
pub struct AuthContext {
|
||||
pub session: Session,
|
||||
pub admin_user: Option<AdminUser>,
|
||||
pub pending_registration_count: Option<u32>,
|
||||
pub branding: Option<PremiumBranding>,
|
||||
}
|
||||
|
||||
@@ -29,17 +30,19 @@ pub async fn require_auth(
|
||||
let Some(session) = session else {
|
||||
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
|
||||
};
|
||||
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
|
||||
AdminFetchResult::Unauthorized => {
|
||||
let login_url = format!("{}/login", config.base_path);
|
||||
return clear_session_and_redirect(&login_url, config);
|
||||
}
|
||||
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
|
||||
AdminFetchResult::None => None,
|
||||
};
|
||||
let (admin_user, pending_registration_count) =
|
||||
match fetch_admin_user(state.http_client(), config, &session).await {
|
||||
AdminFetchResult::Unauthorized => {
|
||||
let login_url = format!("{}/login", config.base_path);
|
||||
return clear_session_and_redirect(&login_url, config);
|
||||
}
|
||||
AdminFetchResult::Ok(me) => (Some(me.user), me.pending_registration_count),
|
||||
AdminFetchResult::None => (None, None),
|
||||
};
|
||||
request.extensions_mut().insert(AuthContext {
|
||||
session,
|
||||
admin_user,
|
||||
pending_registration_count,
|
||||
branding: state.self_hosted_branding().await,
|
||||
});
|
||||
|
||||
@@ -83,7 +86,7 @@ fn extract_session(request: &Request, secret_key: &str) -> Option<Session> {
|
||||
}
|
||||
|
||||
enum AdminFetchResult {
|
||||
Ok(Box<AdminUser>),
|
||||
Ok(Box<AdminUserMeResponse>),
|
||||
Unauthorized,
|
||||
None,
|
||||
}
|
||||
@@ -109,11 +112,8 @@ async fn fetch_admin_user(
|
||||
if !response.status().is_success() {
|
||||
return AdminFetchResult::None;
|
||||
}
|
||||
match response
|
||||
.json::<crate::api::types::AdminUserMeResponse>()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => AdminFetchResult::Ok(Box::new(resp.user)),
|
||||
match response.json::<AdminUserMeResponse>().await {
|
||||
Ok(me) => AdminFetchResult::Ok(Box::new(me)),
|
||||
Err(_) => AdminFetchResult::None,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::client::{AdminApiClient, ApiResult};
|
||||
use crate::api::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use crate::api::types::{FlashLevel, FlashMessage};
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::templates;
|
||||
@@ -201,6 +201,16 @@ async fn execute_check(
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned && r.decision_id.is_some() => (
|
||||
"info",
|
||||
format!(
|
||||
"{value} is blocked by decision {}{}. End that decision to remove the block.",
|
||||
r.decision_id.unwrap_or_default(),
|
||||
r.expires_at
|
||||
.map(|expires_at| format!(" until {}", format_admin_timestamp(&expires_at)))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
),
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => (
|
||||
"info",
|
||||
@@ -226,6 +236,12 @@ fn ban_action_result(
|
||||
) -> (&'static str, String) {
|
||||
match result {
|
||||
Ok(()) => ("success", success_message),
|
||||
Err(ApiError::Http { status: 409, .. }) => (
|
||||
"error",
|
||||
format!(
|
||||
"{error_message}. It belongs to an enforcement decision, so end that decision instead."
|
||||
),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: ban action");
|
||||
("error", error_message)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
enforcement::api_error_message,
|
||||
generated::types::EnforcementDecisionEndRequestOutcome,
|
||||
},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf::CsrfToken,
|
||||
flash::{self, FlashData},
|
||||
htmx,
|
||||
},
|
||||
state::AppState,
|
||||
templates::pages::enforcement_decision,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecisionsQuery {
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecisionActionQuery {
|
||||
action: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/decisions", get(decisions)).route(
|
||||
"/decisions/{decision_id}",
|
||||
get(decision_detail).post(decision_post),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_decision_id(value: &str) -> bool {
|
||||
(1..=20).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
async fn decisions(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Query(query): Query<DecisionsQuery>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let lookup = query
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
if let Some(id) = lookup.filter(|id| is_decision_id(id)) {
|
||||
return Redirect::to(&format!("{}/decisions/{id}", config.base_path)).into_response();
|
||||
}
|
||||
let error = lookup.map(|_| "A decision ID is a number.");
|
||||
let admin_acls = auth
|
||||
.0
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or_default();
|
||||
let review_queue = if acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW) {
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client.list_review_queue().await;
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!(%error, "admin API request failed: list the review queue");
|
||||
}
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let markup =
|
||||
enforcement_decision::decisions_page(config, &auth.0, error, review_queue.as_ref());
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn decision_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
Path(decision_id): Path<String>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let decision = if is_decision_id(&decision_id) {
|
||||
client
|
||||
.get_decision(&decision_id)
|
||||
.await
|
||||
.log_error("load enforcement decision")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let users = match &decision {
|
||||
Some(decision) => client
|
||||
.lookup_users_by_ids(&enforcement_decision::decision_user_ids(decision))
|
||||
.await
|
||||
.log_error("load users named by an enforcement decision")
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let markup = enforcement_decision::enforcement_decision_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&decision_id,
|
||||
decision.as_ref(),
|
||||
&users,
|
||||
&csrf.0.0,
|
||||
htmx::targets(&headers, "main-content"),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn decision_post(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(decision_id): Path<String>,
|
||||
Query(query): Query<DecisionActionQuery>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let redirect = format!("{}/decisions/{decision_id}", config.base_path);
|
||||
let flash = match MultiValueForm::from_request(request).await {
|
||||
Some(form) if is_decision_id(&decision_id) => {
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
dispatch(&client, &decision_id, query.action.as_deref(), &form).await
|
||||
}
|
||||
Some(_) => FlashData::error("A decision ID is a number."),
|
||||
None => FlashData::error("Invalid form data"),
|
||||
};
|
||||
if htmx::is_htmx_request(&headers) && htmx::targets(&headers, "flash-container") {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn failure(context: &str, error: &crate::api::client::ApiError) -> FlashData {
|
||||
tracing::warn!(%error, "admin API request failed: {context}");
|
||||
match api_error_message(error) {
|
||||
Some(message) => FlashData::error(format!("{context}: {message}")),
|
||||
None => FlashData::error(context.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(
|
||||
client: &AdminApiClient,
|
||||
decision_id: &str,
|
||||
action: Option<&str>,
|
||||
form: &MultiValueForm,
|
||||
) -> FlashData {
|
||||
match action {
|
||||
Some("end") => {
|
||||
let Some(outcome) = form
|
||||
.first("outcome")
|
||||
.and_then(|value| EnforcementDecisionEndRequestOutcome::try_from(value).ok())
|
||||
else {
|
||||
return FlashData::error("Choose an outcome");
|
||||
};
|
||||
let Some(internal_note) = form.clean("internal_note") else {
|
||||
return FlashData::error("Add an internal note");
|
||||
};
|
||||
match client
|
||||
.end_decision(
|
||||
decision_id,
|
||||
outcome,
|
||||
form.clean("statement"),
|
||||
&internal_note,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => FlashData::success("Decision ended"),
|
||||
Err(error) => failure("Failed to end the decision", &error),
|
||||
}
|
||||
}
|
||||
Some("add_note") => {
|
||||
let Some(body) = form.clean("body") else {
|
||||
return FlashData::error("Write a note first");
|
||||
};
|
||||
match client.annotate_decision(decision_id, body).await {
|
||||
Ok(_) => FlashData::success("Note added"),
|
||||
Err(error) => failure("Failed to add the note", &error),
|
||||
}
|
||||
}
|
||||
_ => FlashData::error("Unknown decision action"),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
enforcement::api_error_message,
|
||||
generated::types::CommunityRemovalBasis,
|
||||
},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf,
|
||||
@@ -10,7 +14,10 @@ use crate::{
|
||||
},
|
||||
routes::guild_tabs,
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{
|
||||
self,
|
||||
components::enforcement_fields::{BasisSubmission, basis_from_form},
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
@@ -308,11 +315,7 @@ async fn dispatch_guild_action(
|
||||
"Guild shutdown requested",
|
||||
"Failed to shut down guild",
|
||||
),
|
||||
"delete_guild" => action_result(
|
||||
client.delete_guild(guild_id).await,
|
||||
"Guild deleted",
|
||||
"Failed to delete guild",
|
||||
),
|
||||
"delete_guild" => remove_community(client, guild_id, form).await,
|
||||
"update_settings" => {
|
||||
let mut settings = serde_json::Map::new();
|
||||
for key in &[
|
||||
@@ -455,6 +458,37 @@ async fn dispatch_guild_action(
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_community(
|
||||
client: &AdminApiClient,
|
||||
guild_id: &str,
|
||||
form: &MultiValueForm,
|
||||
) -> FlashData {
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return FlashData::error(message),
|
||||
};
|
||||
let community_basis = match &basis {
|
||||
BasisSubmission::Enforcement(submission) => {
|
||||
CommunityRemovalBasis::Enforcement(submission.decision.clone())
|
||||
}
|
||||
BasisSubmission::HolderRequest(_) => CommunityRemovalBasis::HolderRequest,
|
||||
BasisSubmission::Housekeeping(_) => return FlashData::error("Choose a basis"),
|
||||
};
|
||||
match client
|
||||
.delete_guild(guild_id, community_basis, basis.internal_note())
|
||||
.await
|
||||
{
|
||||
Ok(_) => FlashData::success("Community removed"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, guild_id, "admin API request failed: remove community");
|
||||
FlashData::error(api_error_message(&error).map_or_else(
|
||||
|| "Failed to remove the community".to_owned(),
|
||||
|message| format!("Failed to remove the community: {message}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
success_message: &'static str,
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::client::AdminApiClient,
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
enforcement::api_error_message,
|
||||
generated::types::{BulkModerationBasis, MessageDeletionBasis},
|
||||
},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates::{
|
||||
components::enforcement_fields::{bulk_decision_from_form, can_take_action},
|
||||
pages::messages_page::MessageActions,
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, Request, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
@@ -93,17 +100,31 @@ pub(crate) async fn messages_post(
|
||||
.into_response()
|
||||
}
|
||||
"delete" => {
|
||||
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
|
||||
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
|
||||
let back = format!("{base}/messages?channel_id={}", encode_opt(&channel_id));
|
||||
let basis = form
|
||||
.first("basis")
|
||||
.and_then(|value| MessageDeletionBasis::try_from(value).ok());
|
||||
let flash = match (&channel_id, &message_id, basis) {
|
||||
(Some(cid), Some(mid), Some(basis)) => {
|
||||
let internal_note = form.clean("internal_note");
|
||||
match client
|
||||
.delete_message(cid, mid, basis, internal_note.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => FlashData::success("Message deleted"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: delete message");
|
||||
FlashData::error(api_error_message(&error).map_or_else(
|
||||
|| "Failed to delete the message".to_owned(),
|
||||
|message| format!("Failed to delete the message: {message}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_), None) => FlashData::error("Choose a basis"),
|
||||
_ => FlashData::error("Enter the channel ID and message ID"),
|
||||
};
|
||||
let audit_log_reason = form.clean("audit_log_reason");
|
||||
match client
|
||||
.delete_message(cid, mid, audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
|
||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||
}
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
_ => Redirect::to(&format!("{base}/messages")).into_response(),
|
||||
}
|
||||
@@ -150,6 +171,15 @@ pub(crate) async fn system_dms_post(
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_basis(form: &MultiValueForm) -> Result<BulkModerationBasis, String> {
|
||||
match form.first("basis") {
|
||||
Some("enforcement") => bulk_decision_from_form(form).map(BulkModerationBasis::Enforcement),
|
||||
Some("holder_request") => Ok(BulkModerationBasis::HolderRequest),
|
||||
Some("housekeeping") => Ok(BulkModerationBasis::Housekeeping),
|
||||
_ => Err("Choose a basis".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn bulk_actions_post(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
@@ -197,35 +227,48 @@ pub(crate) async fn bulk_actions_post(
|
||||
.bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
||||
"bulk-schedule-user-deletion" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let (Ok(reason_code), Ok(days)) = (
|
||||
form.parse_value::<u32>("reason_code"),
|
||||
form.parse_value::<u32>("days_until_deletion"),
|
||||
) else {
|
||||
let basis = match bulk_basis(&form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error(message),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Ok(Some(days)) = form.parse_value::<u32>("days_until_deletion") else {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Invalid deletion reason code or delay"),
|
||||
FlashData::error("Enter the days until deletion"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let public_reason = form.clean("public_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
client
|
||||
.bulk_schedule_user_deletion(
|
||||
&user_ids,
|
||||
reason_code.unwrap_or(2),
|
||||
days.unwrap_or(14),
|
||||
public_reason.as_deref(),
|
||||
notify_user,
|
||||
audit_log_reason.as_deref(),
|
||||
days,
|
||||
basis,
|
||||
form.clean("internal_note").as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"bulk-delete-user-messages" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let basis = match bulk_basis(&form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error(message),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
client
|
||||
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
|
||||
.bulk_delete_user_messages(&user_ids, basis, form.clean("internal_note").as_deref())
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
@@ -295,7 +338,13 @@ pub(crate) async fn messages_browse_fragment(
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let markup = crate::templates::pages::messages_page::browse_messages_fragment(
|
||||
config, &resp.data, can_delete, None,
|
||||
config,
|
||||
&resp.data,
|
||||
MessageActions {
|
||||
delete: can_delete,
|
||||
take_action: can_take_action(admin_acls),
|
||||
},
|
||||
None,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -325,14 +374,6 @@ fn encode_opt(value: &Option<String>) -> String {
|
||||
urlencoding::encode(value.as_deref().unwrap_or("")).into_owned()
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({"success": false, "error": message})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn archives_download(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, components::enforcement_fields::can_take_action},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -137,6 +137,7 @@ async fn messages_page(
|
||||
prefill_channel_id: channel_id,
|
||||
can_lookup,
|
||||
can_delete,
|
||||
can_take_action: can_take_action(admin_acls),
|
||||
lookup_result: lookup_result.as_ref(),
|
||||
browse_result: browse_result.as_ref(),
|
||||
search_result: search_result.as_ref(),
|
||||
@@ -201,13 +202,6 @@ async fn bulk_actions_page(
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let account_identity = state.account_identity(&client).await;
|
||||
let markup = templates::pages::bulk_actions::bulk_actions_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&csrf.0.0,
|
||||
account_identity.is_username(),
|
||||
);
|
||||
let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -8,11 +8,13 @@ mod bans_actions;
|
||||
mod billing_actions;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
pub mod enforcement;
|
||||
mod guild_tabs;
|
||||
pub mod guilds;
|
||||
pub mod jobs;
|
||||
mod message_actions;
|
||||
pub mod messages;
|
||||
pub mod registrations;
|
||||
pub mod reports;
|
||||
pub mod system;
|
||||
mod system_actions;
|
||||
@@ -63,6 +65,8 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
.merge(applications::router())
|
||||
.merge(codes::router())
|
||||
.merge(discovery::router())
|
||||
.merge(enforcement::router())
|
||||
.merge(registrations::router())
|
||||
.merge(jobs::router())
|
||||
.merge(messages::router())
|
||||
.merge(system::router())
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::{client::AdminApiClient, types::PendingRegistrationStatus},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf::CsrfToken,
|
||||
flash::{self, FlashData},
|
||||
htmx,
|
||||
},
|
||||
state::AppState,
|
||||
templates::{self, layout_sidebar::pending_registration_count_markup},
|
||||
utils::forms::clean_string,
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PendingRegistrationForm {
|
||||
#[serde(default)]
|
||||
user_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/pending-registrations", get(pending_registrations_page))
|
||||
.route(
|
||||
"/pending-registrations/approve",
|
||||
post(approve_pending_registration),
|
||||
)
|
||||
.route(
|
||||
"/pending-registrations/reject",
|
||||
post(reject_pending_registration),
|
||||
)
|
||||
}
|
||||
|
||||
async fn pending_registrations_page(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let pending_registrations = match client.list_pending_registrations().await {
|
||||
Ok(pending_registrations) => Some(pending_registrations),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: list pending registrations");
|
||||
None
|
||||
}
|
||||
};
|
||||
let markup = templates::pages::pending_registrations::pending_registrations_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&csrf.0.0,
|
||||
pending_registrations.as_deref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn approve_pending_registration(
|
||||
state: State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
form: Form<PendingRegistrationForm>,
|
||||
) -> Response {
|
||||
decide_pending_registration(
|
||||
state,
|
||||
headers,
|
||||
auth,
|
||||
csrf,
|
||||
form,
|
||||
PendingRegistrationStatus::Approved,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reject_pending_registration(
|
||||
state: State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
form: Form<PendingRegistrationForm>,
|
||||
) -> Response {
|
||||
decide_pending_registration(
|
||||
state,
|
||||
headers,
|
||||
auth,
|
||||
csrf,
|
||||
form,
|
||||
PendingRegistrationStatus::Rejected,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn decide_pending_registration(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
Form(form): Form<PendingRegistrationForm>,
|
||||
status: PendingRegistrationStatus,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let (success_message, error_message) = match status {
|
||||
PendingRegistrationStatus::Approved => {
|
||||
("Registration approved", "Failed to approve registration")
|
||||
}
|
||||
PendingRegistrationStatus::Rejected => {
|
||||
("Registration rejected", "Failed to reject registration")
|
||||
}
|
||||
};
|
||||
let flash = match clean_string(form.user_id.as_deref().unwrap_or("")) {
|
||||
None => FlashData::error("User ID is required"),
|
||||
Some(user_id) => {
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
match client.decide_pending_registration(&user_id, status).await {
|
||||
Ok(pending_registrations) => {
|
||||
let flash = FlashData::success(success_message);
|
||||
if htmx::targets(&headers, "pending-registration-list") {
|
||||
let markup = maud::html! {
|
||||
(templates::pages::pending_registrations::pending_registration_list(
|
||||
config,
|
||||
&csrf.0.0,
|
||||
&pending_registrations,
|
||||
))
|
||||
(pending_registration_count_markup(
|
||||
u32::try_from(pending_registrations.len()).ok(),
|
||||
true,
|
||||
))
|
||||
};
|
||||
let mut response = Html(markup.into_string()).into_response();
|
||||
htmx::add_toast_header(&mut response, &flash);
|
||||
return response;
|
||||
}
|
||||
flash
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ?status, "admin API request failed: decide pending registration");
|
||||
FlashData::error(error_message)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if htmx::is_htmx_request(&headers) {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
flash::redirect_with_flash(
|
||||
&format!("{}/pending-registrations", config.base_path),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError, ApiResultExt},
|
||||
generated::types::{UpdateReportRequestOutcome, UpdateReportRequestResolution},
|
||||
reports::SearchReportsParams,
|
||||
types::ReportEntry,
|
||||
},
|
||||
@@ -15,7 +16,7 @@ use crate::{
|
||||
htmx,
|
||||
},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, components::enforcement_fields::is_snowflake},
|
||||
utils::{forms::clean_string, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use axum::{
|
||||
@@ -61,6 +62,14 @@ struct LegalHoldForm {
|
||||
clear: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NoteForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteForm {
|
||||
#[serde(default)]
|
||||
@@ -78,6 +87,10 @@ struct ResolveForm {
|
||||
#[serde(default)]
|
||||
resolution: Option<String>,
|
||||
#[serde(default)]
|
||||
outcome: Option<String>,
|
||||
#[serde(default)]
|
||||
duplicate_of: Option<String>,
|
||||
#[serde(default)]
|
||||
public_comment: Option<String>,
|
||||
#[serde(default)]
|
||||
notify_reporter: Option<String>,
|
||||
@@ -94,6 +107,10 @@ pub fn router() -> Router<AppState> {
|
||||
"/reports/{report_id}/resolve",
|
||||
axum::routing::post(report_resolve),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/notes",
|
||||
axum::routing::post(report_note),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/legal-hold",
|
||||
axum::routing::post(report_legal_hold),
|
||||
@@ -210,12 +227,21 @@ async fn report_detail(
|
||||
.log_error("load report detail");
|
||||
match report {
|
||||
Some(report) => {
|
||||
let live = load_live_profile(&client, &auth.0, &report).await;
|
||||
let note_author_ids = note_author_ids(&report);
|
||||
let (live, note_authors) =
|
||||
tokio::join!(load_live_profile(&client, &auth.0, &report), async {
|
||||
client
|
||||
.lookup_users_by_ids(¬e_author_ids)
|
||||
.await
|
||||
.log_error("load report note authors")
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let markup = templates::pages::report_detail::report_detail_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&report,
|
||||
&live,
|
||||
¬e_authors,
|
||||
&csrf_token,
|
||||
is_detail_fragment,
|
||||
);
|
||||
@@ -228,6 +254,17 @@ async fn report_detail(
|
||||
}
|
||||
}
|
||||
|
||||
fn note_author_ids(report: &ReportEntry) -> Vec<String> {
|
||||
let mut ids: Vec<String> = report
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| note.author_id.clone())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
async fn load_live_profile(
|
||||
client: &AdminApiClient,
|
||||
auth: &AuthContext,
|
||||
@@ -323,6 +360,35 @@ async fn report_resolve(
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let outcome = match resolution {
|
||||
UpdateReportRequestResolution::NoViolation => Some(UpdateReportRequestOutcome::NoViolation),
|
||||
UpdateReportRequestResolution::Actioned => form
|
||||
.outcome
|
||||
.as_deref()
|
||||
.and_then(|value| UpdateReportRequestOutcome::try_from(value).ok())
|
||||
.filter(|outcome| *outcome != UpdateReportRequestOutcome::NoViolation),
|
||||
UpdateReportRequestResolution::Duplicate => None,
|
||||
};
|
||||
let duplicate_of = form
|
||||
.duplicate_of
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| is_snowflake(value))
|
||||
.map(str::to_owned);
|
||||
let missing = match resolution {
|
||||
UpdateReportRequestResolution::Actioned if outcome.is_none() => Some(OUTCOME_REQUIRED),
|
||||
UpdateReportRequestResolution::Duplicate if duplicate_of.is_none() => {
|
||||
Some(DUPLICATE_OF_REQUIRED)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(message) = missing {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(message),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let public_comment = clean_string(form.public_comment.as_deref().unwrap_or(""));
|
||||
let notify_reporter =
|
||||
@@ -331,9 +397,10 @@ async fn report_resolve(
|
||||
.resolve_report(
|
||||
&report_id,
|
||||
resolution,
|
||||
outcome,
|
||||
duplicate_of.as_deref(),
|
||||
public_comment.as_deref(),
|
||||
notify_reporter,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -346,7 +413,46 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn report_note(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let back = format!("{}/reports/{report_id}", config.base_path);
|
||||
let form: NoteForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report note form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(body) = clean_string(form.body.as_deref().unwrap_or("")) else {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Write a note first"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let flash = match client.annotate_report(&report_id, body).await {
|
||||
Ok(_) => FlashData::success("Note added"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: add report note");
|
||||
FlashData::error("Failed to add the note")
|
||||
}
|
||||
};
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
|
||||
const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account";
|
||||
const DUPLICATE_OF_REQUIRED: &str = "Enter the ID of the report this one repeats";
|
||||
|
||||
async fn report_legal_hold(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -6,25 +6,27 @@ use crate::{
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestResponse,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitKeyMetadata,
|
||||
LimitRule, LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest,
|
||||
RegistrationMode, SsoConfigUpdateRequest, VOICE_P2P_MAX_PARTICIPANTS_RANGE,
|
||||
VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf::CsrfToken,
|
||||
flash::{self, FlashData},
|
||||
htmx,
|
||||
@@ -41,6 +43,8 @@ use axum::{
|
||||
use maud::Markup;
|
||||
use serde::Deserialize;
|
||||
|
||||
const BRANDING_ASSET_FORM_BYTES: usize = BRANDING_ASSET_MAX_IMAGE_CHARS * 3 + 64 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActionQuery {
|
||||
pub action: Option<String>,
|
||||
@@ -147,6 +151,15 @@ pub async fn instance_config_post(
|
||||
Query(aq): Query<ActionQuery>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
match aq.action.as_deref() {
|
||||
Some("upload_branding_asset") => {
|
||||
return branding_asset_post(&state, &headers, &auth.0, true, request).await;
|
||||
}
|
||||
Some("remove_branding_asset") => {
|
||||
return branding_asset_post(&state, &headers, &auth.0, false, request).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let form = match MultiValueForm::from_request(request).await {
|
||||
@@ -304,48 +317,6 @@ pub async fn instance_config_post(
|
||||
},
|
||||
None => FlashData::error("Registration URL ID is required"),
|
||||
},
|
||||
"approve_pending_registration" => match form.clean("user_id") {
|
||||
Some(user_id) => match client.approve_pending_registration(&user_id).await {
|
||||
Ok(instance_config) => {
|
||||
let flash = FlashData::success("Registration approved");
|
||||
if htmx::targets(&headers, "pending-registration-list") {
|
||||
return render_pending_registration_list_response(
|
||||
config,
|
||||
&csrf.0.0,
|
||||
&instance_config,
|
||||
&flash,
|
||||
);
|
||||
}
|
||||
flash
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: approve pending registration");
|
||||
FlashData::error("Failed to approve registration")
|
||||
}
|
||||
},
|
||||
None => FlashData::error("User ID is required"),
|
||||
},
|
||||
"reject_pending_registration" => match form.clean("user_id") {
|
||||
Some(user_id) => match client.reject_pending_registration(&user_id).await {
|
||||
Ok(instance_config) => {
|
||||
let flash = FlashData::success("Registration rejected");
|
||||
if htmx::targets(&headers, "pending-registration-list") {
|
||||
return render_pending_registration_list_response(
|
||||
config,
|
||||
&csrf.0.0,
|
||||
&instance_config,
|
||||
&flash,
|
||||
);
|
||||
}
|
||||
flash
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: reject pending registration");
|
||||
FlashData::error("Failed to reject registration")
|
||||
}
|
||||
},
|
||||
None => FlashData::error("User ID is required"),
|
||||
},
|
||||
_ => FlashData::error("Unknown instance config action"),
|
||||
};
|
||||
if htmx::is_htmx_request(&headers) {
|
||||
@@ -354,6 +325,88 @@ pub async fn instance_config_post(
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn branding_asset_post(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
auth: &AuthContext,
|
||||
upload: bool,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let form = MultiValueForm::from_request_with_limit(request, BRANDING_ASSET_FORM_BYTES).await;
|
||||
let flash = match build_branding_asset_change(form.as_ref(), upload) {
|
||||
Err(message) => FlashData::error(message),
|
||||
Ok((kind, image)) => {
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
|
||||
let result = client.upload_branding_asset(kind, image).await;
|
||||
remember_premium_branding(state, &result);
|
||||
match result {
|
||||
Ok(instance_config) => {
|
||||
let flash = branding_asset_success(kind, upload);
|
||||
if htmx::targets(
|
||||
headers,
|
||||
&templates::pages::instance_config::branding_asset_field_id(kind),
|
||||
) {
|
||||
return render_fragment_with_toast(
|
||||
templates::pages::instance_config::branding_asset_field(
|
||||
kind,
|
||||
kind.url(&instance_config.app_public.branding),
|
||||
),
|
||||
&flash,
|
||||
);
|
||||
}
|
||||
flash
|
||||
}
|
||||
Err(error) => branding_asset_failure(kind, upload, &error),
|
||||
}
|
||||
}
|
||||
};
|
||||
if htmx::is_htmx_request(headers) {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
redirect_back_with_flash(
|
||||
&config.base_path,
|
||||
"/instance-config",
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_branding_asset_change(
|
||||
form: Option<&MultiValueForm>,
|
||||
upload: bool,
|
||||
) -> Result<(BrandingAssetKind, Option<String>), &'static str> {
|
||||
let form = form.ok_or("Invalid form data")?;
|
||||
let kind = form
|
||||
.first("kind")
|
||||
.and_then(BrandingAssetKind::parse)
|
||||
.ok_or("Unknown branding asset")?;
|
||||
if !upload {
|
||||
return Ok((kind, None));
|
||||
}
|
||||
let image = form.clean("image").ok_or("Choose an image to upload")?;
|
||||
Ok((kind, Some(image)))
|
||||
}
|
||||
|
||||
fn branding_asset_success(kind: BrandingAssetKind, upload: bool) -> FlashData {
|
||||
let verb = if upload { "uploaded" } else { "removed" };
|
||||
FlashData::success(format!("{} {verb}", kind.label()))
|
||||
}
|
||||
|
||||
fn branding_asset_failure(
|
||||
kind: BrandingAssetKind,
|
||||
upload: bool,
|
||||
error: &crate::api::client::ApiError,
|
||||
) -> FlashData {
|
||||
let verb = if upload { "upload" } else { "remove" };
|
||||
let label = kind.as_str();
|
||||
tracing::warn!(%error, kind = label, "admin API request failed: {verb} branding asset");
|
||||
match super::billing_actions::validation_message(error) {
|
||||
Some(message) => FlashData::error(format!("Failed to {verb} the {label}: {message}")),
|
||||
None => FlashData::error(format!("Failed to {verb} the {label}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_premium_branding(
|
||||
state: &AppState,
|
||||
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
|
||||
@@ -381,22 +434,6 @@ fn render_registration_url_list_response(
|
||||
)
|
||||
}
|
||||
|
||||
fn render_pending_registration_list_response(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
instance_config: &crate::api::types::InstanceConfigResponse,
|
||||
flash: &FlashData,
|
||||
) -> Response {
|
||||
render_fragment_with_toast(
|
||||
templates::pages::instance_config::pending_registration_list(
|
||||
config,
|
||||
csrf_token,
|
||||
&instance_config.registration.pending_registrations,
|
||||
),
|
||||
flash,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
||||
let mut response = Html(markup.into_string()).into_response();
|
||||
htmx::add_toast_header(&mut response, flash);
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
admin_flags,
|
||||
api::client::{AdminApiClient, ApiError},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError},
|
||||
enforcement::{ClosureRequest, api_error_message},
|
||||
generated::{
|
||||
snowflake,
|
||||
types::{AdminUserFeatureLimitRequestLimit, AdminUserMessageShredRequestEntriesItem},
|
||||
},
|
||||
},
|
||||
middleware::flash::FlashData,
|
||||
templates::components::enforcement_fields::{
|
||||
BasisSubmission, basis_from_form, decision_from_form, network_blocks_from_form,
|
||||
skipped_networks_summary,
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
@@ -74,16 +85,21 @@ pub async fn dispatch(
|
||||
return DispatchOutcome::error("User not found");
|
||||
}
|
||||
};
|
||||
let suspended = user.temp_banned_until.is_some();
|
||||
let editable =
|
||||
|flag: &u64| admin_flags::user_flag_is_editable(*flag, user.flags, suspended);
|
||||
let add = submitted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|flag| user.flags & *flag == 0)
|
||||
.filter(|flag| user.flags & *flag == 0 && editable(flag))
|
||||
.map(|flag| flag.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let remove = admin_flags::USER_FLAGS
|
||||
.iter()
|
||||
.map(|flag| flag.value)
|
||||
.filter(|flag| user.flags & *flag != 0 && !selected.contains(flag))
|
||||
.filter(|flag| {
|
||||
user.flags & *flag != 0 && !selected.contains(flag) && editable(flag)
|
||||
})
|
||||
.map(|flag| flag.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
DispatchOutcome::from_result(
|
||||
@@ -122,12 +138,18 @@ pub async fn dispatch(
|
||||
let add = submitted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|flag| current & *flag != *flag)
|
||||
.filter(|flag| {
|
||||
current & *flag != *flag && admin_flags::premium_flag_is_editable(*flag)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let remove = admin_flags::PREMIUM_FLAGS
|
||||
.iter()
|
||||
.map(|flag| flag.value)
|
||||
.filter(|flag| current & *flag == *flag && !selected.contains(flag))
|
||||
.filter(|flag| {
|
||||
current & *flag == *flag
|
||||
&& !selected.contains(flag)
|
||||
&& admin_flags::premium_flag_is_editable(*flag)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
DispatchOutcome::from_result(
|
||||
client.update_premium_flags(user_id, &add, &remove).await,
|
||||
@@ -172,12 +194,20 @@ pub async fn dispatch(
|
||||
"Failed to terminate user sessions",
|
||||
),
|
||||
"clear_fields" => {
|
||||
let f = form.list_values_any(&["fields[]", "fields"]);
|
||||
DispatchOutcome::from_result(
|
||||
client.clear_user_fields(user_id, &f).await,
|
||||
"User fields cleared successfully",
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
let fields = form.list_values_any(&["fields[]", "fields"]);
|
||||
if fields.is_empty() {
|
||||
return DispatchOutcome::error("Choose at least one field");
|
||||
}
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client.clear_user_fields(user_id, &fields, &basis).await {
|
||||
Ok(_) => DispatchOutcome::success("Profile fields cleared"),
|
||||
Err(error) => {
|
||||
decision_failure("Failed to clear the profile fields", user_id, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
@@ -218,43 +248,61 @@ pub async fn dispatch(
|
||||
"Failed to change email",
|
||||
)
|
||||
}
|
||||
"temp_ban" => {
|
||||
let Ok(duration) = form.parse_value_any::<u32>(&["duration_hours", "duration"]) else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
"suspend" => {
|
||||
let Ok(Some(duration_hours)) = form.parse_value::<u32>("duration_hours") else {
|
||||
return DispatchOutcome::error("Choose a duration");
|
||||
};
|
||||
let reason = get("reason");
|
||||
let private = get("private_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.temp_ban_user(
|
||||
user_id,
|
||||
duration.unwrap_or(24),
|
||||
reason.as_deref(),
|
||||
notify_user,
|
||||
private.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User temporarily banned successfully",
|
||||
"Failed to temporarily ban user",
|
||||
)
|
||||
let submission = match decision_from_form(form) {
|
||||
Ok(submission) => submission,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let block_networks = match network_blocks_from_form(form) {
|
||||
Ok(block_networks) => block_networks,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let remove_evidence = form.bool_value("remove_evidence");
|
||||
match client
|
||||
.suspend_user(
|
||||
user_id,
|
||||
duration_hours,
|
||||
remove_evidence,
|
||||
block_networks,
|
||||
&submission,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let done = if duration_hours == 0 {
|
||||
"Account banned."
|
||||
} else {
|
||||
"Account suspended."
|
||||
};
|
||||
DispatchOutcome::success(with_skipped(done, &response.skipped_ips))
|
||||
}
|
||||
Err(error) => decision_failure("Failed to suspend the account", user_id, &error),
|
||||
}
|
||||
}
|
||||
"warn" => {
|
||||
let submission = match decision_from_form(form) {
|
||||
Ok(submission) => submission,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client.warn_user(user_id, &submission).await {
|
||||
Ok(response) => DispatchOutcome::success(format!(
|
||||
"Warning sent as decision {}",
|
||||
response.decision_id.as_str()
|
||||
)),
|
||||
Err(error) => decision_failure("Failed to send the warning", user_id, &error),
|
||||
}
|
||||
}
|
||||
"unban" => {
|
||||
let public_reason = get("public_reason");
|
||||
let private_reason = get("private_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.unban_user(
|
||||
user_id,
|
||||
public_reason.as_deref(),
|
||||
notify_user,
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User unbanned successfully",
|
||||
"Failed to unban user",
|
||||
)
|
||||
let Some(internal_note) = get("internal_note") else {
|
||||
return DispatchOutcome::error("Add an internal note");
|
||||
};
|
||||
match client.unban_user(user_id, Some(&internal_note)).await {
|
||||
Ok(_) => DispatchOutcome::success("Suspension lifted"),
|
||||
Err(error) => decision_failure("Failed to lift the suspension", user_id, &error),
|
||||
}
|
||||
}
|
||||
"ban_ip" => {
|
||||
let Some(ip) = get("ip") else {
|
||||
@@ -279,30 +327,33 @@ pub async fn dispatch(
|
||||
"Failed to ban avatar hash",
|
||||
)
|
||||
}
|
||||
"schedule_deletion" => {
|
||||
let Ok(reason_code) = form.parse_value::<i32>("reason_code") else {
|
||||
return DispatchOutcome::error("Invalid deletion reason code");
|
||||
"close" => close_account(client, user_id, form).await,
|
||||
"limit_features" => {
|
||||
let Some(limit) = form
|
||||
.first("limit")
|
||||
.and_then(|value| AdminUserFeatureLimitRequestLimit::try_from(value).ok())
|
||||
else {
|
||||
return DispatchOutcome::error("Choose a limit");
|
||||
};
|
||||
let public_reason = get("public_reason");
|
||||
let private_reason = get("private_reason");
|
||||
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
|
||||
return DispatchOutcome::error("Invalid deletion delay");
|
||||
let ends_at = match (limit, form.first("ends_on")) {
|
||||
(AdminUserFeatureLimitRequestLimit::Reports, Some(value)) => match parse_day(value)
|
||||
{
|
||||
Some(ends_at) => Some(ends_at),
|
||||
None => return DispatchOutcome::error("Enter the end date as a date"),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.schedule_deletion(
|
||||
user_id,
|
||||
reason_code.unwrap_or(0),
|
||||
public_reason.as_deref(),
|
||||
days.unwrap_or(60),
|
||||
notify_user,
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User deletion scheduled successfully",
|
||||
"Failed to schedule user deletion",
|
||||
)
|
||||
let submission = match decision_from_form(form) {
|
||||
Ok(submission) => submission,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client
|
||||
.limit_features(user_id, limit, ends_at, &submission)
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("Feature limited"),
|
||||
Err(error) => decision_failure("Failed to limit the feature", user_id, &error),
|
||||
}
|
||||
}
|
||||
"cancel_deletion" => {
|
||||
let Some(expected) = get("expected_pending_deletion_at") else {
|
||||
@@ -315,40 +366,15 @@ pub async fn dispatch(
|
||||
"Confirm whose deletion you are canceling before submitting",
|
||||
);
|
||||
}
|
||||
let Some(private_reason) = get("private_reason") else {
|
||||
return DispatchOutcome::error("A private reason is required to cancel a deletion");
|
||||
let Some(internal_note) = get("internal_note") else {
|
||||
return DispatchOutcome::error("Add an internal note");
|
||||
};
|
||||
let notify_user = form.bool_value("notify_user");
|
||||
match client
|
||||
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
|
||||
.cancel_deletion(user_id, &expected, Some(&internal_note))
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("User deletion canceled successfully"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The pending deletion changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion");
|
||||
DispatchOutcome::error("Failed to cancel user deletion")
|
||||
}
|
||||
}
|
||||
}
|
||||
"annotate_ban" => {
|
||||
let Some(ban_audit_log_id) = get("ban_audit_log_id") else {
|
||||
return DispatchOutcome::error("The ban audit log entry is missing from the form");
|
||||
};
|
||||
let Some(note) = get("note") else {
|
||||
return DispatchOutcome::error("Note is required");
|
||||
};
|
||||
match client.annotate_ban(user_id, &ban_audit_log_id, ¬e).await {
|
||||
Ok(()) => DispatchOutcome::success("Note added to the ban"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The ban changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: annotate ban");
|
||||
DispatchOutcome::error("Failed to add the note to the ban")
|
||||
}
|
||||
Ok(_) => DispatchOutcome::success("Deletion cancelled"),
|
||||
Err(error) => decision_failure("Failed to cancel the deletion", user_id, &error),
|
||||
}
|
||||
}
|
||||
"change_dob" => {
|
||||
@@ -431,79 +457,172 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
}
|
||||
"delete_all_messages" => {
|
||||
let dry_run = parse_dry_run(form.first("dry_run"));
|
||||
match client.delete_all_user_messages(user_id, dry_run).await {
|
||||
Ok(response) if dry_run => DispatchOutcome {
|
||||
flash: FlashData::success(format!(
|
||||
"Dry run found {} across {}. Confirm to delete them permanently.",
|
||||
count_noun(response.message_count, "message", "messages"),
|
||||
count_noun(response.channel_count, "channel", "channels")
|
||||
)),
|
||||
redirect_params: vec![
|
||||
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
|
||||
(
|
||||
"delete_all_messages_channel_count".to_owned(),
|
||||
response.channel_count.to_string(),
|
||||
),
|
||||
(
|
||||
"delete_all_messages_message_count".to_owned(),
|
||||
response.message_count.to_string(),
|
||||
),
|
||||
],
|
||||
},
|
||||
Ok(response) => {
|
||||
if let Some(job_id) = response.job_id {
|
||||
DispatchOutcome {
|
||||
flash: FlashData::success(
|
||||
"Delete job queued. Monitor progress in the status panel.",
|
||||
),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), job_id)],
|
||||
}
|
||||
} else {
|
||||
DispatchOutcome::success("No messages found for deletion.")
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: delete all user messages");
|
||||
DispatchOutcome::error("Failed to delete all user messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
"delete_all_messages" => delete_all_messages(client, user_id, form).await,
|
||||
"cancel_bulk_message_deletion" => DispatchOutcome::from_result(
|
||||
client.cancel_bulk_message_deletion(user_id).await,
|
||||
"Bulk message deletion canceled successfully",
|
||||
"Failed to cancel bulk message deletion",
|
||||
),
|
||||
"message_shred" => {
|
||||
let csv = form.first("csv_data").unwrap_or_default();
|
||||
match parse_message_shred_csv(csv) {
|
||||
Ok(entries) if entries.is_empty() => DispatchOutcome::error(
|
||||
"CSV did not contain any valid channel_id,message_id pairs.",
|
||||
),
|
||||
Ok(entries) => match client.queue_message_shred(user_id, &entries).await {
|
||||
Ok(response) => {
|
||||
if let Some(job_id) = response.job_id {
|
||||
DispatchOutcome {
|
||||
flash: FlashData::success("Message shred job queued"),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), job_id)],
|
||||
}
|
||||
} else {
|
||||
DispatchOutcome::success("Message shred job queued")
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: queue message shred job");
|
||||
DispatchOutcome::error("Failed to queue message shred job")
|
||||
}
|
||||
let entries = match parse_message_shred_csv(form.first("csv_data").unwrap_or_default())
|
||||
{
|
||||
Ok(entries) if entries.is_empty() => {
|
||||
return DispatchOutcome::error("The CSV has no channel_id,message_id rows.");
|
||||
}
|
||||
Ok(entries) => entries,
|
||||
Err(error) => return DispatchOutcome::error(error),
|
||||
};
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client.queue_message_shred(user_id, entries, &basis).await {
|
||||
Ok(response) => DispatchOutcome {
|
||||
flash: FlashData::success(removal_message(
|
||||
"Message shred queued",
|
||||
response.decision_id.as_ref().map(|id| id.as_str()),
|
||||
)),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), response.job_id)],
|
||||
},
|
||||
Err(error) => DispatchOutcome::error(error),
|
||||
Err(error) => {
|
||||
decision_failure("Failed to queue the message shred", user_id, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => DispatchOutcome::error(format!("Unknown user action: {action}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_skipped(
|
||||
done: &str,
|
||||
skipped: &[crate::api::generated::types::SkippedNetworkBlock],
|
||||
) -> String {
|
||||
match skipped_networks_summary(skipped) {
|
||||
Some(summary) => format!("{done} {summary}"),
|
||||
None => done.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_account(
|
||||
client: &AdminApiClient,
|
||||
user_id: &str,
|
||||
form: &MultiValueForm,
|
||||
) -> DispatchOutcome {
|
||||
let Ok(Some(days_until_deletion)) = form.parse_value::<u32>("days_until_deletion") else {
|
||||
return DispatchOutcome::error("Enter the days until deletion");
|
||||
};
|
||||
let replace_pending_deletion_at = match form.clean("replace_pending_deletion_at") {
|
||||
Some(value) => match chrono::DateTime::parse_from_rfc3339(&value) {
|
||||
Ok(at) => Some(at.with_timezone(&chrono::Utc)),
|
||||
Err(_) => {
|
||||
return DispatchOutcome::error(
|
||||
"The pending deletion is missing from the form. Reload and review.",
|
||||
);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let enforcement = matches!(basis, BasisSubmission::Enforcement(_));
|
||||
let block_networks = match network_blocks_from_form(form) {
|
||||
Ok(block_networks) => block_networks.filter(|_| enforcement),
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let request = ClosureRequest {
|
||||
basis: basis.moderation_basis(),
|
||||
days_until_deletion,
|
||||
remove_evidence: enforcement && form.bool_value("remove_evidence"),
|
||||
block_networks,
|
||||
replace_pending_deletion_at,
|
||||
internal_note: basis.internal_note().map(str::to_owned),
|
||||
};
|
||||
match client.close_account(user_id, &request).await {
|
||||
Ok(response) => {
|
||||
DispatchOutcome::success(with_skipped("Account closed.", &response.skipped_ips))
|
||||
}
|
||||
Err(error) => decision_failure("Failed to close the account", user_id, &error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_all_messages(
|
||||
client: &AdminApiClient,
|
||||
user_id: &str,
|
||||
form: &MultiValueForm,
|
||||
) -> DispatchOutcome {
|
||||
let basis = if parse_dry_run(form.first("dry_run")) {
|
||||
None
|
||||
} else {
|
||||
match basis_from_form(form) {
|
||||
Ok(basis) => Some(basis),
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
}
|
||||
};
|
||||
let response = match client
|
||||
.delete_all_user_messages(user_id, basis.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => return decision_failure("Failed to delete the messages", user_id, &error),
|
||||
};
|
||||
let message_count = u64::try_from(*response.message_count).unwrap_or_default();
|
||||
let channel_count = u64::try_from(*response.channel_count).unwrap_or_default();
|
||||
if basis.is_none() {
|
||||
return DispatchOutcome {
|
||||
flash: FlashData::success(format!(
|
||||
"Found {} in {}. Choose a basis to delete them.",
|
||||
count_noun(message_count, "message", "messages"),
|
||||
count_noun(channel_count, "channel", "channels")
|
||||
)),
|
||||
redirect_params: vec![
|
||||
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
|
||||
(
|
||||
"delete_all_messages_channel_count".to_owned(),
|
||||
channel_count.to_string(),
|
||||
),
|
||||
(
|
||||
"delete_all_messages_message_count".to_owned(),
|
||||
message_count.to_string(),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
match response.job_id {
|
||||
Some(job_id) => DispatchOutcome {
|
||||
flash: FlashData::success(removal_message(
|
||||
"Delete job queued",
|
||||
response.decision_id.as_ref().map(|id| id.as_str()),
|
||||
)),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), job_id)],
|
||||
},
|
||||
None => DispatchOutcome::success("No messages to delete"),
|
||||
}
|
||||
}
|
||||
|
||||
fn removal_message(queued: &str, decision_id: Option<&str>) -> String {
|
||||
match decision_id {
|
||||
Some(decision_id) => format!("{queued} as decision {decision_id}"),
|
||||
None => queued.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_day(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")
|
||||
.ok()?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|at| at.and_utc())
|
||||
}
|
||||
|
||||
fn decision_failure(context: &str, user_id: &str, error: &ApiError) -> DispatchOutcome {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: {context}");
|
||||
match api_error_message(error) {
|
||||
Some(message) => DispatchOutcome::error(format!("{context}: {message}")),
|
||||
None => DispatchOutcome::error(context.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_legacy_flag_delta_fields(form: &MultiValueForm) -> bool {
|
||||
["add_flags[]", "add_flags", "remove_flags[]", "remove_flags"]
|
||||
.iter()
|
||||
@@ -521,7 +640,9 @@ fn parse_dry_run(value: Option<&str>) -> bool {
|
||||
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
|
||||
}
|
||||
|
||||
fn parse_message_shred_csv(csv: &str) -> Result<Vec<serde_json::Value>, String> {
|
||||
fn parse_message_shred_csv(
|
||||
csv: &str,
|
||||
) -> Result<Vec<AdminUserMessageShredRequestEntriesItem>, String> {
|
||||
let mut entries = Vec::new();
|
||||
for line in csv.trim().lines() {
|
||||
let trimmed = line.trim();
|
||||
@@ -534,12 +655,10 @@ fn parse_message_shred_csv(csv: &str) -> Result<Vec<serde_json::Value>, String>
|
||||
"Each row must contain channel_id and message_id separated by a comma".to_owned(),
|
||||
);
|
||||
}
|
||||
let channel_id = parse_csv_snowflake(parts[0], "channel_id", trimmed)?;
|
||||
let message_id = parse_csv_snowflake(parts[1], "message_id", trimmed)?;
|
||||
entries.push(serde_json::json!({
|
||||
"channel_id": channel_id,
|
||||
"message_id": message_id,
|
||||
}));
|
||||
entries.push(AdminUserMessageShredRequestEntriesItem {
|
||||
channel_id: snowflake(&parse_csv_snowflake(parts[0], "channel_id", trimmed)?),
|
||||
message_id: snowflake(&parse_csv_snowflake(parts[1], "message_id", trimmed)?),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -572,8 +691,10 @@ mod tests {
|
||||
let entries = parse_message_shred_csv("channel_id,message_id\n123,456\n789,101112")
|
||||
.expect("valid csv");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["channel_id"], "123");
|
||||
assert_eq!(entries[0]["message_id"], "456");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&entries[0]).expect("serializable entry"),
|
||||
serde_json::json!({"channel_id": "123", "message_id": "456"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct TabQuery {
|
||||
pub delete_all_messages_dry_run: Option<String>,
|
||||
pub delete_all_messages_channel_count: Option<u64>,
|
||||
pub delete_all_messages_message_count: Option<u64>,
|
||||
pub report_id: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -114,7 +116,8 @@ pub async fn render(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let ban_logs = if u.temp_banned_until.is_some()
|
||||
let legacy_logs = if u.temp_banned_until.is_some()
|
||||
&& u.enforcement_decision_id.is_none()
|
||||
&& acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW)
|
||||
{
|
||||
client
|
||||
@@ -130,16 +133,41 @@ pub async fn render(
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.log_error("load ban audit logs")
|
||||
.log_error("load suspension audit logs")
|
||||
.map(|response| response.logs)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let decisions = if acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW) {
|
||||
Some(client.list_user_decisions(user_id).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(Err(error)) = &decisions {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: list user decisions");
|
||||
}
|
||||
let can_block_networks = acl::has_permission(admin_acls, acl::USER_VIEW_IP)
|
||||
&& acl::has_permission(admin_acls, acl::BAN_IP_ADD)
|
||||
&& (acl::has_permission(admin_acls, acl::USER_TEMP_BAN)
|
||||
|| acl::has_permission(admin_acls, acl::USER_DELETE));
|
||||
let network_candidates = if can_block_networks {
|
||||
Some(client.list_network_block_candidates(user_id).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(Err(error)) = &network_candidates {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: list network block candidates");
|
||||
}
|
||||
let context = tabs::moderation::ModerationContext {
|
||||
deletion_scheduler: deletion_scheduler.as_ref(),
|
||||
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
|
||||
username_sign_in: account_identity.is_username(),
|
||||
legacy_suspension: tabs::moderation::find_legacy_suspension(&u, &legacy_logs),
|
||||
decisions: decisions.as_ref(),
|
||||
network_candidates: network_candidates.as_ref(),
|
||||
prefill: tabs::moderation::DecisionPrefill {
|
||||
report_ids: query.report_id.iter().cloned().collect(),
|
||||
messages: query.message.iter().cloned().collect(),
|
||||
},
|
||||
};
|
||||
Some(tabs::moderation::moderation_tab(
|
||||
config,
|
||||
|
||||
@@ -53,6 +53,8 @@ struct UserDetailQuery {
|
||||
delete_all_messages_dry_run: Option<String>,
|
||||
delete_all_messages_channel_count: Option<u64>,
|
||||
delete_all_messages_message_count: Option<u64>,
|
||||
report_id: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -380,6 +382,8 @@ async fn user_tab(
|
||||
delete_all_messages_dry_run: None,
|
||||
delete_all_messages_channel_count: None,
|
||||
delete_all_messages_message_count: None,
|
||||
report_id: None,
|
||||
message: None,
|
||||
};
|
||||
let admin_acls = auth
|
||||
.0
|
||||
@@ -458,6 +462,8 @@ fn to_tab_query(q: &UserDetailQuery) -> user_tabs::TabQuery {
|
||||
delete_all_messages_dry_run: q.delete_all_messages_dry_run.clone(),
|
||||
delete_all_messages_channel_count: q.delete_all_messages_channel_count,
|
||||
delete_all_messages_message_count: q.delete_all_messages_message_count,
|
||||
report_id: q.report_id.clone(),
|
||||
message: q.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
admin_flags::{
|
||||
self, COPYRIGHT_REASON_CODE, ENFORCEMENT_REASONS, EnforcementHandling, user_flag_bits,
|
||||
},
|
||||
api::{
|
||||
client::ApiResult,
|
||||
enforcement::DecisionSubmission,
|
||||
generated::{snowflake, types as generated_types},
|
||||
},
|
||||
templates::components::form::checkbox,
|
||||
utils::{forms::MultiValueForm, plural::count_noun, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub const FIELD_LABEL_CLASS: &str = "block text-sm font-medium text-neutral-700";
|
||||
pub const FIELD_HELP_CLASS: &str = "text-xs text-neutral-500";
|
||||
pub const FIELD_INPUT_CLASS: &str = "block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
pub const INTERNAL_NOTE_MAX_CHARS: usize = 512;
|
||||
const STATEMENT_MAX_CHARS: usize = 500;
|
||||
const ACCOUNT_DELETION_MIN_DAYS: u32 = 14;
|
||||
const ENFORCEMENT_CLOSURE_MIN_DAYS: u32 = 74;
|
||||
const RIGHTS_HOLDER_MAX_CHARS: usize = 200;
|
||||
|
||||
const AUTOMATED_LIMIT_FLAGS: &[(u64, &str)] = &[
|
||||
(user_flag_bits::ACCOUNT_LIMITED, "Account limited"),
|
||||
(user_flag_bits::PROFILE_HIDDEN, "Profile hidden"),
|
||||
];
|
||||
|
||||
const HANDLING_GROUPS: &[(EnforcementHandling, &str)] = &[
|
||||
(EnforcementHandling::Standard, "Standard"),
|
||||
(
|
||||
EnforcementHandling::Protected,
|
||||
"Protected: fewer details in the notice",
|
||||
),
|
||||
(
|
||||
EnforcementHandling::Restricted,
|
||||
"Restricted: no details in the notice",
|
||||
),
|
||||
];
|
||||
|
||||
pub struct DecisionFields<'a> {
|
||||
pub id_prefix: &'a str,
|
||||
pub kind: generated_types::EnforcementKindType,
|
||||
pub report_ids: &'a [String],
|
||||
pub messages: Option<&'a [String]>,
|
||||
pub user_flags: Option<u64>,
|
||||
pub allow_restricted: bool,
|
||||
}
|
||||
|
||||
pub struct BasisFields<'a> {
|
||||
pub decision: DecisionFields<'a>,
|
||||
pub choices: &'a [(&'a str, &'a str)],
|
||||
pub plain_note_help: &'a str,
|
||||
pub enforcement_extra: Markup,
|
||||
}
|
||||
|
||||
pub enum BasisSubmission {
|
||||
Enforcement(DecisionSubmission),
|
||||
HolderRequest(Option<String>),
|
||||
Housekeeping(Option<String>),
|
||||
}
|
||||
|
||||
impl BasisSubmission {
|
||||
pub fn internal_note(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Enforcement(submission) => Some(&submission.internal_note),
|
||||
Self::HolderRequest(note) | Self::Housekeeping(note) => note.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn moderation_basis(&self) -> generated_types::ModerationBasis {
|
||||
match self {
|
||||
Self::Enforcement(submission) => {
|
||||
generated_types::ModerationBasis::Enforcement(submission.decision.clone())
|
||||
}
|
||||
Self::HolderRequest(_) => generated_types::ModerationBasis::HolderRequest,
|
||||
Self::Housekeeping(_) => generated_types::ModerationBasis::Housekeeping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn automated_limits(flags: u64) -> Vec<&'static str> {
|
||||
AUTOMATED_LIMIT_FLAGS
|
||||
.iter()
|
||||
.filter(|(bit, _)| flags & bit != 0)
|
||||
.map(|(_, label)| *label)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn field_label(for_id: &str, text: &str) -> Markup {
|
||||
html! {
|
||||
label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn required_mark() -> Markup {
|
||||
html! {
|
||||
span class="text-red-500 ml-0.5" { "*" }
|
||||
}
|
||||
}
|
||||
|
||||
fn required_label(for_id: &str, text: &str) -> Markup {
|
||||
html! {
|
||||
label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) (required_mark()) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup {
|
||||
let id = format!("{id_prefix}-internal-note");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(required_label(&id, "Internal note"))
|
||||
input type="text" id=(id) name="internal_note" required
|
||||
maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deletion_days_field(id: &str, self_hosted: bool) -> Markup {
|
||||
let (days, help) = if self_hosted {
|
||||
(
|
||||
ACCOUNT_DELETION_MIN_DAYS,
|
||||
format!("At least {ACCOUNT_DELETION_MIN_DAYS} days."),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
ENFORCEMENT_CLOSURE_MIN_DAYS,
|
||||
format!(
|
||||
"At least {ENFORCEMENT_CLOSURE_MIN_DAYS} days when a rule was broken. At least {ACCOUNT_DELETION_MIN_DAYS} days otherwise."
|
||||
),
|
||||
)
|
||||
};
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(id, "Days until deletion"))
|
||||
input type="number" id=(id) name="days_until_deletion"
|
||||
value=(days) min="1" max="365" required class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn statement_field(id_prefix: &str, help: &str) -> Markup {
|
||||
let id = format!("{id_prefix}-statement");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(&id, "Note to the user"))
|
||||
textarea id=(id) name="statement" rows="2" maxlength=(STATEMENT_MAX_CHARS)
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup {
|
||||
checked_radio_group(legend, name, id_prefix, options, None)
|
||||
}
|
||||
|
||||
fn checked_radio_group(
|
||||
legend: &str,
|
||||
name: &str,
|
||||
id_prefix: &str,
|
||||
options: &[(&str, &str)],
|
||||
checked: Option<&str>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { (legend) (required_mark()) }
|
||||
div class="flex flex-wrap gap-x-4 gap-y-1" {
|
||||
@for (value, label) in options {
|
||||
@let id = format!("{id_prefix}-{name}-{value}");
|
||||
div class="flex items-center gap-2" {
|
||||
input type="radio" id=(id) name=(name) value=(value) required
|
||||
checked[checked == Some(*value)]
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(id) class="text-sm text-neutral-900" { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codes_attribute(handling: EnforcementHandling) -> String {
|
||||
ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.filter(|reason| reason.handling == handling)
|
||||
.map(|reason| reason.code.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn reason_select(
|
||||
id_prefix: &str,
|
||||
kind: generated_types::EnforcementKindType,
|
||||
allow_restricted: bool,
|
||||
) -> Markup {
|
||||
let id = format!("{id_prefix}-reason");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(required_label(&id, "Reason"))
|
||||
select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for (handling, group) in HANDLING_GROUPS
|
||||
.iter()
|
||||
.filter(|(handling, _)| allow_restricted || *handling != EnforcementHandling::Restricted)
|
||||
{
|
||||
@let reasons = ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.filter(|reason| reason.handling == *handling && reason.allows(kind))
|
||||
.collect::<Vec<_>>();
|
||||
@if !reasons.is_empty() {
|
||||
optgroup label=(group) {
|
||||
@for reason in reasons {
|
||||
option value=(reason.code) {
|
||||
(admin_flags::deletion_reason_label(reason.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn automated_limits_hint(flags: u64) -> Markup {
|
||||
let limits = automated_limits(flags);
|
||||
html! {
|
||||
p class=(FIELD_HELP_CLASS) data-automated-limits="" {
|
||||
@if limits.is_empty() {
|
||||
"No automated limits on this account now."
|
||||
} @else {
|
||||
"Automated limits on this account now: " (limits.join(", ")) "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
|
||||
let prefix = fields.id_prefix;
|
||||
let restricted_codes = codes_attribute(EnforcementHandling::Restricted);
|
||||
let reports_id = format!("{prefix}-report-ids");
|
||||
let messages_id = format!("{prefix}-evidence");
|
||||
let rights_holder_id = format!("{prefix}-rights-holder");
|
||||
let from_report = !fields.report_ids.is_empty();
|
||||
html! {
|
||||
div class="space-y-4" data-enforcement-fields="" {
|
||||
(reason_select(prefix, fields.kind, fields.allow_restricted))
|
||||
(checked_radio_group("How did we find this?", "source", prefix, &[
|
||||
("report", "A report"),
|
||||
("own_review", "Our own review"),
|
||||
], from_report.then_some("report")))
|
||||
div class="space-y-1" {
|
||||
(radio_group("Automated tools", "automation", prefix, &[
|
||||
("none", "Not involved"),
|
||||
("flagged", "Flagged this for review"),
|
||||
]))
|
||||
@if let Some(flags) = fields.user_flags {
|
||||
(automated_limits_hint(flags))
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
label for=(reports_id) class=(FIELD_LABEL_CLASS) {
|
||||
"Reports"
|
||||
span data-show-for-source="report" hidden[!from_report] { (required_mark()) }
|
||||
}
|
||||
textarea id=(reports_id) name="report_ids" rows="2"
|
||||
placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) {
|
||||
(fields.report_ids.join("\n"))
|
||||
}
|
||||
}
|
||||
@if let Some(messages) = fields.messages {
|
||||
div class="space-y-1" {
|
||||
(field_label(&messages_id, "Messages"))
|
||||
textarea id=(messages_id) name="evidence" rows="3"
|
||||
placeholder="One message link or channel_id/message_id per line"
|
||||
class=(FIELD_INPUT_CLASS) {
|
||||
(messages.join("\n"))
|
||||
}
|
||||
p class=(FIELD_HELP_CLASS) data-hide-for-codes=(restricted_codes) {
|
||||
"Messages this user sent in a linked report are included automatically. Without any messages, add a note to the user."
|
||||
}
|
||||
p class=(FIELD_HELP_CLASS) data-show-for-codes=(restricted_codes) hidden {
|
||||
"Messages this user sent in a linked report are included automatically. At least one message is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
div data-hide-for-codes=(restricted_codes) {
|
||||
(statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions."))
|
||||
}
|
||||
div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden {
|
||||
(required_label(&rights_holder_id, "Rights holder or right concerned"))
|
||||
input type="text" id=(rights_holder_id) name="rights_holder" required disabled
|
||||
maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"For example Example Records, or trademark. This is the one place a name is shown to the user."
|
||||
}
|
||||
}
|
||||
div class="space-y-2" data-show-for-codes=(restricted_codes) hidden {
|
||||
(checkbox("confirmations", "evidence_preserved", "Evidence is preserved", false, true))
|
||||
(checkbox("confirmations", "reports_filed_or_not_required", "Any required report is filed, or none is required", false, true))
|
||||
}
|
||||
(internal_note_field(prefix, "Staff only. Saved as the first note on the decision."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const NETWORK_BLOCK_DEFAULT_DAYS: u64 = 90;
|
||||
|
||||
fn network_note(network: &generated_types::NetworkBlockCandidatesResponseNetworksItem) -> String {
|
||||
use generated_types::NetworkBlockCandidatesResponseNetworksItemSkipReason as SkipReason;
|
||||
match network.skip_reason {
|
||||
Some(SkipReason::Exempt) => "Exempt, never blocked".to_owned(),
|
||||
Some(SkipReason::SpecialPurpose) => {
|
||||
"Private or special-purpose range, never blocked".to_owned()
|
||||
}
|
||||
None => {
|
||||
let others = u64::try_from(*network.other_unrestricted_accounts).unwrap_or(0);
|
||||
format!(
|
||||
"{} last active here",
|
||||
count_noun(
|
||||
others,
|
||||
"other unrestricted account",
|
||||
"other unrestricted accounts"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blockable(network: &generated_types::NetworkBlockCandidatesResponseNetworksItem) -> bool {
|
||||
network.skip_reason.is_none() && *network.other_unrestricted_accounts == 0
|
||||
}
|
||||
|
||||
pub fn network_block_fields(
|
||||
id_prefix: &str,
|
||||
candidates: &ApiResult<generated_types::NetworkBlockCandidatesResponse>,
|
||||
) -> Markup {
|
||||
let days_id = format!("{id_prefix}-network-block-days");
|
||||
html! {
|
||||
fieldset class="space-y-2" data-network-blocks="" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Block networks this account used" }
|
||||
@match candidates {
|
||||
Err(_) => {
|
||||
p class="text-sm text-red-700" { "Failed to load the networks this account used." }
|
||||
}
|
||||
Ok(response) if response.networks.is_empty() => {
|
||||
p class=(FIELD_HELP_CLASS) { "This account used no networks in the last 30 days." }
|
||||
}
|
||||
Ok(response) => {
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Networks from the last 30 days. A network other unrestricted accounts use can't be blocked."
|
||||
}
|
||||
@for network in &response.networks {
|
||||
div {
|
||||
(checkbox("block_networks", &network.ip, &network.ip, false, is_blockable(network)))
|
||||
p class={(FIELD_HELP_CLASS) " pl-7"} {
|
||||
"Last used " (format_admin_timestamp(&network.last_seen_at)) ". " (network_note(network)) "."
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
(field_label(&days_id, "Block length in days"))
|
||||
input type="number" id=(days_id) name="network_block_days"
|
||||
value=(NETWORK_BLOCK_DEFAULT_DAYS) min="1" max="365" class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { "Ending the decision or deleting the account removes the blocks sooner." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn network_blocks_from_form(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<Option<generated_types::NetworkBlockRequest>, String> {
|
||||
let ips = form
|
||||
.values("block_networks")
|
||||
.iter()
|
||||
.map(|ip| ip.trim().to_owned())
|
||||
.filter(|ip| !ip.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if ips.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let days = match form.parse_value::<u64>("network_block_days") {
|
||||
Ok(Some(days)) => days,
|
||||
Ok(None) => NETWORK_BLOCK_DEFAULT_DAYS,
|
||||
Err(_) => return Err("Enter the block length as a number of days".to_owned()),
|
||||
};
|
||||
let days =
|
||||
std::num::NonZeroU64::new(days).ok_or("The block length must be at least one day")?;
|
||||
Ok(Some(generated_types::NetworkBlockRequest { days, ips }))
|
||||
}
|
||||
|
||||
pub fn skipped_networks_summary(
|
||||
skipped: &[generated_types::SkippedNetworkBlock],
|
||||
) -> Option<String> {
|
||||
use generated_types::SkippedNetworkBlockReason as Reason;
|
||||
if skipped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entries = skipped
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let reason = match entry.reason {
|
||||
Reason::Exempt => "exempt",
|
||||
Reason::SpecialPurpose => "special-purpose range",
|
||||
Reason::AlreadyBlocked => "already blocked",
|
||||
};
|
||||
format!("{} ({reason})", entry.ip)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Some(format!("Not blocked: {}.", entries.join(", ")))
|
||||
}
|
||||
|
||||
pub fn basis_fields(fields: &BasisFields<'_>) -> Markup {
|
||||
let prefix = fields.decision.id_prefix;
|
||||
let plain = fields
|
||||
.choices
|
||||
.iter()
|
||||
.map(|(value, _)| *value)
|
||||
.filter(|value| *value != "enforcement")
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
html! {
|
||||
(radio_group("Basis", "basis", prefix, fields.choices))
|
||||
fieldset class="space-y-4" data-show-for-basis="enforcement" hidden disabled {
|
||||
(enforcement_fields(&fields.decision))
|
||||
(fields.enforcement_extra)
|
||||
}
|
||||
fieldset data-show-for-basis=(plain) hidden disabled {
|
||||
(internal_note_field(&format!("{prefix}-plain"), fields.plain_note_help))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn basis_from_form(form: &MultiValueForm) -> Result<BasisSubmission, String> {
|
||||
match form.first("basis") {
|
||||
Some("enforcement") => decision_from_form(form).map(BasisSubmission::Enforcement),
|
||||
Some("holder_request") => Ok(BasisSubmission::HolderRequest(form.clean("internal_note"))),
|
||||
Some("housekeeping") => Ok(BasisSubmission::Housekeeping(form.clean("internal_note"))),
|
||||
_ => Err("Choose a basis".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision_from_form(form: &MultiValueForm) -> Result<DecisionSubmission, String> {
|
||||
let reason_code = form
|
||||
.first("reason_code")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.and_then(|code| generated_types::EnforcementReasonCodeType::try_from(code).ok())
|
||||
.ok_or("Choose a reason")?;
|
||||
let source = form
|
||||
.first("source")
|
||||
.and_then(|value| generated_types::EnforcementDecisionInputSource::try_from(value).ok())
|
||||
.ok_or("Choose how we found this")?;
|
||||
let automation = form
|
||||
.first("automation")
|
||||
.and_then(|value| generated_types::EnforcementDecisionInputAutomation::try_from(value).ok())
|
||||
.ok_or("Choose whether automated tools were involved")?;
|
||||
let report_ids = lines(form.first("report_ids"))
|
||||
.map(|line| {
|
||||
is_snowflake(line)
|
||||
.then(|| snowflake(line))
|
||||
.ok_or_else(|| format!("Not a report ID: {line}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let evidence = lines(form.first("evidence"))
|
||||
.map(|line| {
|
||||
parse_message_reference(line)
|
||||
.map(|(channel_id, message_id)| {
|
||||
generated_types::EnforcementDecisionInputEvidenceItem {
|
||||
channel_id: snowflake(&channel_id),
|
||||
message_id: snowflake(&message_id),
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| format!("Not a message link or channel_id/message_id: {line}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let confirmations = form
|
||||
.values("confirmations")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::EnforcementDecisionInputConfirmationsItem::try_from(value.as_str())
|
||||
.map_err(|_| format!("Unknown confirmation: {value}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let internal_note = form.clean("internal_note").ok_or("Add an internal note")?;
|
||||
Ok(DecisionSubmission {
|
||||
decision: generated_types::EnforcementDecisionInput {
|
||||
automation,
|
||||
confirmations,
|
||||
evidence,
|
||||
reason_code,
|
||||
report_ids,
|
||||
rights_holder: form.clean("rights_holder"),
|
||||
source,
|
||||
statement: form.clean("statement"),
|
||||
},
|
||||
internal_note,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bulk_decision_from_form(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<generated_types::BulkEnforcementDecisionInput, String> {
|
||||
let reason_code = form
|
||||
.first("reason_code")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.and_then(|code| generated_types::EnforcementReasonCodeType::try_from(code).ok())
|
||||
.ok_or("Choose a reason")?;
|
||||
let automation = form
|
||||
.first("automation")
|
||||
.and_then(|value| {
|
||||
generated_types::BulkEnforcementDecisionInputAutomation::try_from(value).ok()
|
||||
})
|
||||
.ok_or("Choose whether automated tools were involved")?;
|
||||
let statement = form.clean("statement").ok_or("Add a note to the user")?;
|
||||
Ok(generated_types::BulkEnforcementDecisionInput {
|
||||
automation,
|
||||
reason_code,
|
||||
source: generated_types::BulkEnforcementDecisionInputSource::OwnReview,
|
||||
statement,
|
||||
})
|
||||
}
|
||||
|
||||
fn lines(value: Option<&str>) -> impl Iterator<Item = &str> {
|
||||
value
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn is_snowflake(value: &str) -> bool {
|
||||
(1..=20).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn snowflake_pair(channel_id: &str, message_id: &str) -> Option<(String, String)> {
|
||||
(is_snowflake(channel_id) && is_snowflake(message_id))
|
||||
.then(|| (channel_id.to_owned(), message_id.to_owned()))
|
||||
}
|
||||
|
||||
pub fn parse_message_reference(line: &str) -> Option<(String, String)> {
|
||||
let line = line.trim();
|
||||
if let Some((channel_id, message_id)) = line.split_once(['/', ','])
|
||||
&& let Some(pair) = snowflake_pair(channel_id.trim(), message_id.trim())
|
||||
{
|
||||
return Some(pair);
|
||||
}
|
||||
let url = url::Url::parse(line)
|
||||
.or_else(|_| url::Url::parse("https://admin.invalid/")?.join(line))
|
||||
.ok()?;
|
||||
let query_value = |key: &str| {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
};
|
||||
if let (Some(channel_id), Some(message_id)) =
|
||||
(query_value("channel_id"), query_value("message_id"))
|
||||
{
|
||||
return snowflake_pair(&channel_id, &message_id);
|
||||
}
|
||||
let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
|
||||
match segments.as_slice() {
|
||||
[.., "channels", _, channel_id, message_id] => snowflake_pair(channel_id, message_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TakeAction<'a> {
|
||||
pub author_id: Option<&'a str>,
|
||||
pub report_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn can_take_action(admin_acls: &[String]) -> bool {
|
||||
acl::has_any_permission(admin_acls, &[acl::USER_TEMP_BAN, acl::USER_UPDATE_FLAGS])
|
||||
}
|
||||
|
||||
pub fn take_action_href(
|
||||
base: &str,
|
||||
user_id: &str,
|
||||
report_id: Option<&str>,
|
||||
message: Option<(&str, &str)>,
|
||||
) -> String {
|
||||
let mut href = format!("{base}/users/{user_id}?tab=moderation");
|
||||
if let Some(report_id) = report_id {
|
||||
href.push_str("&report_id=");
|
||||
href.push_str(&urlencoding::encode(report_id));
|
||||
}
|
||||
if let Some((channel_id, message_id)) = message {
|
||||
href.push_str("&message=");
|
||||
href.push_str(&urlencoding::encode(&format!("{channel_id}/{message_id}")));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types::EnforcementKindType;
|
||||
|
||||
fn form(body: &str) -> MultiValueForm {
|
||||
MultiValueForm::parse(body.as_bytes())
|
||||
}
|
||||
|
||||
fn render(kind: EnforcementKindType, flags: u64) -> String {
|
||||
enforcement_fields(&DecisionFields {
|
||||
id_prefix: "suspend",
|
||||
kind,
|
||||
report_ids: &["1600000000000000001".to_owned()],
|
||||
messages: Some(&["1700000000000000001/1700000000000000002".to_owned()]),
|
||||
user_flags: Some(flags),
|
||||
allow_restricted: true,
|
||||
})
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_days_default_to_the_closure_floor_of_each_mode() {
|
||||
let hosted = deletion_days_field("close-days", false).into_string();
|
||||
assert!(hosted.contains(r#"name="days_until_deletion" value="74""#));
|
||||
assert!(
|
||||
hosted.contains("At least 74 days when a rule was broken. At least 14 days otherwise.")
|
||||
);
|
||||
let self_hosted = deletion_days_field("close-days", true).into_string();
|
||||
assert!(self_hosted.contains(r#"name="days_until_deletion" value="14""#));
|
||||
assert!(self_hosted.contains(">At least 14 days.</p>"));
|
||||
assert!(!self_hosted.contains("74"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fieldset_renders_every_input_with_prefills() {
|
||||
let html = render(EnforcementKindType::Suspension, 0);
|
||||
for marker in [
|
||||
r#"name="reason_code""#,
|
||||
r#"name="source" value="report""#,
|
||||
r#"name="source" value="own_review""#,
|
||||
r#"name="automation" value="none""#,
|
||||
r#"name="automation" value="flagged""#,
|
||||
r#"name="report_ids""#,
|
||||
r#"name="evidence""#,
|
||||
r#"name="statement""#,
|
||||
r#"name="rights_holder""#,
|
||||
r#"name="confirmations" value="evidence_preserved""#,
|
||||
r#"name="confirmations" value="reports_filed_or_not_required""#,
|
||||
r#"name="internal_note""#,
|
||||
">1600000000000000001</textarea>",
|
||||
">1700000000000000001/1700000000000000002</textarea>",
|
||||
"No automated limits on this account now.",
|
||||
] {
|
||||
assert!(html.contains(marker), "missing {marker}\n{html}");
|
||||
}
|
||||
assert!(!html.contains(r#"name="automation" value="automated""#));
|
||||
assert_eq!(html.matches("checked").count(), 1);
|
||||
assert!(html.contains(r#"name="source" value="report" required checked"#));
|
||||
assert!(html.contains(r#"<span data-show-for-source="report"><span"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fieldset_without_a_report_leaves_the_source_open() {
|
||||
let html = enforcement_fields(&DecisionFields {
|
||||
id_prefix: "limit",
|
||||
kind: EnforcementKindType::FeatureLimit,
|
||||
report_ids: &[],
|
||||
messages: Some(&[]),
|
||||
user_flags: None,
|
||||
allow_restricted: true,
|
||||
})
|
||||
.into_string();
|
||||
assert!(!html.contains("checked"));
|
||||
assert!(html.contains(r#"<span data-show-for-source="report" hidden><span"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_carry_a_mark() {
|
||||
let html = render(EnforcementKindType::FeatureLimit, 0);
|
||||
let mark = r#"<span class="text-red-500 ml-0.5">*</span>"#;
|
||||
for label in [
|
||||
"Reason",
|
||||
"How did we find this?",
|
||||
"Automated tools",
|
||||
"Internal note",
|
||||
"Rights holder or right concerned",
|
||||
] {
|
||||
assert!(html.contains(&format!("{label}{mark}")), "unmarked {label}");
|
||||
}
|
||||
for label in ["Messages<", "Note to the user<"] {
|
||||
assert!(html.contains(label), "marked {label}");
|
||||
}
|
||||
assert_eq!(html.matches(mark).count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rights_holder_and_confirmations_start_hidden_and_follow_their_codes() {
|
||||
let html = render(EnforcementKindType::Suspension, 0);
|
||||
assert!(html.contains(r#"data-show-for-codes="31" hidden"#));
|
||||
assert!(html.contains(r#"data-show-for-codes="9 13 24" hidden"#));
|
||||
assert!(html.contains(r#"data-hide-for-codes="9 13 24""#));
|
||||
assert!(html.contains(r#"name="rights_holder" required disabled"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_are_grouped_by_handling_and_filtered_by_kind() {
|
||||
let suspend = render(EnforcementKindType::Suspension, 0);
|
||||
assert!(suspend.contains(
|
||||
r#"<optgroup label="Restricted: no details in the notice"><option value="9">"#
|
||||
));
|
||||
assert!(suspend.contains(
|
||||
r#"<optgroup label="Protected: fewer details in the notice"><option value="7">"#
|
||||
));
|
||||
for omitted in [1, 2, 19, 30, 31] {
|
||||
assert!(
|
||||
!suspend.contains(&format!(r#"<option value="{omitted}">"#)),
|
||||
"suspension offers {omitted}"
|
||||
);
|
||||
}
|
||||
let warn = render(EnforcementKindType::Warning, 0);
|
||||
assert!(!warn.contains("Restricted: no details"));
|
||||
assert!(warn.contains(r#"<option value="31">Copyright</option>"#));
|
||||
assert!(warn.contains(r#"<option value="29">"#));
|
||||
assert!(!warn.contains(r#"<option value="30">"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_limits_list_the_neutral_hook_flags() {
|
||||
let html = render(
|
||||
EnforcementKindType::Warning,
|
||||
user_flag_bits::ACCOUNT_LIMITED
|
||||
| user_flag_bits::PROFILE_HIDDEN
|
||||
| user_flag_bits::SPAMMER,
|
||||
);
|
||||
assert!(
|
||||
html.contains("Automated limits on this account now: Account limited, Profile hidden.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_from_form_builds_the_request() {
|
||||
let submission = decision_from_form(&form(
|
||||
"reason_code=31&source=report&automation=flagged\
|
||||
&report_ids=1600000000000000001%0D%0A%0D%0A1600000000000000002\
|
||||
&evidence=1700000000000000001%2F1700000000000000002%0Ahttps%3A%2F%2Fweb.example%2Fchannels%2F%40me%2F1700000000000000003%2F1700000000000000004\
|
||||
&statement=+Please+stop+&rights_holder=Example+Records\
|
||||
&internal_note=Ticket+42",
|
||||
))
|
||||
.expect("valid form");
|
||||
assert_eq!(submission.internal_note, "Ticket 42");
|
||||
let body = serde_json::to_value(&submission.decision).expect("serializable decision");
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"reason_code": 31,
|
||||
"source": "report",
|
||||
"automation": "flagged",
|
||||
"report_ids": ["1600000000000000001", "1600000000000000002"],
|
||||
"evidence": [
|
||||
{"channel_id": "1700000000000000001", "message_id": "1700000000000000002"},
|
||||
{"channel_id": "1700000000000000003", "message_id": "1700000000000000004"}
|
||||
],
|
||||
"statement": "Please stop",
|
||||
"rights_holder": "Example Records"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_from_form_sends_restricted_confirmations_and_omits_empty_fields() {
|
||||
let submission = decision_from_form(&form(
|
||||
"reason_code=13&source=own_review&automation=none&report_ids=&evidence=1%2F2\
|
||||
&statement=&confirmations=evidence_preserved\
|
||||
&confirmations=reports_filed_or_not_required&internal_note=Filed",
|
||||
))
|
||||
.expect("valid form");
|
||||
let body = serde_json::to_value(&submission.decision).expect("serializable decision");
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"reason_code": 13,
|
||||
"source": "own_review",
|
||||
"automation": "none",
|
||||
"evidence": [{"channel_id": "1", "message_id": "2"}],
|
||||
"confirmations": ["evidence_preserved", "reports_filed_or_not_required"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_from_form_refuses_incomplete_input() {
|
||||
let cases = [
|
||||
(
|
||||
"source=report&automation=none&internal_note=x",
|
||||
"Choose a reason",
|
||||
),
|
||||
(
|
||||
"reason_code=2&source=report&automation=none&internal_note=x",
|
||||
"Choose a reason",
|
||||
),
|
||||
(
|
||||
"reason_code=3&automation=none&internal_note=x",
|
||||
"Choose how we found this",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=automated&internal_note=x",
|
||||
"Choose whether automated tools were involved",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=none&internal_note=+",
|
||||
"Add an internal note",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=none&internal_note=x&report_ids=abc",
|
||||
"Not a report ID: abc",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=none&internal_note=x&evidence=hello",
|
||||
"Not a message link or channel_id/message_id: hello",
|
||||
),
|
||||
];
|
||||
for (body, expected) in cases {
|
||||
let error = decision_from_form(&form(body)).err();
|
||||
assert_eq!(error.as_deref(), Some(expected), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_fields_hide_the_decision_until_a_rule_was_broken() {
|
||||
let html = basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "shred",
|
||||
kind: EnforcementKindType::ContentRemoval,
|
||||
report_ids: &[],
|
||||
messages: None,
|
||||
user_flags: None,
|
||||
allow_restricted: false,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
],
|
||||
plain_note_help: "Staff only.",
|
||||
enforcement_extra: html! {},
|
||||
})
|
||||
.into_string();
|
||||
assert!(html.contains(r#"data-show-for-basis="enforcement" hidden disabled"#));
|
||||
assert!(
|
||||
html.contains(r#"data-show-for-basis="holder_request housekeeping" hidden disabled"#)
|
||||
);
|
||||
assert!(!html.contains(r#"name="evidence""#));
|
||||
assert!(!html.contains("Restricted: no details"));
|
||||
assert!(!html.contains("data-automated-limits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_from_form_reads_each_basis() {
|
||||
assert!(matches!(
|
||||
basis_from_form(&form("basis=holder_request&internal_note=Asked+by+email")),
|
||||
Ok(BasisSubmission::HolderRequest(Some(note))) if note == "Asked by email"
|
||||
));
|
||||
assert!(matches!(
|
||||
basis_from_form(&form("basis=housekeeping")),
|
||||
Ok(BasisSubmission::Housekeeping(None))
|
||||
));
|
||||
let enforcement = basis_from_form(&form(
|
||||
"basis=enforcement&reason_code=3&source=own_review&automation=none&internal_note=x",
|
||||
))
|
||||
.expect("valid enforcement basis");
|
||||
assert_eq!(enforcement.internal_note(), Some("x"));
|
||||
assert!(matches!(
|
||||
enforcement.moderation_basis(),
|
||||
generated_types::ModerationBasis::Enforcement(_)
|
||||
));
|
||||
assert_eq!(
|
||||
basis_from_form(&form("internal_note=x")).err().as_deref(),
|
||||
Some("Choose a basis")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_references_accept_ids_admin_links_and_app_links() {
|
||||
let expected = Some(("111".to_owned(), "222".to_owned()));
|
||||
for line in [
|
||||
"111/222",
|
||||
" 111 / 222 ",
|
||||
"111,222",
|
||||
"https://admin.example/messages?channel_id=111&message_id=222&context_limit=50",
|
||||
"/messages?channel_id=111&message_id=222",
|
||||
"https://web.example/channels/@me/111/222",
|
||||
"https://web.example/channels/999/111/222",
|
||||
] {
|
||||
assert_eq!(parse_message_reference(line), expected, "{line}");
|
||||
}
|
||||
for line in [
|
||||
"111",
|
||||
"abc/222",
|
||||
"https://web.example/channels/999/111",
|
||||
"https://web.example/invite/111/222",
|
||||
"/messages?channel_id=111",
|
||||
] {
|
||||
assert_eq!(parse_message_reference(line), None, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_action_links_open_the_moderation_tab_with_prefills() {
|
||||
assert_eq!(
|
||||
take_action_href("/admin", "1", Some("2"), Some(("3", "4"))),
|
||||
"/admin/users/1?tab=moderation&report_id=2&message=3%2F4"
|
||||
);
|
||||
assert_eq!(
|
||||
take_action_href("", "1", None, None),
|
||||
"/users/1?tab=moderation"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub struct Message {
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_id: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub missing_attachments: Vec<MissingAttachment>,
|
||||
@@ -85,6 +86,7 @@ fn message_from_value(value: &Value) -> Message {
|
||||
channel_content_warning_text: value["channel_content_warning_text"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
guild_id: value_id(&value["guild_id"]),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
missing_attachments,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::enforcement_fields::{TakeAction, take_action_href};
|
||||
use super::icons::paperclip_icon;
|
||||
use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
|
||||
use super::message_data::{Attachment, Message};
|
||||
@@ -110,6 +110,48 @@ fn render_missing_attachments(msg: &Message) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_button(value: &str, label: &str) -> Markup {
|
||||
html! {
|
||||
button type="button"
|
||||
class="cursor-pointer text-blue-600 hover:underline"
|
||||
data-copy-value=(value)
|
||||
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_meta(msg: &Message, jump_link: Option<&str>, take_action: Option<&str>) -> Markup {
|
||||
html! {
|
||||
div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-neutral-400 text-xs" {
|
||||
span class="select-all" { (msg.id) }
|
||||
@if let Some(href) = take_action {
|
||||
a href=(href) class="text-blue-600 hover:underline" { "Take action" }
|
||||
}
|
||||
@if let Some(link) = jump_link {
|
||||
(copy_button(link, "Copy link"))
|
||||
}
|
||||
(copy_button(&msg.id, "Copy message ID"))
|
||||
@if !msg.channel_id.is_empty() {
|
||||
(copy_button(&msg.channel_id, "Copy channel ID"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_jump_link(web_app_endpoint: &str, msg: &Message) -> Option<String> {
|
||||
if msg.channel_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}/channels/{}/{}/{}",
|
||||
web_app_endpoint.trim_end_matches('/'),
|
||||
msg.guild_id.as_deref().unwrap_or("@me"),
|
||||
msg.channel_id,
|
||||
msg.id
|
||||
))
|
||||
}
|
||||
|
||||
fn message_row(
|
||||
base_path: &str,
|
||||
avatar_url: &str,
|
||||
@@ -117,6 +159,7 @@ fn message_row(
|
||||
include_delete: bool,
|
||||
is_highlighted: bool,
|
||||
is_grouped: bool,
|
||||
meta: &Markup,
|
||||
) -> Markup {
|
||||
let hover = if is_highlighted {
|
||||
" hover:bg-amber-100"
|
||||
@@ -150,9 +193,7 @@ fn message_row(
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
(meta)
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-0 right-2 hidden group-hover:block" {
|
||||
button type="button"
|
||||
@@ -233,9 +274,7 @@ fn message_row(
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
(meta)
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-1 right-2 hidden group-hover:block" {
|
||||
@@ -257,6 +296,7 @@ pub fn message_list(
|
||||
messages: &[Message],
|
||||
include_delete: bool,
|
||||
highlight_message_id: Option<&str>,
|
||||
take_action: Option<&TakeAction<'_>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="divide-y-0" {
|
||||
@@ -268,68 +308,95 @@ pub fn message_list(
|
||||
@let avatar_url = user_avatar_url(
|
||||
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
|
||||
);
|
||||
@let take_action_href = take_action
|
||||
.filter(|action| {
|
||||
msg.webhook_id.is_none()
|
||||
&& action.author_id.is_none_or(|author_id| author_id == msg.author_id)
|
||||
})
|
||||
.map(|action| take_action_href(
|
||||
base_path,
|
||||
&msg.author_id,
|
||||
action.report_id,
|
||||
Some((&msg.channel_id, &msg.id)),
|
||||
));
|
||||
@let jump_link = message_jump_link(&config.web_app_endpoint, msg);
|
||||
@let meta = message_meta(msg, jump_link.as_deref(), take_action_href.as_deref());
|
||||
(message_row(
|
||||
base_path, &avatar_url, msg,
|
||||
include_delete, is_highlighted, is_grouped,
|
||||
&meta,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
let csrf = json_string(csrf_token);
|
||||
let script = r#"(function() {
|
||||
var csrf = __CSRF__;
|
||||
function bp() {
|
||||
return document.documentElement.dataset.basePath || '';
|
||||
}
|
||||
function toast(level, message) {
|
||||
document.body.dispatchEvent(new CustomEvent('showFlash', {detail: {level: level, message: message}}));
|
||||
}
|
||||
function post(action, fields) {
|
||||
fields.append('_csrf', csrf);
|
||||
return fetch(bp() + '/messages?action=' + action, {
|
||||
method: 'POST',
|
||||
body: fields,
|
||||
credentials: 'same-origin',
|
||||
headers: {'x-csrf-token': csrf}
|
||||
});
|
||||
}
|
||||
function deleteMessage(b) {
|
||||
var fields = new URLSearchParams();
|
||||
fields.append('channel_id', b.dataset.channelId || '');
|
||||
fields.append('message_id', b.dataset.messageId || '');
|
||||
b.disabled = true;
|
||||
b.textContent = 'Deleting...';
|
||||
toast('info', 'Deleting message...');
|
||||
post('delete', fields).then(function(r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
var row = b.closest('[data-message-id]');
|
||||
if (row) {
|
||||
row.style.opacity = '0.5';
|
||||
row.style.pointerEvents = 'none';
|
||||
}
|
||||
b.textContent = 'Deleted';
|
||||
toast('success', 'Message deleted.');
|
||||
}).catch(function() {
|
||||
b.disabled = false;
|
||||
b.textContent = 'Delete';
|
||||
toast('error', 'Failed to delete message.');
|
||||
});
|
||||
pub fn message_delete_prefill_script() -> Markup {
|
||||
html! {
|
||||
script defer { (PreEscaped(MESSAGE_DELETE_PREFILL_SCRIPT)) }
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_DELETE_PREFILL_SCRIPT: &str = r#"(function() {
|
||||
if (window.__fluxerMessageDeletePrefill) return;
|
||||
window.__fluxerMessageDeletePrefill = true;
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
var d = t.closest('.delete-message-btn');
|
||||
if (d instanceof HTMLButtonElement) {
|
||||
e.preventDefault();
|
||||
deleteMessage(d);
|
||||
}
|
||||
var button = t.closest('.delete-message-btn');
|
||||
var form = document.getElementById('delete-message-form');
|
||||
if (!(button instanceof HTMLButtonElement) || !form) return;
|
||||
e.preventDefault();
|
||||
form.querySelector('[name="channel_id"]').value = button.dataset.channelId || '';
|
||||
form.querySelector('[name="message_id"]').value = button.dataset.messageId || '';
|
||||
form.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
form.querySelector('[name="basis"]').focus({preventScroll: true});
|
||||
});
|
||||
})();"#
|
||||
.replace("__CSRF__", &csrf);
|
||||
html! {
|
||||
script defer { (PreEscaped(script)) }
|
||||
})();"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::templates::components::message_data::ordered_messages;
|
||||
use serde_json::json;
|
||||
|
||||
fn message(guild_id: serde_json::Value, channel_id: serde_json::Value) -> Message {
|
||||
ordered_messages(&[json!({
|
||||
"id": "1560373081531219968",
|
||||
"channel_id": channel_id,
|
||||
"guild_id": guild_id,
|
||||
"author_id": "1",
|
||||
"author_username": "someone",
|
||||
})])
|
||||
.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_link_uses_guild_id_and_web_app_endpoint() {
|
||||
let msg = message(json!("42"), json!("7"));
|
||||
assert_eq!(
|
||||
message_jump_link("https://web.fluxer.example/", &msg).as_deref(),
|
||||
Some("https://web.fluxer.example/channels/42/7/1560373081531219968")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_link_falls_back_to_me_for_dms() {
|
||||
let msg = message(json!(null), json!("7"));
|
||||
assert_eq!(
|
||||
message_jump_link("https://web.fluxer.example", &msg).as_deref(),
|
||||
Some("https://web.fluxer.example/channels/@me/7/1560373081531219968")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_without_channel_skips_link_and_channel_copy() {
|
||||
let msg = message(json!(null), json!(null));
|
||||
assert_eq!(message_jump_link("https://web.fluxer.example", &msg), None);
|
||||
let html = message_meta(&msg, None, None).into_string();
|
||||
assert!(html.contains(r#"data-copy-value="1560373081531219968""#));
|
||||
assert!(html.contains("Copy message ID"));
|
||||
assert!(!html.contains("Copy link"));
|
||||
assert!(!html.contains("Copy channel ID"));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod data_field;
|
||||
pub mod detail_tabs;
|
||||
pub mod drawer;
|
||||
pub mod empty_state;
|
||||
pub mod enforcement_fields;
|
||||
pub mod error_display;
|
||||
pub mod form;
|
||||
pub mod icons;
|
||||
@@ -21,6 +22,7 @@ pub mod report_category;
|
||||
pub mod report_webhook;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod staff_notes;
|
||||
pub mod table;
|
||||
pub mod tooltip;
|
||||
pub mod user_display;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
templates::components::{
|
||||
enforcement_fields::{FIELD_HELP_CLASS, FIELD_INPUT_CLASS, field_label},
|
||||
form::{csrf_input, form_actions, submit_button},
|
||||
section_card::section_card_simple,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const NOTE_MAX_CHARS: usize = 2000;
|
||||
|
||||
pub struct StaffNote<'a> {
|
||||
pub note_id: &'a str,
|
||||
pub author: Markup,
|
||||
pub body: &'a str,
|
||||
pub created_at: &'a str,
|
||||
}
|
||||
|
||||
pub struct StaffNoteForm<'a> {
|
||||
pub action: String,
|
||||
pub refresh_target: &'a str,
|
||||
pub field_id: String,
|
||||
pub help: &'a str,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn staff_notes_section(notes: &[StaffNote], form: Option<StaffNoteForm>) -> Markup {
|
||||
html! {
|
||||
(section_card_simple("Notes", html! {
|
||||
div class="space-y-4" {
|
||||
@if notes.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No notes." }
|
||||
} @else {
|
||||
ol class="space-y-3" {
|
||||
@for note in notes {
|
||||
li class="space-y-1" data-note=(note.note_id) {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(note.author)
|
||||
" on " (format_admin_timestamp(note.created_at))
|
||||
}
|
||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Some(form) = form {
|
||||
form method="post" action=(form.action)
|
||||
data-admin-refresh-on-success=(form.refresh_target) {
|
||||
(csrf_input(form.csrf_token))
|
||||
div class="space-y-3" {
|
||||
div class="space-y-1" {
|
||||
(field_label(&form.field_id, "Add a note"))
|
||||
textarea id=(form.field_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { (form.help) }
|
||||
}
|
||||
(form_actions(submit_button("Add note")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::user_tag::user_tag;
|
||||
use crate::{api::types::AdminUser, utils::user_tag::user_tag};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub const SYSTEM_USER_ID: &str = "0";
|
||||
|
||||
pub fn format_user_display(
|
||||
global_name: Option<&str>,
|
||||
@@ -16,3 +19,29 @@ pub fn format_user_display(
|
||||
_ => "Unknown".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_name(users: &[AdminUser], id: &str) -> String {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return "System".to_owned();
|
||||
}
|
||||
users.iter().find(|user| user.id == id).map_or_else(
|
||||
|| id.to_owned(),
|
||||
|user| {
|
||||
format_user_display(
|
||||
user.global_name.as_deref(),
|
||||
Some(&user.username),
|
||||
Some(&user.discriminator),
|
||||
user.bot,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return html! { "System" };
|
||||
}
|
||||
html! {
|
||||
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
use super::components::drawer::drawer_controller_script;
|
||||
use super::layout_header::render_header;
|
||||
use super::layout_scripts::{
|
||||
ADMIN_ACTION_FORM_SCRIPT, COPY_TO_CLIPBOARD_SCRIPT, HTMX_FLASH_SCRIPT,
|
||||
HTMX_SCROLL_PRESERVER_SCRIPT, SH_LINK_REWRITE_SCRIPT, SIDEBAR_SCRIPT,
|
||||
ADMIN_ACTION_FORM_SCRIPT, COPY_TO_CLIPBOARD_SCRIPT, ENFORCEMENT_FIELDS_SCRIPT,
|
||||
HTMX_FLASH_SCRIPT, HTMX_SCROLL_PRESERVER_SCRIPT, SH_LINK_REWRITE_SCRIPT, SIDEBAR_SCRIPT,
|
||||
};
|
||||
use super::layout_sidebar::render_sidebar;
|
||||
|
||||
@@ -104,7 +104,7 @@ pub fn admin_layout_ext(
|
||||
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
|
||||
a href="#main-content" class="skip-link" { "Skip to main content" }
|
||||
div class="flex h-[100dvh]" {
|
||||
(render_sidebar(config, admin_acls, active_page, options.inspected_voice_region_id))
|
||||
(render_sidebar(config, admin_acls, active_page, options.inspected_voice_region_id, auth.pending_registration_count))
|
||||
div data-sidebar-overlay="" class="pointer-events-none fixed inset-0 z-30 bg-black/50 opacity-0 transition-opacity duration-200 ease-in-out lg:hidden" aria-hidden="true" {}
|
||||
div class="flex h-[100dvh] w-full flex-1 flex-col overflow-y-auto" {
|
||||
(render_header(config, auth, csrf_token))
|
||||
@@ -128,6 +128,7 @@ pub fn admin_layout_ext(
|
||||
script defer { (PreEscaped(HTMX_SCROLL_PRESERVER_SCRIPT)) }
|
||||
script defer { (PreEscaped(HTMX_FLASH_SCRIPT)) }
|
||||
script defer { (PreEscaped(COPY_TO_CLIPBOARD_SCRIPT)) }
|
||||
script defer { (PreEscaped(ENFORCEMENT_FIELDS_SCRIPT)) }
|
||||
(drawer_controller_script())
|
||||
@if let Some(extra) = options.extra_scripts {
|
||||
script defer { (PreEscaped(extra)) }
|
||||
|
||||
@@ -571,6 +571,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
showToast(failed ? 'error' : 'success', isLoginResponse(xhr) ? 'Session expired. Sign in again.' : failed ? 'Action failed.' : 'Done.');
|
||||
markToastHandled(event.detail);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('htmx:sendError', function (event) {
|
||||
if (requestVerb(event.detail) !== 'get') return;
|
||||
var path = event.detail.pathInfo && event.detail.pathInfo.finalRequestPath;
|
||||
if (path) window.location.assign(path);
|
||||
}, true);
|
||||
})();
|
||||
"#;
|
||||
|
||||
@@ -622,3 +628,44 @@ pub const SH_LINK_REWRITE_SCRIPT: &str = r#"
|
||||
})();
|
||||
|
||||
"#;
|
||||
|
||||
pub const ENFORCEMENT_FIELDS_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
if (window.__fluxerEnforcementFields) return;
|
||||
window.__fluxerEnforcementFields = true;
|
||||
function codes(section, name) {
|
||||
var value = section.getAttribute(name);
|
||||
return value === null ? null : value.split(' ');
|
||||
}
|
||||
function syncReason(form, code) {
|
||||
form.querySelectorAll('[data-show-for-codes], [data-hide-for-codes]').forEach(function (section) {
|
||||
var showFor = codes(section, 'data-show-for-codes');
|
||||
var hideFor = codes(section, 'data-hide-for-codes');
|
||||
var shown = showFor ? showFor.indexOf(code) >= 0 : hideFor.indexOf(code) < 0;
|
||||
section.hidden = !shown;
|
||||
section.querySelectorAll('input, textarea, select').forEach(function (control) {
|
||||
control.disabled = !shown;
|
||||
});
|
||||
});
|
||||
}
|
||||
function syncChoice(form, name, value) {
|
||||
form.querySelectorAll('[data-show-for-' + name + ']').forEach(function (section) {
|
||||
var shown = codes(section, 'data-show-for-' + name).indexOf(value) >= 0;
|
||||
section.hidden = !shown;
|
||||
section.disabled = !shown;
|
||||
});
|
||||
}
|
||||
document.addEventListener('change', function (event) {
|
||||
var control = event.target;
|
||||
var form = control instanceof Element ? control.closest('form') : null;
|
||||
if (!form) return;
|
||||
if (control instanceof HTMLSelectElement && control.name === 'reason_code') {
|
||||
syncReason(form, control.value);
|
||||
} else if (control instanceof HTMLSelectElement && (control.name === 'resolution' || control.name === 'duration_hours')) {
|
||||
syncChoice(form, control.name, control.value);
|
||||
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit' || control.name === 'source')) {
|
||||
syncChoice(form, control.name, control.value);
|
||||
}
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
@@ -4,13 +4,14 @@ use crate::{acl, config::AdminConfig};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
|
||||
use super::layout_sidebar_nav::NAV_SECTIONS;
|
||||
use super::layout_sidebar_nav::{NAV_SECTIONS, PENDING_REGISTRATIONS_KEY};
|
||||
|
||||
pub fn render_sidebar(
|
||||
config: &AdminConfig,
|
||||
admin_acls: &[String],
|
||||
active_page: &str,
|
||||
inspected_voice_region_id: Option<&str>,
|
||||
pending_registration_count: Option<u32>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@@ -40,7 +41,7 @@ pub fn render_sidebar(
|
||||
div class="space-y-1" {
|
||||
@for item in &visible_items {
|
||||
@let active = active_page == item.active_key;
|
||||
@let base_classes = "block min-h-[44px] px-3 py-2.5 rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900";
|
||||
@let base_classes = "flex min-h-[44px] items-center justify-between gap-3 px-3 py-2.5 rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900";
|
||||
@let item_path = if item.active_key == "voice-servers" {
|
||||
if let Some(region_id) = inspected_voice_region_id {
|
||||
let encoded = urlencoding::encode(region_id);
|
||||
@@ -58,6 +59,9 @@ pub fn render_sidebar(
|
||||
};
|
||||
a href={(base) (item_path)} class=(classes) aria-current=[active.then_some("page")] data-active=[active.then_some("")] {
|
||||
(item.title)
|
||||
@if item.active_key == PENDING_REGISTRATIONS_KEY {
|
||||
(pending_registration_count_markup(pending_registration_count, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,3 +73,13 @@ pub fn render_sidebar(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending_registration_count_markup(count: Option<u32>, out_of_band: bool) -> Markup {
|
||||
html! {
|
||||
span id="pending-registration-count" class="tabular-nums text-neutral-400" hx-swap-oob=[out_of_band.then_some("true")] {
|
||||
@if let Some(count) = count.filter(|count| *count > 0) {
|
||||
(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ pub struct NavItem {
|
||||
pub required_acls: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub const PENDING_REGISTRATIONS_KEY: &str = "pending-registrations";
|
||||
|
||||
pub struct NavSection {
|
||||
pub title: &'static str,
|
||||
pub items: &'static [NavItem],
|
||||
@@ -38,12 +40,24 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
title: "Moderation",
|
||||
items: &[
|
||||
item!("Reports", "/reports", "reports", [acl::REPORT_VIEW]),
|
||||
item!(
|
||||
"Decisions",
|
||||
"/decisions",
|
||||
"decisions",
|
||||
[acl::ENFORCEMENT_VIEW]
|
||||
),
|
||||
item!(
|
||||
"Discovery Review",
|
||||
"/discovery",
|
||||
"discovery",
|
||||
[acl::DISCOVERY_REVIEW]
|
||||
),
|
||||
item!(
|
||||
"Pending Registrations",
|
||||
"/pending-registrations",
|
||||
PENDING_REGISTRATIONS_KEY,
|
||||
[acl::REGISTRATION_REVIEW]
|
||||
),
|
||||
item!(
|
||||
"Bulk Actions",
|
||||
"/bulk-actions",
|
||||
|
||||
@@ -21,11 +21,17 @@ pub fn format_action(action: &str) -> String {
|
||||
pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
||||
match action {
|
||||
"temp_ban" | "schedule_deletion" | "ban_ip" | "ban_email" => BadgeVariant::Danger,
|
||||
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" => BadgeVariant::Success,
|
||||
"update_flags" | "update_features" | "set_acls" | "update_settings" | "annotate_ban" => {
|
||||
BadgeVariant::Info
|
||||
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" | "end_enforcement_decision" => {
|
||||
BadgeVariant::Success
|
||||
}
|
||||
"delete_message" => BadgeVariant::Warning,
|
||||
"update_flags"
|
||||
| "update_features"
|
||||
| "set_acls"
|
||||
| "update_settings"
|
||||
| "annotate_ban"
|
||||
| "annotate_enforcement_decision"
|
||||
| "annotate_report" => BadgeVariant::Info,
|
||||
"delete_message" | "warn_user" => BadgeVariant::Warning,
|
||||
_ => BadgeVariant::Default,
|
||||
}
|
||||
}
|
||||
@@ -126,6 +132,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}))
|
||||
},
|
||||
"enforcement_decision" => html! {
|
||||
a href={(base) "/decisions/" (entry.target_id)}
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Decision" }
|
||||
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
|
||||
}
|
||||
}
|
||||
},
|
||||
"message" => html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Message" }
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct BanConfig {
|
||||
pub active_page: &'static str,
|
||||
pub show_bulk_tools: bool,
|
||||
pub show_duration: bool,
|
||||
pub add_title: Option<&'static str>,
|
||||
pub add_help: Option<&'static str>,
|
||||
}
|
||||
|
||||
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
|
||||
@@ -53,6 +55,10 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "ip-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: true,
|
||||
add_title: Some("Block a network"),
|
||||
add_help: Some(
|
||||
"For accounts, block networks from the account's suspension or closure instead.",
|
||||
),
|
||||
},
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
@@ -65,6 +71,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
@@ -77,6 +85,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "phrase-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Blocklist",
|
||||
@@ -89,6 +99,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "url-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "File SHA Blocklist",
|
||||
@@ -101,6 +113,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "file-sha-bans",
|
||||
show_bulk_tools: true,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Avatar Hash Blocklist",
|
||||
@@ -113,6 +127,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "avatar-hash-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Domain Blocklist",
|
||||
@@ -125,6 +141,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "url-domain-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Profile Substring Blocklist",
|
||||
@@ -137,6 +155,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "profile-substring-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -187,7 +207,14 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
h3 class="text-base font-medium text-neutral-900 mb-4" {
|
||||
"Ban " (cfg.input_label)
|
||||
@if let Some(title) = cfg.add_title {
|
||||
(title)
|
||||
} @else {
|
||||
"Ban " (cfg.input_label)
|
||||
}
|
||||
}
|
||||
@if let Some(help) = cfg.add_help {
|
||||
p class="text-sm text-neutral-500 mb-4" { (help) }
|
||||
}
|
||||
form method="post" action={(base) (cfg.route) "?action=ban&_csrf=" (csrf_token)}
|
||||
hx-post={(base) (cfg.route) "?action=ban&_csrf=" (csrf_token)}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::{DELETION_REASONS, DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
|
||||
use crate::admin_flags::{DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
|
||||
use crate::{
|
||||
acl,
|
||||
api::generated::types::EnforcementKindType,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{
|
||||
FIELD_HELP_CLASS, FIELD_INPUT_CLASS, deletion_days_field, field_label,
|
||||
internal_note_field, radio_group, reason_select,
|
||||
},
|
||||
form::{
|
||||
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
form_field_group, opt_out_checkbox, select_chevron, submit_button, text_input,
|
||||
textarea_input,
|
||||
checkbox, csrf_input, danger_button, form_actions, form_field_group, submit_button,
|
||||
text_input, textarea_input,
|
||||
},
|
||||
page_container::page_header,
|
||||
section_card::section_card_simple,
|
||||
@@ -28,12 +32,7 @@ fn guild_feature_label(feature: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_actions_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
.admin_user
|
||||
@@ -54,7 +53,7 @@ pub fn bulk_actions_page(
|
||||
(bulk_add_guild_members_section(base, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
|
||||
(bulk_schedule_deletion_section(base, csrf_token, username_sign_in))
|
||||
(bulk_close_accounts_section(base, config.self_hosted, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
|
||||
(bulk_delete_user_messages_section(base, csrf_token))
|
||||
@@ -187,53 +186,53 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup {
|
||||
fn bulk_decision_fields(
|
||||
id_prefix: &str,
|
||||
kind: EnforcementKindType,
|
||||
statement_help: &str,
|
||||
) -> Markup {
|
||||
let statement_id = format!("{id_prefix}-statement");
|
||||
html! {
|
||||
fieldset class="space-y-4" data-show-for-basis="enforcement" hidden disabled {
|
||||
(reason_select(id_prefix, kind, false))
|
||||
(radio_group("Automated tools", "automation", id_prefix, &[
|
||||
("none", "Not involved"),
|
||||
("flagged", "Flagged this for review"),
|
||||
]))
|
||||
div class="space-y-1" {
|
||||
(field_label(&statement_id, "Note to the user"))
|
||||
textarea id=(statement_id) name="statement" rows="2" maxlength="500" required
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { (statement_help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_close_accounts_section(base: &str, self_hosted: bool, csrf_token: &str) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Schedule User Deletion",
|
||||
"Close accounts",
|
||||
html! {
|
||||
form method="post" action={(base) "/bulk-actions?action=bulk-schedule-user-deletion"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(form_field_group("Deletion Reason", "reason_code", true, None,
|
||||
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id="reason_code" name="reason_code" required
|
||||
class=(FORM_SELECT_CLASS) {
|
||||
option value="" selected { "Select a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
(radio_group("Basis", "basis", "bulk-close", &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holders asked"),
|
||||
("housekeeping", "Inactivity"),
|
||||
]))
|
||||
(deletion_days_field("bulk-close-days", self_hosted))
|
||||
(bulk_decision_fields(
|
||||
"bulk-close",
|
||||
EnforcementKindType::Closure,
|
||||
"Shown to every account in the job. It is the only fact a bulk closure can give, because messages and reports cannot be attached in bulk. Never name or describe other people.",
|
||||
))
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Users that already have a pending deletion are skipped and listed as failed with the reason already scheduled. Cancel those from the user page first to schedule them again."
|
||||
(internal_note_field("bulk-close", "Staff only. Saved as the first note on each decision and in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Accounts that already have a pending deletion are skipped and listed as failed. Each account closed for a broken rule gets its own decision and notice."
|
||||
}
|
||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
|
||||
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
|
||||
html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="60" min="14" max="365" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
@if username_sign_in {
|
||||
input type="hidden" name="notify_user_present" value="1";
|
||||
} @else {
|
||||
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
|
||||
}
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
}))
|
||||
(form_actions(danger_button("Close accounts")))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -242,19 +241,30 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in
|
||||
|
||||
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Delete User Messages",
|
||||
"Delete all messages",
|
||||
html! {
|
||||
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes every message authored by each user across all channels. This cannot be undone."
|
||||
"Deletes every message each account wrote, in every channel. This cannot be undone."
|
||||
}
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete All Messages"))
|
||||
}))
|
||||
(radio_group("Basis", "basis", "bulk-delete-messages", &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holders asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
]))
|
||||
(bulk_decision_fields(
|
||||
"bulk-delete-messages",
|
||||
EnforcementKindType::ContentRemoval,
|
||||
"Shown to every account in the job. It is the only fact a bulk removal can give, because messages and reports cannot be attached in bulk. Never name or describe other people.",
|
||||
))
|
||||
(internal_note_field("bulk-delete-messages", "Staff only. Saved as the first note on each decision and in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Each account whose messages are removed for a broken rule gets its own decision and notice."
|
||||
}
|
||||
(form_actions(danger_button("Delete all messages")))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
admin_flags::{self, EnforcementHandling},
|
||||
api::{
|
||||
client::ApiResult,
|
||||
generated::types::{
|
||||
EnforcementDecisionListResponse, EnforcementDecisionListResponseDecisionsItem,
|
||||
EnforcementDecisionResponse, EnforcementDecisionResponseEvidenceItem,
|
||||
EnforcementDecisionResponseNotice, EnforcementKindType,
|
||||
},
|
||||
types::AdminUser,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{
|
||||
FIELD_HELP_CLASS, FIELD_INPUT_CLASS, FIELD_LABEL_CLASS, field_label,
|
||||
internal_note_field, statement_field,
|
||||
},
|
||||
form::{csrf_input, form_actions, submit_button},
|
||||
page_container::{page_header, page_header_with_back},
|
||||
section_card::section_card_simple,
|
||||
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
|
||||
user_display::{SYSTEM_USER_ID, user_link},
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const CORRECTION_LINE: &str = "If this decision was reported to an authority, file a correction.";
|
||||
const DECISIONS_PAGE_KEY: &str = "decisions";
|
||||
const PERMANENT_SUSPENSION_THRESHOLD_YEARS: i32 = 50;
|
||||
|
||||
pub fn kind_label(kind: EnforcementKindType) -> &'static str {
|
||||
match kind {
|
||||
EnforcementKindType::Warning => "Warning",
|
||||
EnforcementKindType::Suspension => "Suspension",
|
||||
EnforcementKindType::Closure => "Closure",
|
||||
EnforcementKindType::FeatureLimit => "Feature limit",
|
||||
EnforcementKindType::ContentRemoval => "Content removal",
|
||||
EnforcementKindType::ProfileReset => "Profile reset",
|
||||
EnforcementKindType::CommunityRemoval => "Community removal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_label(
|
||||
status: &str,
|
||||
end_outcome: Option<&str>,
|
||||
replaced: bool,
|
||||
expired: bool,
|
||||
) -> &'static str {
|
||||
match (status, end_outcome) {
|
||||
("ended", Some("lifted")) => "Lifted",
|
||||
("ended", Some("reversed")) => "Reversed",
|
||||
("ended", Some("expired")) => "Expired",
|
||||
("ended", _) => "Ended",
|
||||
_ if replaced => "Replaced",
|
||||
_ if expired => "Expired",
|
||||
_ => "Active",
|
||||
}
|
||||
}
|
||||
|
||||
fn source_label(source: &str) -> &'static str {
|
||||
match source {
|
||||
"report" => "A report",
|
||||
_ => "Our own review",
|
||||
}
|
||||
}
|
||||
|
||||
fn automation_label(automation: &str) -> &'static str {
|
||||
match automation {
|
||||
"flagged" => "Flagged this for review",
|
||||
"automated" => "Decided by automated tools, waiting for a person",
|
||||
_ => "Not involved",
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_status_label(status: &str) -> &'static str {
|
||||
match status {
|
||||
"sent" => "Sent",
|
||||
"undelivered" => "Not delivered. The user sees it when they sign in",
|
||||
_ => "Pending",
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_channel_label(channel: &str) -> &'static str {
|
||||
match channel {
|
||||
"email" => "Email",
|
||||
"system_dm" => "System DM",
|
||||
_ => "Sign-in screen",
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_label(limit: &str) -> &'static str {
|
||||
match limit {
|
||||
"direct_messages" => "Direct messages",
|
||||
"reports" => "Reports",
|
||||
_ => "Purchases",
|
||||
}
|
||||
}
|
||||
|
||||
fn end_acl(kind: EnforcementKindType, removed_all_messages: bool) -> &'static str {
|
||||
match kind {
|
||||
EnforcementKindType::Suspension => acl::USER_TEMP_BAN,
|
||||
EnforcementKindType::Closure => acl::USER_DELETE,
|
||||
EnforcementKindType::Warning | EnforcementKindType::FeatureLimit => acl::USER_UPDATE_FLAGS,
|
||||
EnforcementKindType::ContentRemoval if removed_all_messages => acl::MESSAGE_DELETE_ALL,
|
||||
EnforcementKindType::ContentRemoval => acl::MESSAGE_SHRED,
|
||||
EnforcementKindType::ProfileReset => acl::USER_UPDATE_PROFILE,
|
||||
EnforcementKindType::CommunityRemoval => acl::GUILD_DELETE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_end(
|
||||
admin_acls: &[String],
|
||||
kind: EnforcementKindType,
|
||||
removed_all_messages: bool,
|
||||
) -> bool {
|
||||
acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW)
|
||||
&& acl::has_permission(admin_acls, end_acl(kind, removed_all_messages))
|
||||
}
|
||||
|
||||
pub fn can_lift(kind: EnforcementKindType) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
EnforcementKindType::Suspension
|
||||
| EnforcementKindType::Closure
|
||||
| EnforcementKindType::FeatureLimit
|
||||
)
|
||||
}
|
||||
|
||||
fn is_restricted(reason_code: i32) -> bool {
|
||||
admin_flags::enforcement_reason(reason_code)
|
||||
.is_some_and(|reason| reason.handling == EnforcementHandling::Restricted)
|
||||
}
|
||||
|
||||
pub fn ends_label(
|
||||
kind: EnforcementKindType,
|
||||
ends_at: Option<&str>,
|
||||
deletion_at: Option<&str>,
|
||||
) -> String {
|
||||
match (kind, ends_at, deletion_at) {
|
||||
(EnforcementKindType::Closure, _, Some(at)) => {
|
||||
format!("Deleted on {}", format_admin_timestamp(at))
|
||||
}
|
||||
(_, Some(at), _) => format!("Until {}", format_admin_timestamp(at)),
|
||||
(EnforcementKindType::Suspension, None, _) => "Permanent".to_owned(),
|
||||
(EnforcementKindType::FeatureLimit, None, _) => "No end date".to_owned(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suspension_label(until: &str) -> String {
|
||||
let permanent =
|
||||
time::OffsetDateTime::parse(until, &time::format_description::well_known::Rfc3339)
|
||||
.is_ok_and(|until| {
|
||||
until.year() - time::OffsetDateTime::now_utc().year()
|
||||
>= PERMANENT_SUSPENSION_THRESHOLD_YEARS
|
||||
});
|
||||
if permanent {
|
||||
"Banned permanently".to_owned()
|
||||
} else {
|
||||
format!("Suspended until {}", format_admin_timestamp(until))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EndDecisionForm<'a> {
|
||||
pub base: &'a str,
|
||||
pub decision_id: &'a str,
|
||||
pub can_lift: bool,
|
||||
pub reason_code: i32,
|
||||
pub csrf_token: &'a str,
|
||||
pub refresh_target: &'a str,
|
||||
}
|
||||
|
||||
pub fn end_decision_form(form: &EndDecisionForm<'_>) -> Markup {
|
||||
let prefix = format!("end-{}", form.decision_id);
|
||||
let restricted = is_restricted(form.reason_code);
|
||||
let lifted_id = format!("{prefix}-outcome-lifted");
|
||||
let reversed_id = format!("{prefix}-outcome-reversed");
|
||||
html! {
|
||||
form method="post"
|
||||
action={(form.base) "/decisions/" (form.decision_id) "?action=end"}
|
||||
data-admin-refresh-on-success=(form.refresh_target)
|
||||
data-end-decision-form="" {
|
||||
(csrf_input(form.csrf_token))
|
||||
div class="space-y-4" {
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Outcome" }
|
||||
@if form.can_lift {
|
||||
div class="flex items-center gap-2" {
|
||||
input type="radio" id=(lifted_id) name="outcome" value="lifted" required
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(lifted_id) class="text-sm text-neutral-900" { "Lifted" }
|
||||
}
|
||||
}
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
input type="radio" id=(reversed_id) name="outcome" value="reversed" required
|
||||
class="peer h-4 w-4 accent-neutral-900";
|
||||
label for=(reversed_id) class="text-sm text-neutral-900" { "Reversed" }
|
||||
@if restricted {
|
||||
p class="hidden w-full text-sm text-amber-800 peer-checked:block" data-correction-line="" {
|
||||
(CORRECTION_LINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
@if form.can_lift { "Lifted ends a correct decision early. " }
|
||||
"Reversed says the decision was wrong."
|
||||
}
|
||||
}
|
||||
(statement_field(&prefix, "Shown to the user in the email. Never name or describe other people."))
|
||||
(internal_note_field(&prefix, "Staff only. Saved in the audit log."))
|
||||
(form_actions(submit_button("End decision")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn field(label: &str, value: Markup) -> Markup {
|
||||
html! {
|
||||
div class="flex min-w-0 flex-col gap-1" {
|
||||
dt class="text-xs text-neutral-500" { (label) }
|
||||
dd class="break-words text-sm text-neutral-900" { (value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subject_link(base: &str, decision: &EnforcementDecisionResponse, users: &[AdminUser]) -> Markup {
|
||||
let id = decision.subject_id.as_str();
|
||||
if decision.subject_type.to_string() == "guild" {
|
||||
return html! {
|
||||
a href={(base) "/guilds/" (id)} class="text-blue-600 hover:underline" {
|
||||
(decision.subject_name.as_deref().unwrap_or(id))
|
||||
}
|
||||
};
|
||||
}
|
||||
user_link(base, users, id)
|
||||
}
|
||||
|
||||
fn summary_section(
|
||||
base: &str,
|
||||
decision: &EnforcementDecisionResponse,
|
||||
users: &[AdminUser],
|
||||
) -> Markup {
|
||||
let ends = ends_label(
|
||||
decision.kind,
|
||||
decision.ends_at.as_deref(),
|
||||
decision.deletion_at.as_deref(),
|
||||
);
|
||||
let end_outcome = decision.end_outcome.map(|outcome| outcome.to_string());
|
||||
html! {
|
||||
(section_card_simple("Decision", html! {
|
||||
dl class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(field("Status", html! {
|
||||
(state_label(&decision.status.to_string(), end_outcome.as_deref(), decision.replaced, decision.expired))
|
||||
}))
|
||||
(field("Kind", html! { (kind_label(decision.kind)) }))
|
||||
(field("Subject", subject_link(base, decision, users)))
|
||||
@if let Some(recipient) = decision.recipient_user_id.as_deref().filter(|id| *id != decision.subject_id.as_str()) {
|
||||
(field("Notice goes to", user_link(base, users, recipient)))
|
||||
}
|
||||
(field("Reason", html! {
|
||||
(admin_flags::deletion_reason_label(*decision.reason_code))
|
||||
@if let Some(rule) = &decision.notice.rule {
|
||||
" ("
|
||||
@if let Some(url) = &rule.url {
|
||||
a href=(url) target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline" { (rule.title) }
|
||||
} @else {
|
||||
(rule.title)
|
||||
}
|
||||
")"
|
||||
}
|
||||
}))
|
||||
(field("Decided", html! { (format_admin_timestamp(&decision.created_at)) }))
|
||||
(field("Decided by", user_link(base, users, &decision.decided_by)))
|
||||
@if !ends.is_empty() {
|
||||
(field("Ends", html! { (ends) }))
|
||||
}
|
||||
(field("How we found this", html! { (source_label(&decision.source.to_string())) }))
|
||||
(field("Automated tools", html! { (automation_label(&decision.automation.to_string())) }))
|
||||
@if let Some(limit) = decision.limit {
|
||||
(field("Limit", html! { (limit_label(&limit.to_string())) }))
|
||||
}
|
||||
@if !decision.profile_fields.is_empty() {
|
||||
(field("Profile fields", html! { (decision.profile_fields.join(", ")) }))
|
||||
}
|
||||
@if decision.removed_all_messages {
|
||||
(field("Removed messages", html! { "All messages" }))
|
||||
} @else if let Some(count) = &decision.removed_message_count {
|
||||
(field("Removed messages", html! { (**count) }))
|
||||
}
|
||||
@if let Some(at) = &decision.payment_at {
|
||||
(field("Charge date", html! { (format_admin_timestamp(at)) }))
|
||||
}
|
||||
@if let Some(rights_holder) = &decision.rights_holder {
|
||||
(field("Rights holder or right concerned", html! { (rights_holder) }))
|
||||
}
|
||||
@if let Some(statement) = &decision.statement {
|
||||
(field("Note to the user", html! { span class="whitespace-pre-wrap" { (statement) } }))
|
||||
}
|
||||
@if let Some(at) = &decision.ended_at {
|
||||
(field("Ended", html! {
|
||||
(format_admin_timestamp(at))
|
||||
@if let Some(by) = &decision.ended_by {
|
||||
" by " (user_link(base, users, by))
|
||||
}
|
||||
}))
|
||||
}
|
||||
@if let Some(statement) = &decision.end_statement {
|
||||
(field("Note to the user on ending", html! { span class="whitespace-pre-wrap" { (statement) } }))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn text_list(items: &[String]) -> Markup {
|
||||
html! {
|
||||
@if !items.is_empty() {
|
||||
ul class="list-disc space-y-1 pl-5" {
|
||||
@for item in items { li { (item) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_notice(notice: &EnforcementDecisionResponseNotice) -> Markup {
|
||||
html! {
|
||||
div class="space-y-3 rounded-lg border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-800" data-current-notice="" {
|
||||
p class="font-medium" { (notice.summary) }
|
||||
(text_list(¬ice.effects))
|
||||
@if let Some(rule) = ¬ice.rule {
|
||||
p { "Rule: " (rule.title) }
|
||||
}
|
||||
(text_list(¬ice.facts))
|
||||
@if let Some(statement) = ¬ice.statement {
|
||||
p class="whitespace-pre-wrap" { (statement) }
|
||||
}
|
||||
(text_list(¬ice.how_we_decided))
|
||||
@if let Some(appeal) = ¬ice.appeal {
|
||||
p {
|
||||
"Appeals: " (appeal.email)
|
||||
@if let Some(deadline) = &appeal.deadline {
|
||||
" by " (deadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
(text_list(¬ice.redress))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_section(decision: &EnforcementDecisionResponse) -> Markup {
|
||||
let channels = decision
|
||||
.notice_channels
|
||||
.iter()
|
||||
.map(|channel| notice_channel_label(&channel.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
html! {
|
||||
(section_card_simple("Notice", html! {
|
||||
div class="space-y-4" {
|
||||
dl class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(field("Status", html! { (notice_status_label(&decision.notice_status.to_string())) }))
|
||||
(field("Channels", html! {
|
||||
@if channels.is_empty() { "None" } @else { (channels.join(", ")) }
|
||||
}))
|
||||
@if let Some(locale) = &decision.notice_locale {
|
||||
(field("Locale", html! { (locale) }))
|
||||
}
|
||||
@if let Some(at) = &decision.notice_sent_at {
|
||||
(field("Received", html! { (format_admin_timestamp(at)) }))
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
p class=(FIELD_LABEL_CLASS) { "Text as sent" }
|
||||
@if let Some(text) = &decision.notice_text {
|
||||
pre class="whitespace-pre-wrap break-words rounded-lg bg-neutral-50 p-4 font-sans text-sm text-neutral-800" data-notice-text="" { (text) }
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" { "Nothing was sent yet." }
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
p class=(FIELD_LABEL_CLASS) { "What the user sees now" }
|
||||
(current_notice(&decision.notice))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_row(
|
||||
base: &str,
|
||||
item: &EnforcementDecisionResponseEvidenceItem,
|
||||
restricted: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
li class="space-y-1 py-3" data-evidence-message=(item.message_id.as_str()) {
|
||||
div class="flex flex-wrap items-baseline gap-x-3 text-xs text-neutral-500" {
|
||||
span { (format_admin_timestamp(&item.timestamp)) }
|
||||
span { "Message " (item.message_id.as_str()) }
|
||||
@if let Some(channel_id) = &item.channel_id {
|
||||
a href={(base) "/messages?channel_id=" (channel_id.as_str()) "&message_id=" (item.message_id.as_str()) "&context_limit=50"}
|
||||
class="text-blue-600 hover:underline" { "Open in message tools" }
|
||||
}
|
||||
}
|
||||
@if let Some(content) = &item.content {
|
||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (content) }
|
||||
} @else if restricted {
|
||||
p class="text-sm text-neutral-500" { "Content is not stored for this reason." }
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" { "Content is hidden. Needs the message:lookup permission." }
|
||||
}
|
||||
@if let Some(attachments) = &item.attachments {
|
||||
@for attachment in attachments {
|
||||
p class="text-xs text-neutral-600" { "Attachment: " (attachment.filename) }
|
||||
}
|
||||
} @else if *item.attachment_count > 0 {
|
||||
p class="text-xs text-neutral-600" { "Attachments: " (*item.attachment_count) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_section(base: &str, decision: &EnforcementDecisionResponse) -> Markup {
|
||||
let restricted = is_restricted(*decision.reason_code);
|
||||
html! {
|
||||
(section_card_simple("Evidence", html! {
|
||||
@if decision.evidence.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No messages were recorded." }
|
||||
} @else {
|
||||
ul class="divide-y divide-neutral-100" {
|
||||
@for item in &decision.evidence {
|
||||
(evidence_row(base, item, restricted))
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Some(values) = &decision.profile_values {
|
||||
@if !values.is_empty() {
|
||||
dl class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
@for (name, value) in values {
|
||||
(field(&format!("Previous {name}"), html! { (value) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn reports_section(base: &str, decision: &EnforcementDecisionResponse) -> Markup {
|
||||
html! {
|
||||
@if !decision.report_ids.is_empty() {
|
||||
(section_card_simple("Reports", html! {
|
||||
ul class="space-y-1 text-sm" {
|
||||
@for report_id in &decision.report_ids {
|
||||
li {
|
||||
a href={(base) "/reports/" (report_id.as_str())} class="text-blue-600 hover:underline" {
|
||||
(report_id.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn network_blocks_section(decision: &EnforcementDecisionResponse) -> Markup {
|
||||
html! {
|
||||
@if !decision.network_blocks.is_empty() {
|
||||
(section_card_simple("Network blocks", html! {
|
||||
ul class="space-y-1 text-sm text-neutral-900" {
|
||||
@for block in &decision.network_blocks {
|
||||
li {
|
||||
span class="font-mono" { (block.ip) }
|
||||
@if let Some(expires_at) = &block.expires_at {
|
||||
span class="text-neutral-500" { ", until " (format_admin_timestamp(expires_at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p class="mt-2 text-xs text-neutral-500" {
|
||||
"Ending the decision removes these blocks. Deleting the account removes them too."
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notes_section(
|
||||
base: &str,
|
||||
decision: &EnforcementDecisionResponse,
|
||||
users: &[AdminUser],
|
||||
can_annotate: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let notes: Vec<StaffNote> = decision
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| StaffNote {
|
||||
note_id: note.note_id.as_str(),
|
||||
author: user_link(base, users, ¬e.author_id),
|
||||
body: ¬e.body,
|
||||
created_at: ¬e.created_at,
|
||||
})
|
||||
.collect();
|
||||
staff_notes_section(
|
||||
¬es,
|
||||
can_annotate.then(|| StaffNoteForm {
|
||||
action: format!(
|
||||
"{base}/decisions/{}?action=add_note",
|
||||
decision.decision_id.as_str()
|
||||
),
|
||||
refresh_target: "#decision-detail",
|
||||
field_id: format!("note-{}", decision.decision_id.as_str()),
|
||||
help: "Staff only. Notes cannot be changed and expire after a year.",
|
||||
csrf_token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
|
||||
let mut ids: Vec<String> = [
|
||||
Some(&decision.decided_by),
|
||||
decision.recipient_user_id.as_ref(),
|
||||
decision.ended_by.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(decision.notes.iter().map(|note| ¬e.author_id))
|
||||
.map(|id| id.as_str().to_owned())
|
||||
.collect();
|
||||
if decision.subject_type.to_string() == "user" {
|
||||
ids.push(decision.subject_id.as_str().to_owned());
|
||||
}
|
||||
ids.retain(|id| id != SYSTEM_USER_ID);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn decision_detail(
|
||||
config: &AdminConfig,
|
||||
admin_acls: &[String],
|
||||
decision: &EnforcementDecisionResponse,
|
||||
users: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let decision_id = decision.decision_id.as_str();
|
||||
let expired_end = decision
|
||||
.end_outcome
|
||||
.map(|outcome| outcome.to_string())
|
||||
.as_deref()
|
||||
== Some("expired");
|
||||
let open = (decision.status.to_string() == "active" && !decision.replaced) || expired_end;
|
||||
html! {
|
||||
div id="decision-detail" class="space-y-6" {
|
||||
(summary_section(base, decision, users))
|
||||
@if open
|
||||
&& can_end(admin_acls, decision.kind, decision.removed_all_messages)
|
||||
&& (decision.network_blocks.is_empty() || acl::has_permission(admin_acls, acl::BAN_IP_REMOVE))
|
||||
{
|
||||
(section_card_simple("End decision", end_decision_form(&EndDecisionForm {
|
||||
base,
|
||||
decision_id,
|
||||
can_lift: can_lift(decision.kind) && !expired_end,
|
||||
reason_code: *decision.reason_code,
|
||||
csrf_token,
|
||||
refresh_target: "#decision-detail",
|
||||
})))
|
||||
}
|
||||
(notice_section(decision))
|
||||
(evidence_section(base, decision))
|
||||
(reports_section(base, decision))
|
||||
(network_blocks_section(decision))
|
||||
(notes_section(
|
||||
base,
|
||||
decision,
|
||||
users,
|
||||
acl::has_permission(admin_acls, acl::ENFORCEMENT_ANNOTATE),
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforcement_decision_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
decision_id: &str,
|
||||
decision: Option<&EnforcementDecisionResponse>,
|
||||
users: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let title = format!("Decision {decision_id}");
|
||||
let admin_acls = auth
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let content = html! {
|
||||
(page_header_with_back(&title, None, &format!("{base}/decisions"), Some("Back to decisions")))
|
||||
@match decision {
|
||||
Some(decision) => { (decision_detail(config, admin_acls, decision, users, csrf_token)) }
|
||||
None => {
|
||||
(section_card_simple("Decision", html! {
|
||||
p class="text-sm text-neutral-500" { "This decision was not found or could not be loaded." }
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
if is_htmx {
|
||||
content
|
||||
} else {
|
||||
admin_layout(config, auth, &title, DECISIONS_PAGE_KEY, None, content)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decisions_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
lookup_error: Option<&str>,
|
||||
review_queue: Option<&ApiResult<EnforcementDecisionListResponse>>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header("Decisions", Some("Look up a decision by its ID, or work through the review queue.")))
|
||||
(section_card_simple("Find a decision", html! {
|
||||
form method="get" action={(base) "/decisions"} class="space-y-3" {
|
||||
div class="space-y-1" {
|
||||
(field_label("decision-lookup", "Decision ID"))
|
||||
input type="text" id="decision-lookup" name="id" required inputmode="numeric"
|
||||
class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { "The ID from the notice or the appeal email." }
|
||||
@if let Some(error) = lookup_error {
|
||||
p class="text-sm text-red-700" { (error) }
|
||||
}
|
||||
}
|
||||
(form_actions(submit_button("Open decision")))
|
||||
}
|
||||
}))
|
||||
@if let Some(queue) = review_queue {
|
||||
(section_card_simple("Review queue", html! {
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Automated limits waiting for a person, such as purchase limits after a chargeback. End the limit, or record a warning, suspension or closure on the account, to take it out of the queue."
|
||||
}
|
||||
@match queue {
|
||||
Ok(response) => { (review_queue_table(base, &response.decisions)) }
|
||||
Err(_) => { p class="text-sm text-red-700" { "Failed to load the review queue." } }
|
||||
}
|
||||
}))
|
||||
}
|
||||
};
|
||||
admin_layout(config, auth, "Decisions", DECISIONS_PAGE_KEY, None, content)
|
||||
}
|
||||
|
||||
fn review_queue_table(
|
||||
base: &str,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
html! {
|
||||
@if decisions.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "Nothing is waiting for review." }
|
||||
} @else {
|
||||
div class="overflow-x-auto" {
|
||||
table class="min-w-full divide-y divide-neutral-200 text-sm" data-review-queue="" {
|
||||
thead {
|
||||
tr class="text-left text-xs text-neutral-500" {
|
||||
@for header in ["ID", "Account", "Reason", "Payment", "Date"] {
|
||||
th class="px-3 py-2 font-medium" { (header) }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-neutral-100" {
|
||||
@for decision in decisions {
|
||||
tr {
|
||||
td class="px-3 py-2" {
|
||||
a href={(base) "/decisions/" (decision.decision_id.as_str())} class="text-blue-600 hover:underline" {
|
||||
(decision.decision_id.as_str())
|
||||
}
|
||||
}
|
||||
td class="px-3 py-2" {
|
||||
a href={(base) "/users/" (decision.subject_id.as_str()) "?tab=moderation"} class="text-blue-600 hover:underline" {
|
||||
(decision.subject_id.as_str())
|
||||
}
|
||||
}
|
||||
td class="px-3 py-2" { (admin_flags::deletion_reason_label(*decision.reason_code)) }
|
||||
td class="whitespace-nowrap px-3 py-2" {
|
||||
(decision.payment_at.as_deref().map(format_admin_timestamp).unwrap_or_default())
|
||||
}
|
||||
td class="whitespace-nowrap px-3 py-2" { (format_admin_timestamp(&decision.created_at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decisions_table(
|
||||
base: &str,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
html! {
|
||||
@if decisions.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No decisions." }
|
||||
} @else {
|
||||
div class="overflow-x-auto" {
|
||||
table class="min-w-full divide-y divide-neutral-200 text-sm" data-decisions-table="" {
|
||||
thead {
|
||||
tr class="text-left text-xs text-neutral-500" {
|
||||
@for header in ["ID", "Kind", "Reason", "Status", "Date"] {
|
||||
th class="px-3 py-2 font-medium" { (header) }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-neutral-100" {
|
||||
@for decision in decisions {
|
||||
@let end_outcome = decision.end_outcome.map(|outcome| outcome.to_string());
|
||||
tr {
|
||||
td class="px-3 py-2" {
|
||||
a href={(base) "/decisions/" (decision.decision_id.as_str())} class="text-blue-600 hover:underline" {
|
||||
(decision.decision_id.as_str())
|
||||
}
|
||||
}
|
||||
td class="px-3 py-2" { (kind_label(decision.kind)) }
|
||||
td class="px-3 py-2" { (admin_flags::deletion_reason_label(*decision.reason_code)) }
|
||||
td class="px-3 py-2" {
|
||||
(state_label(&decision.status.to_string(), end_outcome.as_deref(), decision.replaced, decision.expired))
|
||||
}
|
||||
td class="whitespace-nowrap px-3 py-2" { (format_admin_timestamp(&decision.created_at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::GuildInfo,
|
||||
api::{generated::types::EnforcementKindType, types::GuildInfo},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
enforcement_fields::{BasisFields, DecisionFields, basis_fields},
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
@@ -144,17 +145,32 @@ pub fn moderation_tab(
|
||||
}
|
||||
|
||||
@if can_delete {
|
||||
(card_with_header("Delete Guild", html! {
|
||||
p class="mb-3 text-sm text-neutral-500" {
|
||||
"Deleting a guild permanently removes it and all associated data. \
|
||||
This action cannot be undone."
|
||||
}
|
||||
(card_with_header("Remove community", html! {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild.id) "?action=delete_guild&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete Guild"))
|
||||
}))
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Removes the community with its channels, messages and settings. It can't be undone. Child safety and violent extremism reasons can't be used, because the removal deletes the content."
|
||||
}
|
||||
(basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "remove-community",
|
||||
kind: EnforcementKindType::CommunityRemoval,
|
||||
report_ids: &[],
|
||||
messages: Some(&[]),
|
||||
user_flags: None,
|
||||
allow_restricted: false,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The owner asked"),
|
||||
],
|
||||
plain_note_help: "Staff only. Saved in the audit log.",
|
||||
enforcement_extra: html! {},
|
||||
}))
|
||||
(form_actions(danger_button("Remove community")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
use crate::{
|
||||
api::types::{
|
||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||
BRANDING_ASSET_ACCEPT, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind,
|
||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PremiumMode, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, TagStyle, VOICE_P2P_DEFAULT_SALT,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -30,7 +31,7 @@ use crate::{
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
fn format_percent(value: f64) -> String {
|
||||
if value.fract() == 0.0 {
|
||||
@@ -956,36 +957,9 @@ fn app_public_config_section(
|
||||
))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"app_icon_url",
|
||||
"Icon URL",
|
||||
app_public.branding.icon_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/icon.png",
|
||||
))
|
||||
(text_input(
|
||||
"app_symbol_url",
|
||||
"Symbol URL",
|
||||
app_public.branding.symbol_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/symbol.png",
|
||||
))
|
||||
(text_input(
|
||||
"app_logo_url",
|
||||
"Logo URL",
|
||||
app_public.branding.logo_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/logo.png",
|
||||
))
|
||||
(text_input(
|
||||
"app_wordmark_url",
|
||||
"Wordmark URL",
|
||||
app_public.branding.wordmark_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/wordmark.png",
|
||||
))
|
||||
(text_input(
|
||||
"app_favicon_url",
|
||||
"Favicon URL",
|
||||
app_public.branding.favicon_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/favicon.ico",
|
||||
))
|
||||
@for kind in BrandingAssetKind::ALL {
|
||||
(branding_asset_field(kind, kind.url(&app_public.branding)))
|
||||
}
|
||||
(text_input(
|
||||
"app_status_page_url",
|
||||
"Status page URL",
|
||||
@@ -1014,6 +988,7 @@ fn app_public_config_section(
|
||||
}))
|
||||
}
|
||||
}
|
||||
script { (PreEscaped(BRANDING_ASSET_SCRIPT)) }
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Legal Documents" }
|
||||
form method="post" action={(base) "/instance-config?action=update_app_legal"} {
|
||||
@@ -1056,6 +1031,173 @@ fn app_public_config_section(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn branding_asset_field_id(kind: BrandingAssetKind) -> String {
|
||||
format!("branding-asset-{}", kind.as_str())
|
||||
}
|
||||
|
||||
pub fn branding_asset_field(kind: BrandingAssetKind, url: Option<&str>) -> Markup {
|
||||
let name = format!("app_{}_url", kind.as_str());
|
||||
let placeholder = match kind {
|
||||
BrandingAssetKind::Favicon => "https://example.com/favicon.ico".to_owned(),
|
||||
_ => format!("https://example.com/{}.png", kind.as_str()),
|
||||
};
|
||||
html! {
|
||||
div id=(branding_asset_field_id(kind)) class="flex flex-col gap-2"
|
||||
data-branding-asset=(kind.as_str())
|
||||
data-branding-asset-max-chars=(BRANDING_ASSET_MAX_IMAGE_CHARS) {
|
||||
(text_input(&name, &format!("{} URL", kind.label()), url.unwrap_or(""), &placeholder))
|
||||
div class="flex items-center gap-2" {
|
||||
@if let Some(url) = url {
|
||||
img src=(url) alt={(kind.label()) " preview"}
|
||||
onerror="this.hidden=true"
|
||||
class="h-8 w-8 shrink-0 rounded-md border border-neutral-200 bg-neutral-50 object-contain";
|
||||
}
|
||||
input type="file" accept=(BRANDING_ASSET_ACCEPT) class="hidden"
|
||||
data-branding-asset-file
|
||||
aria-label={"Upload " (kind.as_str())};
|
||||
button type="button" data-branding-asset-upload
|
||||
class=(BRANDING_ASSET_BUTTON_CLASS) {
|
||||
"Upload"
|
||||
}
|
||||
@if url.is_some() {
|
||||
button type="button" data-branding-asset-remove
|
||||
class=(BRANDING_ASSET_BUTTON_CLASS) {
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BRANDING_ASSET_BUTTON_CLASS: &str = "inline-flex h-8 shrink-0 items-center justify-center \
|
||||
rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium \
|
||||
text-neutral-700 transition-all hover:border-neutral-400 hover:text-neutral-900 \
|
||||
focus:outline-none focus:ring-2 focus:ring-brand-primary/20 \
|
||||
disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
const BRANDING_ASSET_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
if (window.__adminBrandingAssetBound) return;
|
||||
window.__adminBrandingAssetBound = true;
|
||||
function toast(level, message) {
|
||||
document.body.dispatchEvent(new CustomEvent('showFlash', {detail: {level: level, message: message}}));
|
||||
}
|
||||
function toastFromResponse(response) {
|
||||
var raw = response.headers.get('X-Fluxer-Admin-Toast');
|
||||
if (!raw) return null;
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
return {
|
||||
level: parsed && parsed.level ? String(parsed.level) : 'info',
|
||||
message: parsed && parsed.message ? String(parsed.message) : ''
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function fieldOf(element) {
|
||||
return element.closest('[data-branding-asset]');
|
||||
}
|
||||
function setBusy(field, busy) {
|
||||
field.setAttribute('aria-busy', busy ? 'true' : 'false');
|
||||
field.querySelectorAll('button').forEach(function (button) {
|
||||
button.disabled = busy;
|
||||
});
|
||||
}
|
||||
function send(field, action, image, failure) {
|
||||
var form = field.closest('form');
|
||||
var csrfInput = form ? form.querySelector('input[name="_csrf"]') : null;
|
||||
var token = csrfInput ? csrfInput.value || '' : '';
|
||||
var url = new URL(form ? form.getAttribute('action') || window.location.href : window.location.href, window.location.href);
|
||||
url.searchParams.set('action', action);
|
||||
var body = new URLSearchParams();
|
||||
body.set('_csrf', token);
|
||||
body.set('kind', field.getAttribute('data-branding-asset') || '');
|
||||
if (image !== null) body.set('image', image);
|
||||
setBusy(field, true);
|
||||
fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'HX-Request': 'true',
|
||||
'HX-Target': field.id,
|
||||
'X-CSRF-Token': token
|
||||
},
|
||||
body: body.toString()
|
||||
}).then(function (response) {
|
||||
var parsed = toastFromResponse(response);
|
||||
if (response.status !== 200) {
|
||||
setBusy(field, false);
|
||||
toast(parsed ? parsed.level : 'error', parsed ? parsed.message : failure);
|
||||
return;
|
||||
}
|
||||
return response.text().then(function (html) {
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = html.trim();
|
||||
var replacement = template.content.firstElementChild;
|
||||
if (replacement && replacement.id === field.id) {
|
||||
field.replaceWith(replacement);
|
||||
} else {
|
||||
setBusy(field, false);
|
||||
}
|
||||
if (parsed) toast(parsed.level, parsed.message);
|
||||
});
|
||||
}).catch(function () {
|
||||
setBusy(field, false);
|
||||
toast('error', failure);
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
var upload = target.closest('[data-branding-asset-upload]');
|
||||
if (upload) {
|
||||
var uploadField = fieldOf(upload);
|
||||
var file = uploadField ? uploadField.querySelector('[data-branding-asset-file]') : null;
|
||||
if (file) file.click();
|
||||
return;
|
||||
}
|
||||
var remove = target.closest('[data-branding-asset-remove]');
|
||||
if (remove) {
|
||||
var removeField = fieldOf(remove);
|
||||
if (removeField) send(removeField, 'remove_branding_asset', null, 'Failed to remove the image');
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', function (event) {
|
||||
var input = event.target;
|
||||
if (!(input instanceof HTMLInputElement) || !input.hasAttribute('data-branding-asset-file')) return;
|
||||
var field = fieldOf(input);
|
||||
var selected = input.files && input.files[0];
|
||||
input.value = '';
|
||||
if (!field || !selected) return;
|
||||
var maxChars = Number(field.getAttribute('data-branding-asset-max-chars')) || 0;
|
||||
setBusy(field, true);
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var dataUrl = typeof reader.result === 'string' ? reader.result : '';
|
||||
if (!dataUrl) {
|
||||
setBusy(field, false);
|
||||
toast('error', 'Failed to read the image');
|
||||
return;
|
||||
}
|
||||
if (maxChars > 0 && dataUrl.length > maxChars) {
|
||||
setBusy(field, false);
|
||||
toast('error', 'This image is too large to upload');
|
||||
return;
|
||||
}
|
||||
send(field, 'upload_branding_asset', dataUrl, 'Failed to upload the image');
|
||||
};
|
||||
reader.onerror = function () {
|
||||
setBusy(field, false);
|
||||
toast('error', 'Failed to read the image');
|
||||
};
|
||||
reader.readAsDataURL(selected);
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
fn gateway_rollout_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -1640,7 +1782,7 @@ fn registration_config_section(
|
||||
let base = &config.base_path;
|
||||
section_card_with_description(
|
||||
"Registration Controls",
|
||||
"Control public registration, admin-issued registration URLs, and pending approval requests.",
|
||||
"Control public registration and admin-issued registration URLs.",
|
||||
html! {
|
||||
div class="space-y-8" {
|
||||
(registration_policy_form(base, csrf_token, registration))
|
||||
@@ -1649,7 +1791,6 @@ fn registration_config_section(
|
||||
}
|
||||
(create_registration_url_form(base, csrf_token))
|
||||
(registration_url_list(config, csrf_token, ®istration.urls))
|
||||
(pending_registration_list(config, csrf_token, ®istration.pending_registrations))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1872,110 +2013,7 @@ fn registration_url_row(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending_registration_list(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
pending_registrations: &[PendingRegistrationResponse],
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
div id="pending-registration-list" class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Pending Registrations" }
|
||||
@if pending_registrations.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No pending registrations." }
|
||||
} @else {
|
||||
div class="overflow-x-auto rounded-lg border border-neutral-200" {
|
||||
table class="min-w-[940px] divide-y divide-neutral-200 text-sm" {
|
||||
thead class="bg-neutral-50" {
|
||||
tr {
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Applicant" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Requested" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Registration URL" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "IP" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-neutral-200 bg-white" {
|
||||
@for pending in pending_registrations {
|
||||
(pending_registration_row(config, base, csrf_token, pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_registration_row(
|
||||
config: &AdminConfig,
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
pending: &PendingRegistrationResponse,
|
||||
) -> Markup {
|
||||
let account_name = pending_registration_account_name(pending);
|
||||
let requested_at = format_admin_timestamp(&pending.requested_at);
|
||||
let email = pending.email.as_deref().unwrap_or("None");
|
||||
let link_id = pending.registration_url_id.as_deref().unwrap_or("None");
|
||||
let client_ip = pending.client_ip.as_deref().unwrap_or("None");
|
||||
let approve_action = format!("{base}/instance-config?action=approve_pending_registration");
|
||||
let reject_action = format!("{base}/instance-config?action=reject_pending_registration");
|
||||
html! {
|
||||
tr {
|
||||
td class="max-w-[20rem] px-4 py-3 align-top" {
|
||||
p class="font-medium text-neutral-900" { (account_name) }
|
||||
@if let Some(global_name) = pending.global_name.as_deref() {
|
||||
p class="truncate text-xs text-neutral-500" title=(global_name) { (global_name) }
|
||||
}
|
||||
p class="truncate text-xs text-neutral-500" title=(email) { (email) }
|
||||
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (&pending.user_id) }
|
||||
}
|
||||
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
||||
(requested_at)
|
||||
}
|
||||
td class="px-4 py-3 align-top text-neutral-700" {
|
||||
@if let Some(registration_url_id) = pending.registration_url_id.as_deref() {
|
||||
@let full_url = admin_issued_registration_url(config, registration_url_id);
|
||||
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
|
||||
div class="mt-1" {
|
||||
(compact_copy_button(&full_url, "Copy URL"))
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-500" { (link_id) }
|
||||
}
|
||||
}
|
||||
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
||||
(client_ip)
|
||||
}
|
||||
td class="px-4 py-3 align-top" {
|
||||
div class="flex flex-nowrap gap-2" {
|
||||
form method="post" action=(&approve_action)
|
||||
hx-post=(&approve_action)
|
||||
hx-target="#pending-registration-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
data-admin-allow-swap="true" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="user_id" value=(&pending.user_id);
|
||||
(compact_button("Approve", false))
|
||||
}
|
||||
form method="post" action=(&reject_action)
|
||||
hx-post=(&reject_action)
|
||||
hx-target="#pending-registration-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
data-admin-allow-swap="true" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="user_id" value=(&pending.user_id);
|
||||
(compact_button("Reject", true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
|
||||
pub(super) fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
|
||||
format!(
|
||||
"{}/register?registration_url={}",
|
||||
config.web_app_endpoint.trim_end_matches('/'),
|
||||
@@ -1994,7 +2032,7 @@ fn copyable_registration_url(value: &str, label: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_copy_button(value: &str, label: &str) -> Markup {
|
||||
pub(super) fn compact_copy_button(value: &str, label: &str) -> Markup {
|
||||
html! {
|
||||
button type="button"
|
||||
class="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium text-neutral-700 transition-all hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
@@ -2005,7 +2043,7 @@ fn compact_copy_button(value: &str, label: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_button(label: &str, danger: bool) -> Markup {
|
||||
pub(super) fn compact_button(label: &str, danger: bool) -> Markup {
|
||||
let variant_class = if danger {
|
||||
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500/30"
|
||||
} else {
|
||||
@@ -2091,14 +2129,6 @@ fn registration_url_uses_text(url: &RegistrationUrlResponse) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
|
||||
if pending.discriminator == 0 {
|
||||
pending.username.clone()
|
||||
} else {
|
||||
format!("{}#{:04}", pending.username, pending.discriminator)
|
||||
}
|
||||
}
|
||||
|
||||
fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) -> Markup {
|
||||
let allowed_domains = sso.allowed_domains.join("\n");
|
||||
let secret_caption = if sso.client_secret_set {
|
||||
|
||||
@@ -5,13 +5,14 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{INTERNAL_NOTE_MAX_CHARS, TakeAction},
|
||||
error_display::error_alert,
|
||||
form::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
||||
submit_button,
|
||||
},
|
||||
message_data::{Message, ordered_messages, value_id},
|
||||
message_list::{message_deletion_script, message_list},
|
||||
message_list::{message_delete_prefill_script, message_list},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -193,6 +194,7 @@ pub struct MessagesPageParams<'a> {
|
||||
pub prefill_channel_id: Option<&'a str>,
|
||||
pub can_lookup: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_take_action: bool,
|
||||
pub lookup_result: Option<&'a Value>,
|
||||
pub browse_result: Option<&'a Value>,
|
||||
pub search_result: Option<&'a Value>,
|
||||
@@ -202,11 +204,32 @@ pub struct MessagesPageParams<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MessageActions {
|
||||
pub delete: bool,
|
||||
pub take_action: bool,
|
||||
}
|
||||
|
||||
impl MessageActions {
|
||||
fn take_action(self) -> Option<&'static TakeAction<'static>> {
|
||||
self.take_action.then_some(&ANY_AUTHOR)
|
||||
}
|
||||
}
|
||||
|
||||
const ANY_AUTHOR: TakeAction<'static> = TakeAction {
|
||||
author_id: None,
|
||||
report_id: None,
|
||||
};
|
||||
|
||||
pub fn messages_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
params: &MessagesPageParams<'_>,
|
||||
) -> Markup {
|
||||
let actions = MessageActions {
|
||||
delete: params.can_delete,
|
||||
take_action: params.can_take_action,
|
||||
};
|
||||
let content = html! {
|
||||
(page_header("Message Tools", None))
|
||||
|
||||
@@ -217,19 +240,19 @@ pub fn messages_page(
|
||||
@if let (Some(result), Some(channel_id), Some(query)) = (
|
||||
params.search_result, params.browse_channel_id, params.search_query_text,
|
||||
) {
|
||||
(search_result_card(config, result, channel_id, query, params.can_delete))
|
||||
(search_result_card(config, result, channel_id, query, actions))
|
||||
}
|
||||
@if params.search_result.is_none()
|
||||
&& let (Some(result), Some(channel_id)) = (
|
||||
params.browse_result, params.browse_channel_id,
|
||||
) {
|
||||
(browse_result_card(
|
||||
config, result, channel_id, params.can_delete,
|
||||
config, result, channel_id, actions,
|
||||
params.csrf_token, params.context_limit,
|
||||
))
|
||||
}
|
||||
@if let Some(result) = params.lookup_result {
|
||||
(lookup_result_card(config, result, params.can_delete, params.context_limit))
|
||||
(lookup_result_card(config, result, actions, params.context_limit))
|
||||
}
|
||||
@if params.can_lookup {
|
||||
(browse_channel_form(config, params.csrf_token, params.prefill_channel_id))
|
||||
@@ -238,8 +261,8 @@ pub fn messages_page(
|
||||
}
|
||||
@if params.can_delete {
|
||||
(delete_message_form(config, params.csrf_token))
|
||||
(message_delete_prefill_script())
|
||||
}
|
||||
(message_deletion_script(params.csrf_token))
|
||||
}
|
||||
};
|
||||
admin_layout_ext(
|
||||
@@ -260,7 +283,7 @@ pub fn messages_page(
|
||||
pub fn browse_messages_fragment(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
show_delete: bool,
|
||||
actions: MessageActions,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
@@ -280,8 +303,9 @@ pub fn browse_messages_fragment(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions.delete,
|
||||
highlight_message_id,
|
||||
actions.take_action(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -291,7 +315,7 @@ fn browse_result_card(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
channel_id: &str,
|
||||
show_delete: bool,
|
||||
actions: MessageActions,
|
||||
csrf_token: &str,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
@@ -328,7 +352,7 @@ fn browse_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more,
|
||||
has_newer: false,
|
||||
focus_message_id: None,
|
||||
@@ -343,7 +367,7 @@ fn search_result_card(
|
||||
result: &Value,
|
||||
channel_id: &str,
|
||||
query_text: &str,
|
||||
show_delete: bool,
|
||||
actions: MessageActions,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
let total = result
|
||||
@@ -369,8 +393,9 @@ fn search_result_card(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions.delete,
|
||||
None,
|
||||
actions.take_action(),
|
||||
))
|
||||
}
|
||||
})
|
||||
@@ -379,7 +404,7 @@ fn search_result_card(
|
||||
fn lookup_result_card(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
show_delete: bool,
|
||||
actions: MessageActions,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
@@ -401,7 +426,7 @@ fn lookup_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more: true,
|
||||
has_newer: true,
|
||||
focus_message_id: message_id,
|
||||
@@ -415,7 +440,7 @@ struct MessageScrollPane<'a> {
|
||||
config: &'a AdminConfig,
|
||||
channel_id: &'a str,
|
||||
messages: &'a [Message],
|
||||
show_delete: bool,
|
||||
actions: MessageActions,
|
||||
has_more: bool,
|
||||
has_newer: bool,
|
||||
focus_message_id: Option<&'a str>,
|
||||
@@ -454,8 +479,9 @@ fn message_scroll_pane(pane: MessageScrollPane<'_>) -> Markup {
|
||||
pane.config,
|
||||
&pane.config.base_path,
|
||||
pane.messages,
|
||||
pane.show_delete,
|
||||
pane.actions.delete,
|
||||
pane.focus_message_id,
|
||||
pane.actions.take_action(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -639,12 +665,13 @@ fn lookup_by_attachment_form(config: &AdminConfig, csrf_token: &str) -> Markup {
|
||||
fn delete_message_form(config: &AdminConfig, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
card(html! {
|
||||
h3 class="mb-4 text-base font-semibold text-neutral-900" {
|
||||
"Delete Message"
|
||||
}
|
||||
form method="post" action={(base) "/messages?action=delete"} {
|
||||
h3 class="mb-4 text-base font-semibold text-neutral-900" { "Delete message" }
|
||||
form id="delete-message-form" method="post" action={(base) "/messages?action=delete"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="flex flex-col gap-4" {
|
||||
p class="text-neutral-600 text-sm" {
|
||||
"For a message that broke a rule, use Take action instead, so a decision records the removal and the user is told."
|
||||
}
|
||||
(form_field_group(
|
||||
"Channel ID", "delete-message-channel-id", true, None, None,
|
||||
html! {
|
||||
@@ -662,16 +689,26 @@ fn delete_message_form(config: &AdminConfig, csrf_token: &str) -> Markup {
|
||||
},
|
||||
))
|
||||
(form_field_group(
|
||||
"Audit Log Reason (optional)",
|
||||
"delete-message-audit-log-reason", false, None, None,
|
||||
"Basis", "delete-message-basis", true, None, None,
|
||||
html! {
|
||||
input type="text" id="delete-message-audit-log-reason"
|
||||
name="audit_log_reason" placeholder="Reason for deletion"
|
||||
select id="delete-message-basis" name="basis" required class=(FORM_INPUT_CLASS) {
|
||||
option value="" disabled selected { "Choose a basis" }
|
||||
option value="holder_request" { "The account holder asked" }
|
||||
option value="housekeeping" { "Housekeeping" }
|
||||
}
|
||||
},
|
||||
))
|
||||
(form_field_group(
|
||||
"Internal note (optional)",
|
||||
"delete-message-internal-note", false, None, None,
|
||||
html! {
|
||||
input type="text" id="delete-message-internal-note"
|
||||
name="internal_note" maxlength=(INTERNAL_NOTE_MAX_CHARS)
|
||||
class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete Message"))
|
||||
(danger_button("Delete message"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod bans;
|
||||
pub(crate) mod blocklist_helpers;
|
||||
pub mod bulk_actions;
|
||||
pub mod discovery;
|
||||
pub mod enforcement_decision;
|
||||
pub mod gateway;
|
||||
pub mod gateway_leaderboard;
|
||||
pub mod gateway_nodes;
|
||||
@@ -25,6 +26,7 @@ pub mod jobs_list_helpers;
|
||||
pub mod limit_config;
|
||||
pub mod login;
|
||||
pub mod messages_page;
|
||||
pub mod pending_registrations;
|
||||
pub mod profile_substring_bans;
|
||||
pub mod report_detail;
|
||||
pub mod reports_list;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::PendingRegistrationResponse,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
error_display::error_alert, form::csrf_input, page_container::page_header,
|
||||
table::empty_state,
|
||||
},
|
||||
layout::admin_layout,
|
||||
layout_sidebar_nav::PENDING_REGISTRATIONS_KEY,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::instance_config::{admin_issued_registration_url, compact_button, compact_copy_button};
|
||||
|
||||
pub fn pending_registrations_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
pending_registrations: Option<&[PendingRegistrationResponse]>,
|
||||
) -> Markup {
|
||||
let content = html! {
|
||||
(page_header(
|
||||
"Pending Registrations",
|
||||
Some("Approve or reject accounts waiting for a registration decision."),
|
||||
))
|
||||
@match pending_registrations {
|
||||
Some(pending_registrations) => {
|
||||
(pending_registration_list(config, csrf_token, pending_registrations))
|
||||
}
|
||||
None => (error_alert("Failed to load pending registrations.")),
|
||||
}
|
||||
};
|
||||
admin_layout(
|
||||
config,
|
||||
auth,
|
||||
"Pending Registrations",
|
||||
PENDING_REGISTRATIONS_KEY,
|
||||
None,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pending_registration_list(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
pending_registrations: &[PendingRegistrationResponse],
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
div id="pending-registration-list" {
|
||||
@if pending_registrations.is_empty() {
|
||||
(empty_state("No pending registrations."))
|
||||
} @else {
|
||||
div class="overflow-x-auto rounded-lg border border-neutral-200" {
|
||||
table class="min-w-[940px] divide-y divide-neutral-200 text-sm" {
|
||||
thead class="bg-neutral-50" {
|
||||
tr {
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Applicant" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Requested" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Registration URL" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "IP" }
|
||||
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-neutral-200 bg-white" {
|
||||
@for pending in pending_registrations {
|
||||
(pending_registration_row(config, base, csrf_token, pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_registration_row(
|
||||
config: &AdminConfig,
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
pending: &PendingRegistrationResponse,
|
||||
) -> Markup {
|
||||
let account_name = pending_registration_account_name(pending);
|
||||
let requested_at = format_admin_timestamp(&pending.requested_at);
|
||||
let email = pending.email.as_deref().unwrap_or("None");
|
||||
let client_ip = pending.client_ip.as_deref().unwrap_or("None");
|
||||
let approve_action = format!("{base}/pending-registrations/approve");
|
||||
let reject_action = format!("{base}/pending-registrations/reject");
|
||||
html! {
|
||||
tr {
|
||||
td class="max-w-[20rem] px-4 py-3 align-top" {
|
||||
p class="font-medium text-neutral-900" { (account_name) }
|
||||
@if let Some(global_name) = pending.global_name.as_deref() {
|
||||
p class="truncate text-xs text-neutral-500" title=(global_name) { (global_name) }
|
||||
}
|
||||
p class="truncate text-xs text-neutral-500" title=(email) { (email) }
|
||||
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (&pending.user_id) }
|
||||
}
|
||||
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
||||
(requested_at)
|
||||
}
|
||||
td class="px-4 py-3 align-top text-neutral-700" {
|
||||
@if let Some(registration_url_id) = pending.registration_url_id.as_deref() {
|
||||
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
|
||||
div class="mt-1" {
|
||||
(compact_copy_button(&admin_issued_registration_url(config, registration_url_id), "Copy URL"))
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-500" { "None" }
|
||||
}
|
||||
}
|
||||
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
||||
(client_ip)
|
||||
}
|
||||
td class="px-4 py-3 align-top" {
|
||||
div class="flex flex-nowrap gap-2" {
|
||||
(decision_form(&approve_action, csrf_token, &pending.user_id, "Approve", false))
|
||||
(decision_form(&reject_action, csrf_token, &pending.user_id, "Reject", true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_form(
|
||||
action: &str,
|
||||
csrf_token: &str,
|
||||
user_id: &str,
|
||||
label: &str,
|
||||
danger: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
form method="post" action=(action)
|
||||
hx-post=(action)
|
||||
hx-target="#pending-registration-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
data-admin-allow-swap="true" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="user_id" value=(user_id);
|
||||
(compact_button(label, danger))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
|
||||
if pending.discriminator == 0 {
|
||||
pending.username.clone()
|
||||
} else {
|
||||
format!("{}#{:04}", pending.username, pending.discriminator)
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,14 @@ use crate::{
|
||||
data_field, data_field_link_mono, data_field_mono, data_field_muted,
|
||||
data_field_text,
|
||||
},
|
||||
enforcement_fields::{TakeAction, can_take_action, take_action_href},
|
||||
form::{
|
||||
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, checkbox, csrf_input, danger_button,
|
||||
opt_out_checkbox, select_chevron,
|
||||
},
|
||||
media::user_avatar_url,
|
||||
message_data::ordered_messages,
|
||||
message_list::{message_deletion_script, message_list},
|
||||
message_list::message_list,
|
||||
nsfw_indicators::{
|
||||
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
||||
},
|
||||
@@ -40,6 +41,8 @@ use crate::{
|
||||
},
|
||||
resource_link::{ResourceType, resource_link},
|
||||
section_card::section_card,
|
||||
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
|
||||
user_display::user_link,
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
@@ -93,10 +96,15 @@ fn reported_user_label(report: &ReportEntry) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn has_acl(auth: &AuthContext, permission: &str) -> bool {
|
||||
fn admin_acls(auth: &AuthContext) -> &[String] {
|
||||
auth.admin_user
|
||||
.as_ref()
|
||||
.is_some_and(|admin| acl::has_permission(&admin.acls, permission))
|
||||
.map(|admin| admin.acls.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn has_acl(auth: &AuthContext, permission: &str) -> bool {
|
||||
acl::has_permission(admin_acls(auth), permission)
|
||||
}
|
||||
|
||||
fn basic_info_section(config: &AdminConfig, auth: &AuthContext, report: &ReportEntry) -> Markup {
|
||||
@@ -506,18 +514,38 @@ fn status_card(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) -> Markup {
|
||||
fn take_action_subject(report: &ReportEntry) -> Option<&str> {
|
||||
match report.report_type {
|
||||
0 | 1 => report.reported_user_id.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn actions_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let reported_message = report
|
||||
.reported_channel_id
|
||||
.as_deref()
|
||||
.zip(report.reported_message_id.as_deref());
|
||||
html! {
|
||||
(section_card(Some("Actions"), None, None, html! {
|
||||
div class="flex flex-col gap-3" {
|
||||
@if report.status == 0 {
|
||||
(resolve_report_form(base, &report.report_id, csrf_token))
|
||||
}
|
||||
@if report.report_type == 0 || report.report_type == 1 {
|
||||
@if let Some(ref reported_id) = report.reported_user_id {
|
||||
(nav_link(&format!("{base}/users/{reported_id}"), "View Reported User"))
|
||||
@if let Some(reported_id) = take_action_subject(report) {
|
||||
@if can_take_action(admin_acls(auth)) {
|
||||
(nav_link(
|
||||
&take_action_href(base, reported_id, Some(&report.report_id), reported_message),
|
||||
"Take action",
|
||||
))
|
||||
}
|
||||
(nav_link(&format!("{base}/users/{reported_id}"), "View Reported User"))
|
||||
}
|
||||
@if report.report_type == 2 {
|
||||
@if let Some(ref guild_id) = report.reported_guild_id {
|
||||
@@ -568,6 +596,31 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
fieldset class="flex flex-col gap-3" data-show-for-resolution="actioned" hidden disabled {
|
||||
label for="outcome" class=(FORM_LABEL_CLASS) { "Outcome" }
|
||||
div class="relative" {
|
||||
select id="outcome" name="outcome" required
|
||||
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
|
||||
option value="" selected disabled { "Choose one" }
|
||||
option value="removed" { "Content removed" }
|
||||
option value="warned" { "Warning given" }
|
||||
option value="restricted" { "Account restricted" }
|
||||
option value="action_taken" { "Other action taken" }
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"What happened to the reported content or account. The reporter is told it. Warning given and other action taken tell them we acted but not how."
|
||||
}
|
||||
}
|
||||
fieldset class="flex flex-col gap-3" data-show-for-resolution="duplicate" hidden disabled {
|
||||
label for="duplicate_of" class=(FORM_LABEL_CLASS) { "Duplicate of" }
|
||||
input type="text" id="duplicate_of" name="duplicate_of" required inputmode="numeric"
|
||||
placeholder="Report ID" class={(FORM_CONTROL_CLASS) " h-8 px-3 py-1.5"};
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"The report this one repeats. The reporter is told its outcome, or nothing while it is still open."
|
||||
}
|
||||
}
|
||||
label for="public_comment" class=(FORM_LABEL_CLASS) {
|
||||
"Public comment to the reporter (optional)"
|
||||
}
|
||||
@@ -575,14 +628,14 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
|
||||
class={(FORM_CONTROL_CLASS) " px-3 py-2"} {}
|
||||
(opt_out_checkbox("notify_reporter", "Include the public comment in the reporter notice"))
|
||||
p class="text-neutral-500 text-xs" data-resolve-notice-help="" {
|
||||
"The reporter is told the report was resolved either way, unless their account is gone or barred from reporting. \
|
||||
"The reporter is told the outcome, unless their account is gone or was barred from reporting when they filed it. \
|
||||
When unticked, the comment is not sent or shown on the report. It is only kept in the audit log."
|
||||
}
|
||||
button type="submit"
|
||||
class="inline-flex w-full items-center justify-center gap-2 \
|
||||
font-medium rounded-lg bg-neutral-900 text-white \
|
||||
px-4 py-2 text-sm" {
|
||||
"Resolve Report"
|
||||
"Resolve report"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -893,6 +946,36 @@ fn snapshot_guild_group(
|
||||
}
|
||||
}
|
||||
|
||||
fn notes_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
note_authors: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let notes: Vec<StaffNote> = report
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| StaffNote {
|
||||
note_id: ¬e.note_id,
|
||||
author: user_link(base, note_authors, ¬e.author_id),
|
||||
body: ¬e.body,
|
||||
created_at: ¬e.created_at,
|
||||
})
|
||||
.collect();
|
||||
staff_notes_section(
|
||||
¬es,
|
||||
has_acl(auth, acl::REPORT_RESOLVE).then(|| StaffNoteForm {
|
||||
action: format!("{base}/reports/{}/notes", report.report_id),
|
||||
refresh_target: "#report-detail",
|
||||
field_id: format!("note-{}", report.report_id),
|
||||
help: "Staff only. Never shown to the reporter or the reported account. Notes cannot be changed and are deleted with the report.",
|
||||
csrf_token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn legal_hold_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -990,7 +1073,7 @@ fn delete_report_card(
|
||||
(section_card(Some("Delete Report"), None, None, html! {
|
||||
div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes the report now, with its message context, profile snapshot, stored evidence and search entry. \
|
||||
"Deletes the report now, with its message context, profile snapshot, notes, stored evidence and search entry. \
|
||||
Evidence that another report uses is kept. This cannot be undone."
|
||||
}
|
||||
@if held {
|
||||
@@ -1035,8 +1118,7 @@ fn nav_link(href: &str, label: &str) -> Markup {
|
||||
fn message_context_section(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
include_delete: bool,
|
||||
csrf_token: Option<&str>,
|
||||
take_action: bool,
|
||||
) -> Markup {
|
||||
let Some(values) = &report.message_context else {
|
||||
return html! {};
|
||||
@@ -1058,14 +1140,19 @@ fn message_context_section(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
include_delete,
|
||||
false,
|
||||
report.reported_message_id.as_deref(),
|
||||
take_action
|
||||
.then_some(report.reported_user_id.as_deref())
|
||||
.flatten()
|
||||
.map(|user_id| TakeAction {
|
||||
author_id: Some(user_id),
|
||||
report_id: Some(&report.report_id),
|
||||
})
|
||||
.as_ref(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@if let Some(csrf_token) = csrf_token {
|
||||
(message_deletion_script(csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,6 +1161,7 @@ pub fn report_detail_page(
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
live: &LiveProfile,
|
||||
note_authors: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
@@ -1092,12 +1180,13 @@ pub fn report_detail_page(
|
||||
(report_answers_section(report))
|
||||
(reported_entity_section(config, report))
|
||||
(profile_snapshot_section(config, report, live))
|
||||
(message_context_section(config, report, has_acl(auth, acl::MESSAGE_DELETE), Some(csrf_token)))
|
||||
(message_context_section(config, report, can_take_action(admin_acls(auth))))
|
||||
(additional_info_section(report))
|
||||
(notes_card(config, auth, report, note_authors, csrf_token))
|
||||
}
|
||||
div class="space-y-6" {
|
||||
(status_card(config, report))
|
||||
(actions_card(config, report, csrf_token))
|
||||
(actions_card(config, auth, report, csrf_token))
|
||||
(legal_hold_card(config, auth, report, csrf_token))
|
||||
(delete_report_card(config, auth, report, csrf_token))
|
||||
}
|
||||
@@ -1118,7 +1207,7 @@ pub fn report_detail_fragment(config: &AdminConfig, report: &ReportEntry) -> Mar
|
||||
(basic_info_section_fragment(config, report))
|
||||
(report_answers_section(report))
|
||||
(reported_entity_section(config, report))
|
||||
(message_context_section(config, report, false, None))
|
||||
(message_context_section(config, report, false))
|
||||
(additional_info_section(report))
|
||||
a href={(base) "/reports/" (&report.report_id)}
|
||||
class="inline-flex min-h-[44px] w-full items-center justify-center \
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::{
|
||||
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession, WebAuthnCredential,
|
||||
api::{
|
||||
generated::types::EnforcementKindType,
|
||||
types::{
|
||||
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession,
|
||||
WebAuthnCredential,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
alert::{AlertVariant, alert},
|
||||
enforcement_fields::{BasisFields, DecisionFields, FIELD_HELP_CLASS, basis_fields},
|
||||
form::{checkbox, csrf_input, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
@@ -286,7 +291,7 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
let action_url = format!("{base}/users/{}?action=clear_fields&tab=account", user.id);
|
||||
html! {
|
||||
(card_with_header("Clear Profile Fields", html! {
|
||||
(card_with_header("Clear profile fields", html! {
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
hx-post=(&action_url)
|
||||
@@ -300,10 +305,31 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
@if user.banner.is_some() { (checkbox("fields[]", "banner", "Banner", false, true)) }
|
||||
@if user.bio.is_some() { (checkbox("fields[]", "bio", "Bio", false, true)) }
|
||||
@if user.pronouns.is_some() { (checkbox("fields[]", "pronouns", "Pronouns", false, true)) }
|
||||
@if user.global_name.is_some() { (checkbox("fields[]", "global_name", "Display Name", false, true)) }
|
||||
@if user.global_name.is_some() { (checkbox("fields[]", "global_name", "Display name", false, true)) }
|
||||
}
|
||||
(basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "clear-fields",
|
||||
kind: EnforcementKindType::ProfileReset,
|
||||
report_ids: &[],
|
||||
messages: Some(&[]),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
],
|
||||
plain_note_help: "Staff only. Saved in the audit log.",
|
||||
enforcement_extra: html! {
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"The current values are saved as evidence before they are cleared. The cleared fields are the facts, so evidence and a note to the user are optional."
|
||||
}
|
||||
},
|
||||
}))
|
||||
(form_actions(html! {
|
||||
(submit_button("Clear Selected Fields"))
|
||||
(submit_button("Clear selected fields"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::DELETION_REASONS;
|
||||
use crate::admin_flags::{ENFORCEMENT_LIMITS, deletion_reason_label};
|
||||
use crate::utils::plural::{count_noun, noun_for};
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{ApiError, ApiResult},
|
||||
generated::types::{
|
||||
EnforcementDecisionListResponse, EnforcementDecisionListResponseDecisionsItem,
|
||||
EnforcementKindType, NetworkBlockCandidatesResponse,
|
||||
},
|
||||
types::{AdminUser, AuditLogEntry, MessageShredStatusResponse},
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
form::{
|
||||
checkbox, csrf_input, danger_button, form_actions, opt_out_checkbox, submit_button,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{
|
||||
BasisFields, DecisionFields, FIELD_HELP_CLASS, FIELD_INPUT_CLASS,
|
||||
FIELD_LABEL_CLASS, basis_fields, deletion_days_field, enforcement_fields,
|
||||
field_label, internal_note_field, network_block_fields, required_mark,
|
||||
},
|
||||
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
pages::enforcement_decision::{
|
||||
EndDecisionForm, can_end, can_lift, decisions_table, end_decision_form, ends_label,
|
||||
kind_label, suspension_label,
|
||||
},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
|
||||
const REFRESH_TARGET: &str = "#user-tab-content";
|
||||
const REPORTS_LIMIT_DEFAULT_DAYS: i64 = 90;
|
||||
|
||||
const CONTENT_BASIS_CHOICES: &[(&str, &str)] = &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
];
|
||||
|
||||
const SUSPENSION_DURATIONS: &[(&str, &str)] = &[
|
||||
("1", "1 hour"),
|
||||
("12", "12 hours"),
|
||||
("24", "1 day"),
|
||||
@@ -31,45 +53,36 @@ const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
|
||||
("0", "Permanent"),
|
||||
];
|
||||
|
||||
pub struct CurrentBan<'a> {
|
||||
pub struct LegacySuspension<'a> {
|
||||
pub entry: &'a AuditLogEntry,
|
||||
pub notes: Vec<&'a AuditLogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DecisionPrefill {
|
||||
pub report_ids: Vec<String>,
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ModerationContext<'a> {
|
||||
pub deletion_scheduler: Option<&'a AdminUser>,
|
||||
pub current_ban: Option<CurrentBan<'a>>,
|
||||
pub username_sign_in: bool,
|
||||
pub legacy_suspension: Option<LegacySuspension<'a>>,
|
||||
pub decisions: Option<&'a ApiResult<EnforcementDecisionListResponse>>,
|
||||
pub network_candidates: Option<&'a ApiResult<NetworkBlockCandidatesResponse>>,
|
||||
pub prefill: DecisionPrefill,
|
||||
}
|
||||
|
||||
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
|
||||
pub fn find_legacy_suspension<'a>(
|
||||
user: &AdminUser,
|
||||
logs: &'a [AuditLogEntry],
|
||||
) -> Option<LegacySuspension<'a>> {
|
||||
let banned_until = user.temp_banned_until.as_deref()?;
|
||||
let entry = logs.iter().find(|log| {
|
||||
log.action == "temp_ban"
|
||||
&& log.target_id == user.id
|
||||
&& log.metadata.get("banned_until").map(String::as_str) == Some(banned_until)
|
||||
})?;
|
||||
let mut notes: Vec<&AuditLogEntry> = logs
|
||||
.iter()
|
||||
.filter(|log| {
|
||||
log.action == "annotate_ban"
|
||||
&& log.metadata.get("ban_audit_log_id") == Some(&entry.log_id)
|
||||
})
|
||||
.collect();
|
||||
notes.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Some(CurrentBan { entry, notes })
|
||||
}
|
||||
|
||||
fn deletion_reason_label(code: i32) -> String {
|
||||
let value = code.to_string();
|
||||
DELETION_REASONS
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == value)
|
||||
.map_or_else(
|
||||
|| format!("Reason {code}"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
Some(LegacySuspension { entry })
|
||||
}
|
||||
|
||||
fn admin_name(entry: &AuditLogEntry) -> String {
|
||||
@@ -91,169 +104,256 @@ pub fn moderation_tab(
|
||||
delete_all_messages_dry_run: Option<(u64, u64)>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let can_limit = !user.bot && acl::has_permission(admin_acls, acl::USER_UPDATE_FLAGS);
|
||||
let can_delete_all_messages = acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL);
|
||||
let can_shred_messages = acl::has_permission(admin_acls, acl::MESSAGE_SHRED);
|
||||
let decisions = context
|
||||
.decisions
|
||||
.and_then(|result| result.as_ref().ok())
|
||||
.map(|response| response.decisions.as_slice())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
(current_restriction_card(base, user, csrf_token, admin_acls, context, decisions))
|
||||
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
|
||||
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref(), context.username_sign_in))
|
||||
(deletion_card(base, user, csrf_token, context.deletion_scheduler, context.username_sign_in))
|
||||
@if acl::has_permission(admin_acls, acl::USER_TEMP_BAN) {
|
||||
(suspend_card(base, user, csrf_token, context))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::USER_DELETE) {
|
||||
(close_card(base, config.self_hosted, user, csrf_token, context))
|
||||
}
|
||||
@if can_limit {
|
||||
(limit_card(base, user, csrf_token, &context.prefill))
|
||||
}
|
||||
@if can_limit {
|
||||
(warn_card(base, user, csrf_token, &context.prefill))
|
||||
}
|
||||
}
|
||||
@if let Some(result) = context.decisions {
|
||||
(card_with_header("Decisions", html! {
|
||||
@match result {
|
||||
Ok(response) => { (decisions_table(base, &response.decisions)) }
|
||||
Err(_) => { p class="text-sm text-red-700" { "Failed to load decisions." } }
|
||||
}
|
||||
}))
|
||||
}
|
||||
@if can_delete_all_messages {
|
||||
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
|
||||
(delete_all_messages_card(base, user, csrf_token, &context.prefill, delete_all_messages_dry_run))
|
||||
}
|
||||
@if can_shred_messages {
|
||||
(message_shred_card(base, user, csrf_token, message_shred_job_id, message_shred_status))
|
||||
(message_shred_card(base, user, csrf_token, &context.prefill, message_shred_job_id, message_shred_status))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_user_checkbox(username_sign_in: bool, label: &str) -> Markup {
|
||||
if username_sign_in {
|
||||
html! { input type="hidden" name="notify_user_present" value="1"; }
|
||||
} else {
|
||||
opt_out_checkbox("notify_user", label)
|
||||
}
|
||||
}
|
||||
|
||||
fn ban_actions_card(
|
||||
fn current_restriction_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
username_sign_in: bool,
|
||||
admin_acls: &[String],
|
||||
context: &ModerationContext<'_>,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Ban Actions", html! {
|
||||
@if user.temp_banned_until.is_some() {
|
||||
(current_ban_details(base, user, csrf_token, current_ban))
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Public Reason (optional, shown to the user)"))
|
||||
input type="text" name="public_reason"
|
||||
placeholder="Enter public unban reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional, audit log)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private unban reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(notify_user_checkbox(username_sign_in, "Email the user that the suspension was lifted"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Unban User"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=temp_ban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Duration"))
|
||||
select name="duration"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {
|
||||
@for &(value, label) in TEMP_BAN_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(form_label("Public Reason (optional)"))
|
||||
input type="text" name="reason"
|
||||
placeholder="Enter public ban reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private ban reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(notify_user_checkbox(username_sign_in, "Email the user about this suspension (temporary bans only)"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Ban/Suspend User"))
|
||||
}))
|
||||
}
|
||||
(card_with_header("Current restriction", html! {
|
||||
div data-current-restriction="" {
|
||||
@if let Some(decision_id) = &user.enforcement_decision_id {
|
||||
(decision_restriction(base, user, csrf_token, admin_acls, decision_id, decisions))
|
||||
} @else if let Some(until) = &user.temp_banned_until {
|
||||
(legacy_suspension_restriction(base, user, csrf_token, admin_acls, until, context.legacy_suspension.as_ref()))
|
||||
} @else if let Some(pending) = &user.pending_deletion_at {
|
||||
(legacy_deletion_restriction(base, user, csrf_token, admin_acls, pending, context.deletion_scheduler))
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" { "No current restriction." }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_ban_details(
|
||||
fn decision_restriction(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
admin_acls: &[String],
|
||||
decision_id: &str,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
let Some(ban) = current_ban else {
|
||||
let decision = decisions
|
||||
.iter()
|
||||
.find(|decision| decision.decision_id.as_str() == decision_id);
|
||||
let state = match (decision, user.temp_banned_until.as_deref()) {
|
||||
(Some(decision), _) => {
|
||||
let ends = ends_label(
|
||||
decision.kind,
|
||||
decision.ends_at.as_deref(),
|
||||
decision.deletion_at.as_deref(),
|
||||
);
|
||||
format!(
|
||||
"{} for {}. {ends}",
|
||||
kind_label(decision.kind),
|
||||
deletion_reason_label(*decision.reason_code)
|
||||
)
|
||||
}
|
||||
(None, Some(until)) => suspension_label(until),
|
||||
(None, None) => "Restricted by a decision.".to_owned(),
|
||||
};
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
(state.trim_end())
|
||||
" "
|
||||
a href={(base) "/decisions/" (decision_id)} class="text-blue-600 hover:underline" {
|
||||
"Decision " (decision_id)
|
||||
}
|
||||
}
|
||||
@match decision {
|
||||
Some(decision) if can_end(admin_acls, decision.kind, decision.removed_all_messages) => {
|
||||
(end_decision_form(&EndDecisionForm {
|
||||
base,
|
||||
decision_id,
|
||||
can_lift: can_lift(decision.kind),
|
||||
reason_code: *decision.reason_code,
|
||||
csrf_token,
|
||||
refresh_target: REFRESH_TARGET,
|
||||
}))
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
p class="text-sm text-neutral-500" { "Open the decision to see or end it." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_suspension_restriction(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
until: &str,
|
||||
legacy_suspension: Option<&LegacySuspension<'_>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
(suspension_label(until)) ". Recorded before decisions, so it has no decision."
|
||||
}
|
||||
(legacy_suspension_details(base, legacy_suspension))
|
||||
@if acl::has_permission(admin_acls, acl::USER_TEMP_BAN) {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(internal_note_field("lift", "Staff only. Saved in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) { "The user is told by email that the suspension was lifted." }
|
||||
(form_actions(submit_button("Lift suspension")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_suspension_details(
|
||||
base: &str,
|
||||
legacy_suspension: Option<&LegacySuspension<'_>>,
|
||||
) -> Markup {
|
||||
let Some(suspension) = legacy_suspension else {
|
||||
return html! {
|
||||
p class="mb-4 text-sm text-neutral-500" {
|
||||
"The audit log entry of this ban could not be loaded, so notes cannot be added here."
|
||||
p class="text-sm text-neutral-500" {
|
||||
"The audit log entry of this suspension could not be loaded."
|
||||
}
|
||||
};
|
||||
};
|
||||
html! {
|
||||
div class="mb-4 space-y-3" {
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Banned by: " }
|
||||
dd class="inline" {
|
||||
a href={(base) "/users/" (ban.entry.admin_user_id)} class="underline" {
|
||||
(admin_name(ban.entry))
|
||||
}
|
||||
" on " (format_admin_timestamp(&ban.entry.created_at))
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Suspended by: " }
|
||||
dd class="inline" {
|
||||
a href={(base) "/users/" (suspension.entry.admin_user_id)} class="underline" {
|
||||
(admin_name(suspension.entry))
|
||||
}
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Reason: " }
|
||||
dd class="inline" { (ban.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) }
|
||||
" on " (format_admin_timestamp(&suspension.entry.created_at))
|
||||
}
|
||||
}
|
||||
@if !ban.notes.is_empty() {
|
||||
ul class="space-y-1 text-sm text-neutral-700" {
|
||||
@for note in &ban.notes {
|
||||
li {
|
||||
span class="font-medium" { (admin_name(note)) }
|
||||
" (" (format_admin_timestamp(¬e.created_at)) "): "
|
||||
(note.audit_log_reason.as_deref().unwrap_or(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=annotate_ban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="ban_audit_log_id" value=(ban.entry.log_id);
|
||||
div class="space-y-3" {
|
||||
(form_label("Add a note to this ban"))
|
||||
textarea name="note" rows="2" required maxlength="512"
|
||||
placeholder="Appended to the ban. The original reason is kept."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {}
|
||||
(form_actions(html! {
|
||||
(submit_button("Add Note"))
|
||||
}))
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Internal note: " }
|
||||
dd class="inline" { (suspension.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
context: &ModerationContext<'_>,
|
||||
) -> Markup {
|
||||
let prefill = &context.prefill;
|
||||
html! {
|
||||
(card_with_header("Suspend account", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=suspend&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
div class="space-y-1" {
|
||||
(field_label("suspend-duration", "Duration"))
|
||||
select id="suspend-duration" name="duration_hours" required class=(FIELD_INPUT_CLASS) {
|
||||
@for &(value, label) in SUSPENSION_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
(enforcement_fields(&DecisionFields {
|
||||
id_prefix: "suspend",
|
||||
kind: EnforcementKindType::Suspension,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
}))
|
||||
(remove_evidence_field())
|
||||
@if let Some(candidates) = context.network_candidates {
|
||||
fieldset data-show-for-duration_hours="0" hidden disabled {
|
||||
(network_block_fields("suspend", candidates))
|
||||
}
|
||||
}
|
||||
(form_actions(submit_button("Suspend account")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &DecisionPrefill) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Warn", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=warn&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(enforcement_fields(&DecisionFields {
|
||||
id_prefix: "warn",
|
||||
kind: EnforcementKindType::Warning,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
}))
|
||||
(form_actions(submit_button("Send warning")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_deletion_summary(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
@@ -274,7 +374,7 @@ fn pending_deletion_summary(
|
||||
.map(format_admin_timestamp)
|
||||
.unwrap_or_default();
|
||||
let markup = html! {
|
||||
dl class="mb-4 space-y-1 text-sm text-neutral-700" {
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Scheduled by: " }
|
||||
dd class="inline" {
|
||||
@@ -317,81 +417,159 @@ fn pending_deletion_summary(
|
||||
)
|
||||
}
|
||||
|
||||
fn deletion_card(
|
||||
fn legacy_deletion_restriction(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
pending: &str,
|
||||
scheduler: Option<&AdminUser>,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
|
||||
html! {
|
||||
(card_with_header("Account Deletion", html! {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
@let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
|
||||
(summary)
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
"Scheduled for deletion. No decision owns it, so it is cancelled here."
|
||||
}
|
||||
(summary)
|
||||
@if acl::has_permission(admin_acls, acl::USER_DELETE) {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"} {
|
||||
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="expected_pending_deletion_at" value=(pending);
|
||||
div class="space-y-3" {
|
||||
(form_label("Private Reason"))
|
||||
input type="text" name="private_reason" required
|
||||
placeholder="Why this deletion is being canceled (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
@if !username_sign_in {
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was canceled", false, true))
|
||||
}
|
||||
(internal_note_field("cancel-deletion", "Staff only. Saved in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) { "The user is told by email that the deletion was cancelled." }
|
||||
(checkbox("confirm", "true", &confirmation, false, true))
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Deletion"))
|
||||
}))
|
||||
(form_actions(danger_button("Cancel deletion")))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=schedule_deletion&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Days until deletion"))
|
||||
input type="number" name="days_until_deletion" value="60"
|
||||
min="60" max="365"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Reason"))
|
||||
select name="reason_code" required
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_card(
|
||||
base: &str,
|
||||
self_hosted: bool,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
context: &ModerationContext<'_>,
|
||||
) -> Markup {
|
||||
let prefill = &context.prefill;
|
||||
html! {
|
||||
(card_with_header("Close account", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=close&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "close",
|
||||
kind: EnforcementKindType::Closure,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Inactivity"),
|
||||
],
|
||||
plain_note_help: "Staff only. Saved in the audit log. The holder can cancel the deletion by signing in.",
|
||||
enforcement_extra: html! {
|
||||
(remove_evidence_field())
|
||||
@if let Some(candidates) = context.network_candidates {
|
||||
(network_block_fields("close", candidates))
|
||||
}
|
||||
},
|
||||
}))
|
||||
(deletion_days_field("close-days", self_hosted))
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
input type="hidden" name="replace_pending_deletion_at" value=(pending);
|
||||
p class=(FIELD_HELP_CLASS) { "This replaces the deletion already scheduled." }
|
||||
}
|
||||
(form_actions(danger_button("Close account")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_evidence_field() -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(checkbox("remove_evidence", "true", "Remove these messages", false, true))
|
||||
p class=(FIELD_HELP_CLASS) { "Removes the messages listed above after they are saved as evidence." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn content_basis_fields<'a>(
|
||||
id_prefix: &'a str,
|
||||
user: &'a AdminUser,
|
||||
prefill: &'a DecisionPrefill,
|
||||
snapshot_entries: bool,
|
||||
) -> BasisFields<'a> {
|
||||
BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix,
|
||||
kind: EnforcementKindType::ContentRemoval,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: (!snapshot_entries).then_some(prefill.messages.as_slice()),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: snapshot_entries,
|
||||
},
|
||||
choices: CONTENT_BASIS_CHOICES,
|
||||
plain_note_help: "Staff only. Saved in the audit log.",
|
||||
enforcement_extra: html! {},
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &DecisionPrefill) -> Markup {
|
||||
let reports_end = (time::OffsetDateTime::now_utc()
|
||||
+ time::Duration::days(REPORTS_LIMIT_DEFAULT_DAYS))
|
||||
.date()
|
||||
.to_string();
|
||||
html! {
|
||||
(card_with_header("Limit features", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=limit_features&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(enforcement_fields(&DecisionFields {
|
||||
id_prefix: "limit",
|
||||
kind: EnforcementKindType::FeatureLimit,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
}))
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Limit" (required_mark()) }
|
||||
div class="flex flex-wrap gap-x-4 gap-y-1" {
|
||||
@for limit in ENFORCEMENT_LIMITS {
|
||||
@let id = format!("limit-{}", limit.value);
|
||||
@let codes = limit.codes.iter().map(ToString::to_string).collect::<Vec<_>>().join(" ");
|
||||
div class="flex items-center gap-2" data-show-for-codes=(codes) hidden {
|
||||
input type="radio" id=(id) name="limit" value=(limit.value) required disabled
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(id) class="text-sm text-neutral-900" { (limit.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
(form_label("Public Reason (optional)"))
|
||||
input type="text" name="public_reason"
|
||||
placeholder="Enter public reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(notify_user_checkbox(username_sign_in, "Email the user about the scheduled deletion"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Schedule Deletion"))
|
||||
}))
|
||||
p class=(FIELD_HELP_CLASS) { "Each reason offers only the limits it allows." }
|
||||
}
|
||||
fieldset class="space-y-1" data-show-for-limit="reports" hidden disabled {
|
||||
(field_label("limit-ends-on", "Ends on"))
|
||||
input type="date" id="limit-ends-on" name="ends_on" value=(reports_end) required class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { "At most 365 days from now." }
|
||||
}
|
||||
(form_actions(submit_button("Limit features")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -402,36 +580,34 @@ fn delete_all_messages_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
prefill: &DecisionPrefill,
|
||||
dry_run: Option<(u64, u64)>,
|
||||
) -> Markup {
|
||||
let action = format!(
|
||||
"{base}/users/{}?action=delete_all_messages&tab=moderation",
|
||||
user.id
|
||||
);
|
||||
html! {
|
||||
(card_with_header("Delete All Messages", html! {
|
||||
(card_with_header("Delete all messages", html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Locate every message this user has ever sent and permanently remove them. First run a dry run to see how many channels and messages will be affected."
|
||||
"Removes every message this user has sent. Preview the count first. Child safety and violent extremism reasons can't be used here, because the messages can't all be saved as evidence first. Use the message shredder with the messages instead."
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=delete_all_messages&tab=moderation"}
|
||||
data-admin-result-form="true" {
|
||||
form method="post" action=(action) data-admin-result-form="true" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="dry_run" value="true";
|
||||
(form_actions(html! {
|
||||
(submit_button("Preview Deletion"))
|
||||
}))
|
||||
(form_actions(submit_button("Preview deletion")))
|
||||
}
|
||||
@if let Some((channel_count, message_count)) = dry_run {
|
||||
div class="space-y-3 rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
form method="post" action=(action)
|
||||
class="space-y-4 rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="dry_run" value="false";
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Channels: " (channel_count) " · Messages: " (message_count)
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=delete_all_messages&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="dry_run" value="false";
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete All Messages"))
|
||||
}))
|
||||
(count_noun(message_count, "message", "messages")) " in " (count_noun(channel_count, "channel", "channels")) "."
|
||||
}
|
||||
(basis_fields(&content_basis_fields("delete-all", user, prefill, false)))
|
||||
(form_actions(danger_button("Delete all messages")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,23 +619,24 @@ fn message_shred_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
prefill: &DecisionPrefill,
|
||||
job_id: Option<&str>,
|
||||
status: Option<&ApiResult<MessageShredStatusResponse>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Message Shredder", html! {
|
||||
(card_with_header("Message shredder", html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Upload a CSV file where each row includes the channel_id and message_id separated by a comma. Large files are chunked server-side automatically."
|
||||
"Upload a CSV file with one channel_id,message_id pair per row. When a rule was broken, the first 50 messages are saved as evidence before they are removed, and every message for child safety and violent extremism reasons."
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=message_shred&tab=moderation"}
|
||||
id="message-shred-form" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="csv_data" id="message-shred-csv-data";
|
||||
div class="space-y-3" {
|
||||
div class="space-y-4" {
|
||||
div class="space-y-2" {
|
||||
(form_label("CSV File"))
|
||||
(field_label("message-shred-file", "CSV file"))
|
||||
input id="message-shred-file" type="file" accept=".csv" class="hidden";
|
||||
div class="flex items-center gap-2" {
|
||||
button type="button"
|
||||
@@ -472,10 +649,11 @@ fn message_shred_card(
|
||||
}
|
||||
}
|
||||
}
|
||||
(basis_fields(&content_basis_fields("shred", user, prefill, true)))
|
||||
button type="submit"
|
||||
id="message-shred-submit"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-900 px-4 py-2 text-base font-medium text-white transition-all duration-150 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50" {
|
||||
span { "Shred Messages" }
|
||||
span { "Shred messages" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,7 +676,7 @@ fn message_shred_status_section(
|
||||
html! {
|
||||
div class="space-y-3 rounded-lg border border-neutral-200 bg-white p-4" {
|
||||
div class="flex items-center justify-between gap-3" {
|
||||
h3 class="text-sm font-medium text-neutral-900" { "Message Shred Status" }
|
||||
h3 class="text-sm font-medium text-neutral-900" { "Message shred status" }
|
||||
a href={(base) "/users/" (user.id) "?tab=moderation"}
|
||||
class="text-sm text-neutral-500 hover:text-neutral-900" {
|
||||
"Clear"
|
||||
@@ -623,12 +801,6 @@ fn message_shred_form_script() -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn form_label(text: &str) -> Markup {
|
||||
maud::html! {
|
||||
label class="block text-sm font-medium text-neutral-700" { (text) }
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var form = document.getElementById('message-shred-form');
|
||||
@@ -662,7 +834,7 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
function resetSubmit(label) {
|
||||
processing = false;
|
||||
submitButton.disabled = false;
|
||||
submitButton.querySelector('span').textContent = label || 'Shred Messages';
|
||||
submitButton.querySelector('span').textContent = label || 'Shred messages';
|
||||
}
|
||||
file.addEventListener('change', function () {
|
||||
if (fileName) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
form::{checkbox, csrf_input, form_actions, submit_button},
|
||||
page_container::{card_with_header, detail_row},
|
||||
},
|
||||
templates::pages::enforcement_decision::suspension_label,
|
||||
utils::{
|
||||
bigint::format_discriminator,
|
||||
timestamps::{format_admin_timestamp, snowflake_creation_date},
|
||||
@@ -64,12 +65,8 @@ fn render_overview_tab(
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
@if let Some(until) = &user.temp_banned_until {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" {
|
||||
"Temporarily banned until " (format_admin_timestamp(until))
|
||||
}
|
||||
}
|
||||
@if user.temp_banned_until.is_none() {
|
||||
(restriction_banner(config, user))
|
||||
@if user.temp_banned_until.is_none() && user.enforcement_decision_id.is_none() {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
div class="rounded-lg border border-orange-200 bg-orange-50 p-4 text-sm text-orange-900" {
|
||||
div class="font-medium" { "Scheduled for deletion: " (format_admin_timestamp(pending)) }
|
||||
@@ -262,6 +259,30 @@ fn render_overview_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn restriction_banner(config: &AdminConfig, user: &AdminUser) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@match (&user.enforcement_decision_id, &user.temp_banned_until) {
|
||||
(Some(decision_id), until) => {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" data-restriction-banner="" {
|
||||
@match (until, &user.pending_deletion_at) {
|
||||
(Some(until), _) => { (suspension_label(until)) ". " }
|
||||
(None, Some(pending)) => { "Scheduled for deletion on " (format_admin_timestamp(pending)) ". " }
|
||||
(None, None) => { "Restricted by a decision. " }
|
||||
}
|
||||
a href={(base) "/decisions/" (decision_id)} class="underline" { "Decision " (decision_id) }
|
||||
}
|
||||
}
|
||||
(None, Some(until)) => {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" data-restriction-banner="" {
|
||||
(suspension_label(until)) ". Recorded before decisions, so it has no decision."
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flags_card(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
@@ -269,118 +290,58 @@ fn flags_card(
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let can_update_flags = acl::has_permission(admin_acls, acl::USER_UPDATE_FLAGS);
|
||||
let suspended = user.temp_banned_until.is_some();
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
(u64_flag_form(
|
||||
(flag_form_shell(
|
||||
config,
|
||||
&user.id,
|
||||
"User Flags",
|
||||
"User flags",
|
||||
"update_flags",
|
||||
"flags[]",
|
||||
user.flags,
|
||||
admin_flags::USER_FLAGS,
|
||||
csrf_token,
|
||||
can_update_flags,
|
||||
Some(acl::USER_UPDATE_FLAGS),
|
||||
html! {
|
||||
@for flag in admin_flags::USER_FLAGS {
|
||||
(checkbox(
|
||||
"flags[]",
|
||||
&flag.value.to_string(),
|
||||
flag.name,
|
||||
user.flags & flag.value != 0,
|
||||
can_update_flags && admin_flags::user_flag_is_editable(flag.value, user.flags, suspended),
|
||||
))
|
||||
}
|
||||
},
|
||||
))
|
||||
(i32_flag_form(
|
||||
(flag_form_shell(
|
||||
config,
|
||||
&user.id,
|
||||
"Premium Flags",
|
||||
"Premium flags",
|
||||
"update_premium_flags",
|
||||
"flags[]",
|
||||
user.premium_flags,
|
||||
admin_flags::PREMIUM_FLAGS,
|
||||
csrf_token,
|
||||
can_update_flags,
|
||||
Some(acl::USER_UPDATE_FLAGS),
|
||||
html! {
|
||||
@for flag in admin_flags::PREMIUM_FLAGS {
|
||||
(checkbox(
|
||||
"flags[]",
|
||||
&flag.value.to_string(),
|
||||
flag.name,
|
||||
user.premium_flags & flag.value != 0,
|
||||
can_update_flags && admin_flags::premium_flag_is_editable(flag.value),
|
||||
))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn u64_flag_form(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
action: &str,
|
||||
input_name: &str,
|
||||
value: u64,
|
||||
flags: &[admin_flags::U64Flag],
|
||||
csrf_token: &str,
|
||||
can_edit: bool,
|
||||
required_acl: Option<&str>,
|
||||
) -> Markup {
|
||||
flag_form_shell(
|
||||
config,
|
||||
user_id,
|
||||
title,
|
||||
action,
|
||||
value.to_string(),
|
||||
csrf_token,
|
||||
can_edit,
|
||||
required_acl,
|
||||
html! {
|
||||
@for flag in flags {
|
||||
(flag_checkbox(
|
||||
input_name,
|
||||
flag.value.to_string(),
|
||||
flag.name,
|
||||
value & flag.value != 0,
|
||||
can_edit,
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn i32_flag_form(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
action: &str,
|
||||
input_name: &str,
|
||||
value: i32,
|
||||
flags: &[admin_flags::I32Flag],
|
||||
csrf_token: &str,
|
||||
can_edit: bool,
|
||||
required_acl: Option<&str>,
|
||||
) -> Markup {
|
||||
flag_form_shell(
|
||||
config,
|
||||
user_id,
|
||||
title,
|
||||
action,
|
||||
value.to_string(),
|
||||
csrf_token,
|
||||
can_edit,
|
||||
required_acl,
|
||||
html! {
|
||||
@for flag in flags {
|
||||
(flag_checkbox(
|
||||
input_name,
|
||||
flag.value.to_string(),
|
||||
flag.name,
|
||||
value & flag.value != 0,
|
||||
can_edit,
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn flag_form_shell(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
action: &str,
|
||||
_raw_value: String,
|
||||
csrf_token: &str,
|
||||
can_edit: bool,
|
||||
required_acl: Option<&str>,
|
||||
checkboxes: Markup,
|
||||
) -> Markup {
|
||||
card_with_header(
|
||||
@@ -392,14 +353,15 @@ fn flag_form_shell(
|
||||
(checkboxes)
|
||||
}
|
||||
@if can_edit {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Restriction flags are read-only. Use the moderation tab."
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
} @else {
|
||||
@if let Some(required) = required_acl {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Read-only \u{2014} " (required) " permission required."
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Read-only. Needs the " (acl::USER_UPDATE_FLAGS) " permission."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@ pub struct MultiValueForm {
|
||||
|
||||
impl MultiValueForm {
|
||||
pub async fn from_request(request: Request) -> Option<Self> {
|
||||
let bytes = body::to_bytes(request.into_body(), MAX_FORM_BYTES)
|
||||
.await
|
||||
.ok()?;
|
||||
Self::from_request_with_limit(request, MAX_FORM_BYTES).await
|
||||
}
|
||||
|
||||
pub async fn from_request_with_limit(request: Request, max_bytes: usize) -> Option<Self> {
|
||||
let bytes = body::to_bytes(request.into_body(), max_bytes).await.ok()?;
|
||||
Some(Self::parse(bytes.as_ref()))
|
||||
}
|
||||
|
||||
@@ -103,10 +105,6 @@ impl MultiValueForm {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn opt_out_value(&self, key: &str) -> bool {
|
||||
!self.contains_key(&format!("{key}_present")) || self.bool_value(key)
|
||||
}
|
||||
|
||||
pub fn list_values(&self, key: &str) -> Vec<String> {
|
||||
self.fields
|
||||
.get(key)
|
||||
|
||||
@@ -261,8 +261,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
"admin_registration_urls_enabled": false,
|
||||
"urls": [],
|
||||
"pending_registrations": []
|
||||
"urls": []
|
||||
},
|
||||
"self_hosted": false,
|
||||
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
|
||||
@@ -1282,3 +1281,28 @@ fn deserialize_guild_threads_response() {
|
||||
.is_some_and(|m| m.archived && !m.locked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branding_asset_upload_request_matches_the_generated_contract() {
|
||||
for kind in types::BrandingAssetKind::ALL {
|
||||
for image in [Some("data:image/png;base64,iVBORw0KGgo=".to_owned()), None] {
|
||||
let request = types::BrandingAssetUploadRequest {
|
||||
kind,
|
||||
image: image.clone(),
|
||||
};
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({"kind": kind.as_str(), "image": image})
|
||||
);
|
||||
let generated: generated_types::BrandingAssetUploadRequest =
|
||||
serde_json::from_value(value.clone()).expect("generated branding asset contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated).unwrap()["kind"],
|
||||
value["kind"]
|
||||
);
|
||||
assert_eq!(types::BrandingAssetKind::parse(kind.as_str()), Some(kind));
|
||||
}
|
||||
}
|
||||
assert_eq!(types::BrandingAssetKind::parse("banner"), None);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::{DefaultBodyLimit, Path},
|
||||
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing,
|
||||
@@ -139,6 +140,128 @@ async fn report_legal_hold_form_places_and_clears_the_hold() {
|
||||
assert_eq!(received.lock().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_notes_show_the_author_and_time_and_the_form_adds_one() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001",
|
||||
routing::get(|| async { json_response(report_with_note()) }),
|
||||
)
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001/notes",
|
||||
routing::post(move |Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push(body.clone());
|
||||
json_response(json!({
|
||||
"note_id": "1950000000000000002",
|
||||
"author_id": "1500000000000000000",
|
||||
"body": body["body"],
|
||||
"created_at": "2026-05-27T08:00:00.000Z"
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/users",
|
||||
routing::get(|| async {
|
||||
json_response(json!({ "users": [user("1500000000000000000", "AdminUser")] }))
|
||||
}),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
|
||||
let note_start = page
|
||||
.find(r#"data-note="1950000000000000001""#)
|
||||
.unwrap_or_else(|| panic!("missing note\n{page}"));
|
||||
let note = &page[note_start..page[note_start..].find("</li>").unwrap() + note_start];
|
||||
assert!(
|
||||
note.contains(r#"href="/users/1500000000000000000""#),
|
||||
"{note}"
|
||||
);
|
||||
assert!(note.contains("AdminUser"), "{note}");
|
||||
assert!(note.contains("May 26, 2026, 1:30 PM UTC"), "{note}");
|
||||
assert!(
|
||||
note.contains("Reporter sent more context by email"),
|
||||
"{note}"
|
||||
);
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(&page, "/reports/1800000000000000001/notes", &csrf_token);
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let post = |body: String| {
|
||||
let app = &app;
|
||||
let cookie = cookie.clone();
|
||||
async move {
|
||||
let (status, headers, text) = post_form_with_headers(
|
||||
app,
|
||||
"/reports/1800000000000000001/notes",
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "flash-container"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&body,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
|
||||
headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
|
||||
.to_owned()
|
||||
}
|
||||
};
|
||||
|
||||
let toast = post(format!(
|
||||
"_csrf={csrf_token}&body=++Waiting+on+the+guild+owner++"
|
||||
))
|
||||
.await;
|
||||
assert!(toast.contains("success"), "{toast}");
|
||||
assert!(toast.contains("Note added"), "{toast}");
|
||||
let toast = post(format!("_csrf={csrf_token}&body=+")).await;
|
||||
assert!(toast.contains("error"), "{toast}");
|
||||
assert!(toast.contains("Write a note first"), "{toast}");
|
||||
assert_eq!(
|
||||
*received.lock().unwrap(),
|
||||
vec![json!({"body": "Waiting on the guild owner"})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_notes_hide_the_form_without_report_resolve() {
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
routing::get(|| async {
|
||||
let mut admin = user("1500000000000000000", "AdminUser");
|
||||
admin["acls"] = json!(["report:view"]);
|
||||
json_response(json!({ "user": admin }))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001",
|
||||
routing::get(|| async { json_response(report_with_note()) }),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (_, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
|
||||
assert!(
|
||||
page.contains("Reporter sent more context by email"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(
|
||||
page.contains(r#"href="/users/1500000000000000000""#),
|
||||
"{page}"
|
||||
);
|
||||
assert!(
|
||||
!page.contains(r#"action="/reports/1800000000000000001/notes""#),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
|
||||
@@ -272,6 +395,283 @@ async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_registration_decision_refreshes_the_list_and_sidebar_count() {
|
||||
let received = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
routing::get(|| async {
|
||||
json_response(json!({"user": admin_user(), "pending_registration_count": 2}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/instance/pending-registrations",
|
||||
routing::get(|| async {
|
||||
json_response(json!({"pending_registrations": [
|
||||
pending_registration("1500000000000000002"),
|
||||
pending_registration("1500000000000000003"),
|
||||
]}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/instance/pending-registrations/{user_id}",
|
||||
routing::patch(
|
||||
move |Path(user_id): Path<String>, Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push((user_id, body));
|
||||
json_response(json!({"pending_registrations": [
|
||||
pending_registration("1500000000000000003"),
|
||||
]}))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/pending-registrations", &[]).await;
|
||||
assert!(
|
||||
page.contains(r#"<span id="pending-registration-count" class="tabular-nums text-neutral-400">2</span>"#),
|
||||
"{page}"
|
||||
);
|
||||
assert!(page.contains("ID: 1500000000000000002"), "{page}");
|
||||
let csrf_token = csrf_cookie(&headers).unwrap_or_else(|| {
|
||||
panic!("pending registrations page did not set csrf_token cookie\n{page}")
|
||||
});
|
||||
assert_form_has_csrf(&page, "/pending-registrations/approve", &csrf_token);
|
||||
assert_form_has_csrf(&page, "/pending-registrations/reject", &csrf_token);
|
||||
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let (status, headers, fragment) = post_form_with_headers(
|
||||
&app,
|
||||
"/pending-registrations/approve",
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "pending-registration-list"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&format!("_csrf={csrf_token}&user_id=1500000000000000002"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{fragment}");
|
||||
assert!(
|
||||
fragment.contains(r#"id="pending-registration-list""#),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(!fragment.contains("ID: 1500000000000000002"), "{fragment}");
|
||||
assert!(fragment.contains("ID: 1500000000000000003"), "{fragment}");
|
||||
assert!(
|
||||
fragment.contains(r#"hx-swap-oob="true">1</span>"#),
|
||||
"{fragment}"
|
||||
);
|
||||
let toast = headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(toast.contains("Registration approved"), "{toast}");
|
||||
assert_eq!(
|
||||
*received.lock().unwrap(),
|
||||
vec![(
|
||||
"1500000000000000002".to_owned(),
|
||||
json!({"status": "approved"})
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn branding_asset_upload_takes_a_large_image_and_swaps_the_field() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/instance/config",
|
||||
routing::get(|| async {
|
||||
json_response(instance_config(Some(
|
||||
"https://media.example.test/branding/0/old.png",
|
||||
)))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/instance/config/branding-assets",
|
||||
routing::post(move |Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push(body.clone());
|
||||
if body["image"] == "data:image/png;base64,bad" {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"code": "INVALID_FORM_BODY",
|
||||
"message": "Input form validation failed",
|
||||
"errors": [{"path": "image", "message": "Unsupported image format"}]
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let logo_url = body["image"]
|
||||
.is_string()
|
||||
.then_some("https://media.example.test/branding/0/new.png");
|
||||
json_response(instance_config(logo_url))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/instance-config", &[]).await;
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(
|
||||
&page,
|
||||
"/instance-config?action=update_app_public",
|
||||
&csrf_token,
|
||||
);
|
||||
assert!(page.contains(r#"id="branding-asset-logo""#), "{page}");
|
||||
assert!(page.contains(r#"id="branding-asset-favicon""#), "{page}");
|
||||
assert!(
|
||||
page.contains(r#"src="https://media.example.test/branding/0/old.png""#),
|
||||
"{page}"
|
||||
);
|
||||
assert!(page.contains(r#"accept=".png,.jpeg,.webp"#), "{page}");
|
||||
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let post = |action: &'static str, fields: Vec<(&'static str, String)>| {
|
||||
let app = &app;
|
||||
let cookie = cookie.clone();
|
||||
let csrf_token = csrf_token.clone();
|
||||
async move {
|
||||
let mut body = url::form_urlencoded::Serializer::new(String::new());
|
||||
body.append_pair("_csrf", &csrf_token);
|
||||
for (name, value) in &fields {
|
||||
body.append_pair(name, value);
|
||||
}
|
||||
post_form_with_headers(
|
||||
app,
|
||||
&format!("/instance-config?action={action}"),
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "branding-asset-logo"),
|
||||
("X-CSRF-Token", &csrf_token),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&body.finish(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let toast_of = |headers: &HeaderMap| {
|
||||
headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
};
|
||||
|
||||
let image = format!(
|
||||
"data:image/png;base64,{}",
|
||||
"iVBO+w/A".repeat(2 * 1024 * 1024 / 8)
|
||||
);
|
||||
let (status, headers, fragment) = post(
|
||||
"upload_branding_asset",
|
||||
vec![("kind", "logo".to_owned()), ("image", image.clone())],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{fragment}");
|
||||
assert!(
|
||||
fragment.starts_with(r#"<div id="branding-asset-logo""#),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(
|
||||
fragment.contains(r#"value="https://media.example.test/branding/0/new.png""#),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(
|
||||
fragment.contains("data-branding-asset-remove"),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(toast_of(&headers).contains("Logo uploaded"));
|
||||
|
||||
let (status, headers, fragment) =
|
||||
post("remove_branding_asset", vec![("kind", "logo".to_owned())]).await;
|
||||
assert_eq!(status, StatusCode::OK, "{fragment}");
|
||||
assert!(
|
||||
fragment.contains(r#"name="app_logo_url" value="""#),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(!fragment.contains("<img"), "{fragment}");
|
||||
assert!(
|
||||
!fragment.contains("data-branding-asset-remove"),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(toast_of(&headers).contains("Logo removed"));
|
||||
|
||||
let (status, headers, _) = post(
|
||||
"upload_branding_asset",
|
||||
vec![
|
||||
("kind", "logo".to_owned()),
|
||||
("image", "data:image/png;base64,bad".to_owned()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
let toast = toast_of(&headers);
|
||||
assert!(toast.contains("error"), "{toast}");
|
||||
assert!(
|
||||
toast.contains("Failed to upload the logo: image: Unsupported image format"),
|
||||
"{toast}"
|
||||
);
|
||||
|
||||
let (_, headers, _) = post(
|
||||
"upload_branding_asset",
|
||||
vec![("kind", "logo".to_owned()), ("image", " ".to_owned())],
|
||||
)
|
||||
.await;
|
||||
assert!(toast_of(&headers).contains("Choose an image to upload"));
|
||||
let (_, headers, _) = post(
|
||||
"upload_branding_asset",
|
||||
vec![("kind", "banner".to_owned()), ("image", image.clone())],
|
||||
)
|
||||
.await;
|
||||
assert!(toast_of(&headers).contains("Unknown branding asset"));
|
||||
let (_, headers, _) = post(
|
||||
"update_app_public",
|
||||
vec![
|
||||
("app_product_name", "Fluxer".to_owned()),
|
||||
("app_logo_url", image.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(toast_of(&headers).contains("Invalid form data"));
|
||||
|
||||
assert_eq!(
|
||||
*received.lock().unwrap(),
|
||||
vec![
|
||||
json!({"kind": "logo", "image": image}),
|
||||
json!({"kind": "logo", "image": null}),
|
||||
json!({"kind": "logo", "image": "data:image/png;base64,bad"}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn instance_config(logo_url: Option<&str>) -> Value {
|
||||
json!({
|
||||
"sso": {},
|
||||
"gateway_rollout": {
|
||||
"session_rollout_percentage": 100,
|
||||
"session_rollout_mode": "modulo",
|
||||
"guild_rollout_percentage": 100,
|
||||
"rpc_request_timeout_ms": 10000,
|
||||
"max_concurrent_session_starts": 512,
|
||||
"max_concurrent_guild_starts": 256,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"self_hosted": true,
|
||||
"app_public": {"branding": {"product_name": "Fluxer", "logo_url": logo_url}}
|
||||
})
|
||||
}
|
||||
|
||||
async fn setup_with_api(api: Router) -> TestApp {
|
||||
let api_endpoint = spawn_mock_api(api).await;
|
||||
let router = build_router(test_config(api_endpoint));
|
||||
@@ -440,6 +840,19 @@ fn user(id: &str, username: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn pending_registration(user_id: &str) -> Value {
|
||||
json!({
|
||||
"user_id": user_id,
|
||||
"username": "applicant",
|
||||
"discriminator": 0,
|
||||
"global_name": null,
|
||||
"email": "[email protected]",
|
||||
"requested_at": "2026-10-14T12:00:00.000Z",
|
||||
"registration_url_id": null,
|
||||
"client_ip": "203.0.113.7"
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_report() -> Value {
|
||||
json!({
|
||||
"report_id": "1800000000000000001",
|
||||
@@ -455,6 +868,17 @@ fn searched_report() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn report_with_note() -> Value {
|
||||
let mut report = searched_report();
|
||||
report["notes"] = json!([{
|
||||
"note_id": "1950000000000000001",
|
||||
"author_id": "1500000000000000000",
|
||||
"body": "Reporter sent more context by email",
|
||||
"created_at": "2026-05-26T13:30:00.000Z"
|
||||
}]);
|
||||
report
|
||||
}
|
||||
|
||||
fn report_reasons() -> Value {
|
||||
json!({
|
||||
"reasons": [
|
||||
|
||||
@@ -43,87 +43,119 @@ async fn submit(uri: &str, fields: &str) -> CapturedRequest {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn temp_ban_sends_notify_user_from_the_checkbox() {
|
||||
let uri = format!("/users/{USER_ID}?action=temp_ban&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"duration=24&reason=Spam¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("PUT /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(&uri, "duration=24&reason=Spam¬ify_user_present=1").await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(&uri, "duration=24&reason=Spam").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unban_sends_the_public_reason_in_the_body_and_the_private_reason_as_a_header() {
|
||||
async fn unban_sends_the_internal_note_as_a_header_and_no_body() {
|
||||
let uri = format!("/users/{USER_ID}?action=unban&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"public_reason=Appeal%20accepted&private_reason=Private%20staff%20note¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("DELETE /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
assert_eq!(checked.body["public_reason"], json!("Appeal accepted"));
|
||||
let captured = submit(&uri, "internal_note=Private%20staff%20note").await;
|
||||
assert_eq!(captured.route, format!("DELETE /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(
|
||||
checked.audit_log_reason.as_deref(),
|
||||
captured.audit_log_reason.as_deref(),
|
||||
Some("Private staff note")
|
||||
);
|
||||
assert!(!checked.body.to_string().contains("Private staff note"));
|
||||
let unchecked = submit(
|
||||
assert!(!captured.body.to_string().contains("Private staff note"));
|
||||
}
|
||||
|
||||
const DECISION_FIELDS: &str = "reason_code=3&source=own_review&automation=none&statement=You%20sent%20the%20same%20link%20to%20many%20people.";
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_sends_the_basis_and_never_a_notify_flag() {
|
||||
let uri = format!("/users/{USER_ID}?action=close&tab=moderation");
|
||||
let holder = submit(
|
||||
&uri,
|
||||
"private_reason=Private%20staff%20note¬ify_user_present=1",
|
||||
"basis=holder_request&days_until_deletion=30&internal_note=Asked%20by%20email",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
assert!(!unchecked.body.to_string().contains("Private staff note"));
|
||||
let stale_form = submit(&uri, "").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
assert_eq!(holder.route, format!("PUT /admin/users/{USER_ID}/deletion"));
|
||||
assert_eq!(
|
||||
holder.body,
|
||||
json!({"basis": {"type": "holder_request"}, "days_until_deletion": 30})
|
||||
);
|
||||
assert_eq!(holder.audit_log_reason.as_deref(), Some("Asked by email"));
|
||||
let enforcement = submit(
|
||||
&uri,
|
||||
&format!(
|
||||
"basis=enforcement&days_until_deletion=74&{DECISION_FIELDS}&internal_note=Spam%20review&remove_evidence=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(enforcement.body["basis"]["type"], json!("enforcement"));
|
||||
assert_eq!(
|
||||
enforcement.body["basis"]["decision"]["reason_code"],
|
||||
json!(3)
|
||||
);
|
||||
assert_eq!(enforcement.body["remove_evidence"], json!(true));
|
||||
assert_eq!(enforcement.audit_log_reason.as_deref(), Some("Spam review"));
|
||||
assert!(enforcement.body.get("notify_user").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_deletion_sends_notify_user_from_the_checkbox() {
|
||||
let uri = format!("/users/{USER_ID}?action=schedule_deletion&tab=moderation");
|
||||
let checked = submit(
|
||||
async fn cancel_deletion_sends_only_the_pending_deletion() {
|
||||
let uri = format!("/users/{USER_ID}?action=cancel_deletion&tab=moderation");
|
||||
let captured = submit(
|
||||
&uri,
|
||||
"reason_code=3&days_until_deletion=60¬ify_user_present=1¬ify_user=true",
|
||||
"expected_pending_deletion_at=2026-12-01T00%3A00%3A00.000Z&confirm=true&internal_note=Asked%20to%20keep%20it",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
checked.route,
|
||||
format!("PUT /admin/users/{USER_ID}/deletion")
|
||||
captured.route,
|
||||
format!("DELETE /admin/users/{USER_ID}/deletion")
|
||||
);
|
||||
assert_eq!(
|
||||
captured.body,
|
||||
json!({"expected_pending_deletion_at": "2026-12-01T00:00:00.000Z"})
|
||||
);
|
||||
assert_eq!(
|
||||
captured.audit_log_reason.as_deref(),
|
||||
Some("Asked to keep it")
|
||||
);
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(
|
||||
&uri,
|
||||
"reason_code=3&days_until_deletion=60¬ify_user_present=1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(&uri, "reason_code=3&days_until_deletion=60").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_schedule_deletion_sends_notify_user_from_the_checkbox() {
|
||||
let uri = "/bulk-actions?action=bulk-schedule-user-deletion";
|
||||
let fields = format!("user_ids={USER_ID}&reason_code=3&days_until_deletion=60");
|
||||
let checked = submit(
|
||||
uri,
|
||||
&format!("{fields}¬ify_user_present=1¬ify_user=true"),
|
||||
async fn limit_features_sends_one_limit_with_its_end() {
|
||||
let uri = format!("/users/{USER_ID}?action=limit_features&tab=moderation");
|
||||
let captured = submit(
|
||||
&uri,
|
||||
"limit=reports&ends_on=2026-12-01&reason_code=29&source=own_review&automation=none&statement=False%20reports&internal_note=Report%20review",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, "POST /admin/bulk-jobs");
|
||||
assert_eq!(checked.body["task"], json!("schedule_user_deletion"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(uri, &format!("{fields}¬ify_user_present=1")).await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(uri, &fields).await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
assert_eq!(
|
||||
captured.route,
|
||||
format!("PUT /admin/users/{USER_ID}/feature-limits")
|
||||
);
|
||||
assert_eq!(captured.body["limit"], json!("reports"));
|
||||
assert_eq!(captured.body["ends_at"], json!("2026-12-01T00:00:00Z"));
|
||||
assert_eq!(captured.body["decision"]["reason_code"], json!(29));
|
||||
let purchases = submit(
|
||||
&uri,
|
||||
"limit=purchases&ends_on=2026-12-01&reason_code=14&source=own_review&automation=none&statement=Disputes&internal_note=Billing",
|
||||
)
|
||||
.await;
|
||||
assert!(purchases.body.get("ends_at").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_close_sends_one_bulk_decision() {
|
||||
let captured = submit(
|
||||
"/bulk-actions?action=bulk-schedule-user-deletion",
|
||||
&format!(
|
||||
"user_ids={USER_ID}&basis=enforcement&days_until_deletion=74&reason_code=3&automation=none&statement=Same%20invite%20link&internal_note=Spam%20sweep"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(captured.route, "POST /admin/bulk-jobs");
|
||||
assert_eq!(captured.body["task"], json!("schedule_user_deletion"));
|
||||
assert_eq!(
|
||||
captured.body["basis"],
|
||||
json!({
|
||||
"type": "enforcement",
|
||||
"decision": {
|
||||
"reason_code": 3,
|
||||
"source": "own_review",
|
||||
"automation": "none",
|
||||
"statement": "Same invite link"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(captured.audit_log_reason.as_deref(), Some("Spam sweep"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -131,12 +163,13 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
|
||||
let uri = format!("/reports/{REPORT_ID}/resolve");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"resolution=actioned&public_comment=Handled¬ify_reporter_present=1¬ify_reporter=true",
|
||||
"resolution=actioned&outcome=removed&public_comment=Handled¬ify_reporter_present=1¬ify_reporter=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("PATCH /admin/reports/{REPORT_ID}"));
|
||||
assert_eq!(checked.body["public_comment"], json!("Handled"));
|
||||
assert_eq!(checked.body["resolution"], json!("actioned"));
|
||||
assert_eq!(checked.body["outcome"], json!("removed"));
|
||||
assert_eq!(checked.body["notify_reporter"], json!(true));
|
||||
let unchecked = submit(
|
||||
&uri,
|
||||
@@ -144,8 +177,19 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_reporter"], json!(false));
|
||||
let stale_form = submit(&uri, "resolution=duplicate&public_comment=Handled").await;
|
||||
assert_eq!(stale_form.body["notify_reporter"], json!(true));
|
||||
assert_eq!(unchecked.body["outcome"], json!("no_violation"));
|
||||
let duplicate = submit(
|
||||
&uri,
|
||||
"resolution=duplicate&outcome=restricted&duplicate_of=1400000000000000001&public_comment=Handled",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(duplicate.body["notify_reporter"], json!(true));
|
||||
assert!(duplicate.body.get("outcome").is_none());
|
||||
assert_eq!(duplicate.body["duplicate_of"], json!("1400000000000000001"));
|
||||
let warned = submit(&uri, "resolution=actioned&outcome=warned").await;
|
||||
assert_eq!(warned.body["outcome"], json!("warned"));
|
||||
let other_action = submit(&uri, "resolution=actioned&outcome=action_taken").await;
|
||||
assert_eq!(other_action.body["outcome"], json!("action_taken"));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
|
||||
import type {EmailMessage} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function message(to: string): EmailMessage {
|
||||
return {to, from: {email: '[email protected]', name: 'Example'}, subject: 'Hello', text: 'Body'};
|
||||
}
|
||||
|
||||
describe('DomainRoutedEmailProvider', () => {
|
||||
it.each([
|
||||
{to: '[email protected]', route: 'fallback'},
|
||||
{to: '[email protected]', route: 'fallback'},
|
||||
{to: '[email protected]', route: 'fallback'},
|
||||
{to: '[email protected]', route: 'primary'},
|
||||
{to: '[email protected]', route: 'primary'},
|
||||
{to: '[email protected]', route: 'primary'},
|
||||
{to: '"[email protected]"@example.com', route: 'primary'},
|
||||
])('sends $to through the $route provider', async ({to, route}) => {
|
||||
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const fallback = {sendEmail: vi.fn().mockResolvedValue(false)};
|
||||
const provider = new DomainRoutedEmailProvider(primary, fallback, ['gmail.com', 'googlemail.com']);
|
||||
const sent = message(to);
|
||||
const result = await provider.sendEmail(sent);
|
||||
const [chosen, other] = route === 'fallback' ? [fallback, primary] : [primary, fallback];
|
||||
expect(chosen.sendEmail).toHaveBeenCalledWith(sent);
|
||||
expect(other.sendEmail).not.toHaveBeenCalled();
|
||||
expect(result).toBe(route === 'primary');
|
||||
});
|
||||
|
||||
it('sends every address through the fallback provider when the domains are *', async () => {
|
||||
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const provider = new DomainRoutedEmailProvider(primary, fallback, ['*']);
|
||||
await provider.sendEmail(message('[email protected]'));
|
||||
await provider.sendEmail(message('[email protected]'));
|
||||
expect(fallback.sendEmail).toHaveBeenCalledTimes(2);
|
||||
expect(primary.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never reaches either provider for a bounced address', async () => {
|
||||
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const service = new EmailService(
|
||||
{
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Example',
|
||||
appBaseUrl: 'https://example.com',
|
||||
appealsEmail: null,
|
||||
safetyEmail: null,
|
||||
supportEmail: null,
|
||||
productName: 'Example',
|
||||
},
|
||||
new DomainRoutedEmailProvider(primary, fallback, ['gmail.com']),
|
||||
{isEmailBounced: async () => true},
|
||||
);
|
||||
expect(await service.sendPasswordResetEmail('[email protected]', 'user', 'token')).toBe(false);
|
||||
expect(primary.sendEmail).not.toHaveBeenCalled();
|
||||
expect(fallback.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
|
||||
const logger = createLogger('@pkgs/email/src/DomainRoutedEmailProvider');
|
||||
|
||||
export class DomainRoutedEmailProvider implements IEmailProvider {
|
||||
private readonly primary: IEmailProvider;
|
||||
private readonly fallback: IEmailProvider;
|
||||
private readonly fallbackDomains: ReadonlySet<string>;
|
||||
|
||||
constructor(primary: IEmailProvider, fallback: IEmailProvider, fallbackDomains: ReadonlyArray<string>) {
|
||||
this.primary = primary;
|
||||
this.fallback = fallback;
|
||||
this.fallbackDomains = new Set(fallbackDomains);
|
||||
}
|
||||
|
||||
async sendEmail(message: EmailMessage): Promise<boolean> {
|
||||
const domain = message.to
|
||||
.slice(message.to.lastIndexOf('@') + 1)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const useFallback = this.fallbackDomains.has('*') || this.fallbackDomains.has(domain);
|
||||
logger.debug({domain, provider: useFallback ? 'fallback' : 'primary'}, 'Routing email');
|
||||
return (useFallback ? this.fallback : this.primary).sendEmail(message);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ export interface EmailConfig {
|
||||
fromName: string;
|
||||
replyTo?: string | null;
|
||||
appBaseUrl: string;
|
||||
termsUrl: string | null;
|
||||
guidelinesUrl: string | null;
|
||||
appealsEmail: string | null;
|
||||
safetyEmail: string | null;
|
||||
supportEmail: string | null;
|
||||
|
||||
@@ -4,8 +4,8 @@ import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailConfig, IEmailProvider, UserBouncedEmailChecker} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import type {
|
||||
EmailLegalLinks,
|
||||
EmailTemplateVariables,
|
||||
ReporterOutcome,
|
||||
ReportReceivedTargetKind,
|
||||
} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
@@ -21,20 +21,6 @@ function formatMinorUnitAmount(amountMinor: number, currency: string, locale: st
|
||||
return formatter.format(amountMinor / 10 ** fractionDigits);
|
||||
}
|
||||
|
||||
function optionalReason(reason: string | null): string | null {
|
||||
return reason?.trim() || null;
|
||||
}
|
||||
|
||||
function legalLinkVariables(config: EmailConfig): {
|
||||
termsUrl: string | null;
|
||||
guidelinesUrl: string | null;
|
||||
legalLinks: EmailLegalLinks;
|
||||
} {
|
||||
const {termsUrl, guidelinesUrl} = config;
|
||||
const legalLinks = termsUrl ? (guidelinesUrl ? 'both' : 'terms') : guidelinesUrl ? 'guidelines' : 'none';
|
||||
return {termsUrl, guidelinesUrl, legalLinks};
|
||||
}
|
||||
|
||||
export class EmailService implements IEmailService {
|
||||
private readonly config: EmailConfig;
|
||||
private readonly provider: IEmailProvider | null;
|
||||
@@ -90,40 +76,6 @@ export class EmailService implements IEmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountTempBannedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
durationHours: number,
|
||||
bannedUntil: Date,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_temp_banned', locale, {
|
||||
username,
|
||||
reason: optionalReason(reason),
|
||||
durationHours,
|
||||
bannedUntil,
|
||||
...legalLinkVariables(this.config),
|
||||
appeals_email: this.config.appealsEmail,
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountScheduledForDeletionEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_scheduled_deletion', locale, {
|
||||
username,
|
||||
reason: optionalReason(reason),
|
||||
deletionDate,
|
||||
...legalLinkVariables(this.config),
|
||||
appeals_email: this.config.appealsEmail,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSelfDeletionScheduledEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
@@ -136,14 +88,12 @@ export class EmailService implements IEmailService {
|
||||
async sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
deletionDate: string,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_deletion_scheduled_requested', locale, {
|
||||
username,
|
||||
reason: optionalReason(reason),
|
||||
deletionDate,
|
||||
deletion_date: deletionDate,
|
||||
safety_email: this.config.safetyEmail,
|
||||
});
|
||||
}
|
||||
@@ -151,15 +101,13 @@ export class EmailService implements IEmailService {
|
||||
async sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
deletionDate: string,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'account_deletion_scheduled_inactivity', locale, {
|
||||
username,
|
||||
reason: optionalReason(reason),
|
||||
deletionDate,
|
||||
safety_email: this.config.safetyEmail,
|
||||
deletion_date: deletionDate,
|
||||
support_email: this.config.supportEmail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,28 +122,20 @@ export class EmailService implements IEmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendUnbanNotification(
|
||||
async sendEnforcementNoticeEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
variables: EmailTemplateVariables['enforcement_notice'],
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'unban_notification', locale, {username, reason: optionalReason(reason)});
|
||||
return this.sendTemplatedEmail(email, 'enforcement_notice', locale, variables);
|
||||
}
|
||||
|
||||
async sendScheduledDeletionNotification(
|
||||
async sendEnforcementEndedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
deletionDate: Date,
|
||||
reason: string | null,
|
||||
variables: EmailTemplateVariables['enforcement_ended'],
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'scheduled_deletion_notification', locale, {
|
||||
username,
|
||||
deletionDate,
|
||||
reason: optionalReason(reason),
|
||||
appeals_email: this.config.appealsEmail,
|
||||
});
|
||||
return this.sendTemplatedEmail(email, 'enforcement_ended', locale, variables);
|
||||
}
|
||||
|
||||
async sendInactivityWarningEmail(
|
||||
@@ -250,6 +190,7 @@ export class EmailService implements IEmailService {
|
||||
username: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
outcome: ReporterOutcome,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'report_resolved', locale, {
|
||||
@@ -257,7 +198,8 @@ export class EmailService implements IEmailService {
|
||||
reportId,
|
||||
publicComment,
|
||||
hasComment: publicComment ? 'yes' : 'no',
|
||||
safety_email: this.config.safetyEmail,
|
||||
outcome,
|
||||
appeals_email: this.config.appealsEmail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,12 +207,14 @@ export class EmailService implements IEmailService {
|
||||
email: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
outcome: ReporterOutcome,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'dsa_report_resolved', locale, {
|
||||
reportId,
|
||||
publicComment,
|
||||
hasComment: publicComment ? 'yes' : 'no',
|
||||
outcome,
|
||||
appeals_email: this.config.appealsEmail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ReportReceivedTargetKind} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {
|
||||
EmailTemplateVariables,
|
||||
ReporterOutcome,
|
||||
ReportReceivedTargetKind,
|
||||
} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
|
||||
export interface IEmailService {
|
||||
sendPasswordResetEmail(email: string, username: string, resetToken: string, locale?: string | null): Promise<boolean>;
|
||||
@@ -18,21 +22,6 @@ export interface IEmailService {
|
||||
location: string,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountTempBannedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
durationHours: number,
|
||||
bannedUntil: Date,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountScheduledForDeletionEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendSelfDeletionScheduledEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
@@ -42,29 +31,24 @@ export interface IEmailService {
|
||||
sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
deletionDate: string,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
deletionDate: string,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendAccountDeletionCancelledEmail(email: string, username: string, locale?: string | null): Promise<boolean>;
|
||||
sendUnbanNotification(
|
||||
sendEnforcementNoticeEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
reason: string | null,
|
||||
variables: EmailTemplateVariables['enforcement_notice'],
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendScheduledDeletionNotification(
|
||||
sendEnforcementEndedEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
deletionDate: Date,
|
||||
reason: string | null,
|
||||
variables: EmailTemplateVariables['enforcement_ended'],
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendInactivityWarningEmail(
|
||||
@@ -89,12 +73,14 @@ export interface IEmailService {
|
||||
username: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
outcome: ReporterOutcome,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendDsaReportResolvedEmail(
|
||||
email: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
outcome: ReporterOutcome,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendReportReceivedEmail(
|
||||
|
||||
@@ -25,4 +25,33 @@ describe('SmtpEmailProvider', () => {
|
||||
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
|
||||
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
const message = {
|
||||
to: '[email protected]',
|
||||
from: {email: '[email protected]', name: 'Example'},
|
||||
subject: 'Hi',
|
||||
text: 'Body',
|
||||
};
|
||||
|
||||
it('sends the configured headers verbatim', async () => {
|
||||
const provider = new SmtpEmailProvider({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
headers: {'X-Tracking': '{"enable":0}'},
|
||||
});
|
||||
await provider.sendEmail(message);
|
||||
const {sendMail} = createTransport.mock.results[0].value;
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({headers: {'X-Tracking': {prepared: true, value: '{"enable":0}'}}}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends no extra headers when none are configured', async () => {
|
||||
const provider = new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
|
||||
await provider.sendEmail(message);
|
||||
const {sendMail} = createTransport.mock.results[0].value;
|
||||
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('headers');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ interface SmtpEmailConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
tlsMode?: SmtpTlsMode | null;
|
||||
headers?: Record<string, string>;
|
||||
connectionTimeoutMs?: number;
|
||||
greetingTimeoutMs?: number;
|
||||
socketTimeoutMs?: number;
|
||||
@@ -23,11 +24,17 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
private readonly host: string;
|
||||
private readonly port: number;
|
||||
private readonly tlsMode: SmtpTlsMode;
|
||||
private readonly headers: Record<string, {prepared: true; value: string}> | undefined;
|
||||
|
||||
constructor(config: SmtpEmailConfig) {
|
||||
this.host = config.host;
|
||||
this.port = config.port;
|
||||
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
|
||||
this.headers = config.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(config.headers).map(([name, value]) => [name, {prepared: true as const, value}]),
|
||||
)
|
||||
: undefined;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
@@ -55,6 +62,7 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
to: message.to,
|
||||
from: `${message.from.name} <${message.from.email}>`,
|
||||
...(message.replyTo ? {replyTo: message.replyTo} : {}),
|
||||
...(this.headers ? {headers: this.headers} : {}),
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ReportReceivedTargetKind} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {
|
||||
EmailTemplateVariables,
|
||||
ReporterOutcome,
|
||||
ReportReceivedTargetKind,
|
||||
} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
|
||||
export interface SentEmailRecord {
|
||||
@@ -10,6 +14,10 @@ export interface SentEmailRecord {
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
function stringMetadata(variables: Record<string, string | null>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, value ?? '']));
|
||||
}
|
||||
|
||||
export class TestEmailService implements IEmailService {
|
||||
private readonly sentEmails: Array<SentEmailRecord> = [];
|
||||
|
||||
@@ -50,34 +58,6 @@ export class TestEmailService implements IEmailService {
|
||||
return this.record(email, 'ip_authorization', {token: authorizationToken, ip: ipAddress, location});
|
||||
}
|
||||
|
||||
async sendAccountTempBannedEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
durationHours: number,
|
||||
bannedUntil: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'account_temp_banned', {
|
||||
reason: reason ?? '',
|
||||
duration_hours: durationHours.toString(10),
|
||||
banned_until: bannedUntil.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendAccountScheduledForDeletionEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'account_scheduled_deletion', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendSelfDeletionScheduledEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
@@ -90,53 +70,39 @@ export class TestEmailService implements IEmailService {
|
||||
async sendAccountDeletionRequestedEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
deletionDate: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'account_deletion_scheduled_requested', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
});
|
||||
return this.record(email, 'account_deletion_scheduled_requested', {deletion_date: deletionDate});
|
||||
}
|
||||
|
||||
async sendAccountDeletionInactivityEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
deletionDate: Date,
|
||||
deletionDate: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'account_deletion_scheduled_inactivity', {
|
||||
reason: reason ?? '',
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
});
|
||||
return this.record(email, 'account_deletion_scheduled_inactivity', {deletion_date: deletionDate});
|
||||
}
|
||||
|
||||
async sendAccountDeletionCancelledEmail(email: string, _username: string, _locale?: string | null): Promise<boolean> {
|
||||
return this.record(email, 'account_deletion_cancelled');
|
||||
}
|
||||
|
||||
async sendUnbanNotification(
|
||||
async sendEnforcementNoticeEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
reason: string | null,
|
||||
variables: EmailTemplateVariables['enforcement_notice'],
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'unban_notification', {reason: reason ?? ''});
|
||||
return this.record(email, 'enforcement_notice', stringMetadata(variables));
|
||||
}
|
||||
|
||||
async sendScheduledDeletionNotification(
|
||||
async sendEnforcementEndedEmail(
|
||||
email: string,
|
||||
_username: string,
|
||||
deletionDate: Date,
|
||||
reason: string | null,
|
||||
variables: EmailTemplateVariables['enforcement_ended'],
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'scheduled_deletion_notification', {
|
||||
deletion_date: deletionDate.toISOString(),
|
||||
reason: reason ?? '',
|
||||
});
|
||||
return this.record(email, 'enforcement_ended', stringMetadata(variables));
|
||||
}
|
||||
|
||||
async sendInactivityWarningEmail(
|
||||
@@ -178,18 +144,20 @@ export class TestEmailService implements IEmailService {
|
||||
_username: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
outcome: ReporterOutcome,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'report_resolved', {report_id: reportId, public_comment: publicComment});
|
||||
return this.record(email, 'report_resolved', {report_id: reportId, public_comment: publicComment, outcome});
|
||||
}
|
||||
|
||||
async sendDsaReportResolvedEmail(
|
||||
email: string,
|
||||
reportId: string,
|
||||
publicComment: string,
|
||||
outcome: ReporterOutcome,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
return this.record(email, 'dsa_report_resolved', {report_id: reportId, public_comment: publicComment});
|
||||
return this.record(email, 'dsa_report_resolved', {report_id: reportId, public_comment: publicComment, outcome});
|
||||
}
|
||||
|
||||
async sendReportReceivedEmail(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nT
|
||||
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
|
||||
const DEFAULT_LOCALE = 'en-US';
|
||||
const DOUBLED_PERIOD = /(?<!\.)\.\.(?!\.)/g;
|
||||
|
||||
function formatEmailDate(value: unknown, locale: string, style: string | null): string {
|
||||
const options: Intl.DateTimeFormatOptions = {timeZone: 'UTC', day: 'numeric', month: 'short', year: 'numeric'};
|
||||
@@ -57,8 +58,8 @@ const emailI18n = createStaticI18n<EmailTemplateKey, EmailTemplate, Record<strin
|
||||
messageFormatOptions: {customFormatters: {date: formatEmailDate, time: formatEmailTime}},
|
||||
},
|
||||
(template, variables, mf) => {
|
||||
const compiledSubject = String(compileMessage(mf, template.subject)(variables));
|
||||
const compiledBody = String(compileMessage(mf, template.body)(variables));
|
||||
const compiledSubject = String(compileMessage(mf, template.subject)(variables)).replace(DOUBLED_PERIOD, '.');
|
||||
const compiledBody = String(compileMessage(mf, template.body)(variables)).replace(DOUBLED_PERIOD, '.');
|
||||
return {subject: compiledSubject, body: compiledBody};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,44 +14,53 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
|
||||
account_deletion_cancelled: {username: 'testuser', safety_email: '[email protected]'},
|
||||
account_deletion_scheduled_inactivity: {
|
||||
username: 'testuser',
|
||||
reason: 'Inactive',
|
||||
deletionDate: DATE,
|
||||
safety_email: '[email protected]',
|
||||
deletion_date: 'Dec 1, 2026',
|
||||
support_email: '[email protected]',
|
||||
},
|
||||
account_deletion_scheduled_requested: {
|
||||
username: 'testuser',
|
||||
reason: 'Requested',
|
||||
deletionDate: DATE,
|
||||
deletion_date: 'Dec 14, 2026',
|
||||
safety_email: '[email protected]',
|
||||
},
|
||||
account_scheduled_deletion: {
|
||||
username: 'testuser',
|
||||
reason: 'Spam',
|
||||
deletionDate: DATE,
|
||||
termsUrl: 'https://example.com/terms',
|
||||
guidelinesUrl: 'https://example.com/guidelines',
|
||||
legalLinks: 'both',
|
||||
appeals_email: '[email protected]',
|
||||
},
|
||||
account_temp_banned: {
|
||||
username: 'testuser',
|
||||
reason: 'Spam',
|
||||
durationHours: 24,
|
||||
bannedUntil: DATE,
|
||||
termsUrl: 'https://example.com/terms',
|
||||
guidelinesUrl: 'https://example.com/guidelines',
|
||||
legalLinks: 'both',
|
||||
appeals_email: '[email protected]',
|
||||
},
|
||||
donation_confirmation: {amount: '$5.00', currency: 'USD', interval: 'month', manageUrl: 'https://example.com/m'},
|
||||
donation_magic_link: {manageUrl: 'https://example.com/m', expiresAt: DATE},
|
||||
dsa_report_resolved: {reportId: '1', publicComment: 'Thanks', hasComment: 'yes', appeals_email: '[email protected]'},
|
||||
dsa_report_resolved: {
|
||||
reportId: '1',
|
||||
publicComment: 'Thanks',
|
||||
hasComment: 'yes',
|
||||
outcome: 'removed',
|
||||
appeals_email: '[email protected]',
|
||||
},
|
||||
dsa_report_verification: {code: '123456', expiresAt: DATE},
|
||||
email_change_new: {username: 'testuser', code: '123456', expiresAt: DATE},
|
||||
email_change_original: {username: 'testuser', code: '123456', expiresAt: DATE},
|
||||
email_change_revert: {username: 'testuser', newEmail: '[email protected]', revertUrl: 'https://example.com/r'},
|
||||
email_verification: {username: 'testuser', verifyUrl: 'https://example.com/verify'},
|
||||
gift_chargeback_notification: {username: 'testuser', support_email: '[email protected]'},
|
||||
enforcement_ended: {
|
||||
username: 'testuser',
|
||||
outcome: 'reversed',
|
||||
decision_id: '123',
|
||||
restores_access: 'yes',
|
||||
statement: 'We checked again',
|
||||
content_removed: 'no',
|
||||
},
|
||||
enforcement_notice: {
|
||||
username: 'testuser',
|
||||
kind_label: 'suspension',
|
||||
neutral: 'no',
|
||||
summary: 'We suspended your account.',
|
||||
effects: 'You cannot sign in.',
|
||||
rule_title: 'Spam',
|
||||
rule_url: 'https://example.com/guidelines',
|
||||
facts: 'We found spam.',
|
||||
statement: null,
|
||||
how_we_decided: 'A person decided.',
|
||||
appeals_email: '[email protected]',
|
||||
appeal_deadline: 'Dec 1, 2026',
|
||||
redress: null,
|
||||
decision_id: '123',
|
||||
},
|
||||
harvest_completed: {
|
||||
username: 'testuser',
|
||||
downloadUrl: 'https://example.com/d',
|
||||
@@ -82,16 +91,10 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
|
||||
reportId: '1',
|
||||
publicComment: 'Thanks',
|
||||
hasComment: 'yes',
|
||||
safety_email: '[email protected]',
|
||||
},
|
||||
scheduled_deletion_notification: {
|
||||
username: 'testuser',
|
||||
deletionDate: DATE,
|
||||
reason: 'Payment fraud',
|
||||
outcome: 'restricted',
|
||||
appeals_email: '[email protected]',
|
||||
},
|
||||
self_deletion_scheduled: {username: 'testuser', deletionDate: DATE},
|
||||
unban_notification: {username: 'testuser', reason: 'Appeal accepted'},
|
||||
};
|
||||
|
||||
describe('EmailI18n locale files', () => {
|
||||
@@ -111,6 +114,16 @@ describe('EmailI18n locale files', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s gives every reporter outcome its own wording', (locale) => {
|
||||
for (const key of ['report_resolved', 'dsa_report_resolved'] as const) {
|
||||
const bodies = (['removed', 'restricted', 'action_taken', 'no_violation'] as const).map((outcome) => {
|
||||
const result = getEmailTemplate(key, locale, {...FIXTURE[key], outcome}, 'Fluxer');
|
||||
return result.ok ? result.value.body : null;
|
||||
});
|
||||
expect(bodies, key).not.toContain(null);
|
||||
expect(new Set(bodies).size, key).toBe(bodies.length);
|
||||
}
|
||||
});
|
||||
it.each(['en-US', ...LOCALES])('%s renders every template with UTC times', (locale) => {
|
||||
for (const key of TEMPLATE_KEYS) {
|
||||
const result = getEmailTemplate(key, locale, FIXTURE[key], 'Fluxer');
|
||||
|
||||
@@ -7,19 +7,11 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
},
|
||||
account_deletion_scheduled_inactivity: {
|
||||
subject: 'Your {product_name} account will be deleted due to inactivity',
|
||||
body: 'Hello {username},\n\nYour {product_name} account has been inactive for a long time, so it is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\n{safety_email, select, null {If you want to keep your account, contact the administrators of this instance before that date.} other {If you want to keep your account, contact {safety_email} from this email address before that date.}}\n\n– {product_name} Team',
|
||||
body: 'Hello {username},\n\nYour {product_name} account has been inactive for a long time, so we scheduled it for deletion on {deletion_date}.\n\nTo keep your account, sign in before that date. {support_email, select, null {If you have questions, contact the administrators of this instance.} other {If you have questions, contact {support_email}.}}\n\nThe {product_name} team',
|
||||
},
|
||||
account_deletion_scheduled_requested: {
|
||||
subject: 'Your {product_name} account deletion is scheduled',
|
||||
body: "Hello {username},\n\nAs you requested, your {product_name} account is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\nYour account is locked until then. {safety_email, select, null {If you didn't request this, or you want to keep your account, contact the administrators of this instance before that date.} other {If you didn't request this, or you want to keep your account, contact {safety_email} from this email address before that date.}}\n\n– {product_name} Team",
|
||||
},
|
||||
account_scheduled_deletion: {
|
||||
subject: 'Your {product_name} account will be permanently deleted',
|
||||
body: 'Hello {username},\n\nYour {product_name} account has been scheduled for permanent deletion due to violations of our Terms of Service or Community Guidelines.\n\nScheduled deletion: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.\n\n{legalLinks, select,\n both {Please review:\n- Terms of Service: {termsUrl}\n- Community Guidelines: {guidelinesUrl}\n\n}\n terms {Please review:\n- Terms of Service: {termsUrl}\n\n}\n guidelines {Please review:\n- Community Guidelines: {guidelinesUrl}\n\n}\n other {}\n}Appeals process:\nIf you believe this enforcement decision was incorrect or unjustified, you have 60 days to submit an appeal. {appeals_email, select, null {Contact the administrators of this instance.} other {Email {appeals_email} from this email address.}}\n\nIn your appeal:\n- Clearly explain why you believe the enforcement decision was incorrect or unjustified\n- Provide any relevant evidence or context\n\nA member of the {product_name} Safety Team will review your appeal and may pause the pending deletion until a final decision has been reached.\n\n– {product_name} Safety Team',
|
||||
},
|
||||
account_temp_banned: {
|
||||
subject: 'Your {product_name} account has been temporarily suspended',
|
||||
body: "Hello {username},\n\nYour {product_name} account has been temporarily suspended for violating our Terms of Service or Community Guidelines.\n\nDuration: {durationHours, plural,\n =1 {1 hour}\n other {# hours}\n}\nSuspended until: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}During this time, you won't be able to access your account.\n\n{legalLinks, select,\n both {Please review:\n- Terms of Service: {termsUrl}\n- Community Guidelines: {guidelinesUrl}\n\n}\n terms {Please review:\n- Terms of Service: {termsUrl}\n\n}\n guidelines {Please review:\n- Community Guidelines: {guidelinesUrl}\n\n}\n other {}\n}If you believe this enforcement decision was incorrect or unjustified, you can submit an appeal. {appeals_email, select, null {Contact the administrators of this instance and clearly explain why you believe the decision was incorrect.} other {Email {appeals_email} from this email address and clearly explain why you believe the decision was incorrect.}} We'll review your appeal and respond with our decision.\n\n– {product_name} Safety Team",
|
||||
body: "Hello {username},\n\nAs you asked, we scheduled your {product_name} account for deletion on {deletion_date}.\n\nTo keep your account, sign in before {deletion_date}. {safety_email, select, null {If you didn't ask for this, contact the administrators of this instance.} other {If you didn't ask for this, contact {safety_email}.}}\n\nThe {product_name} team",
|
||||
},
|
||||
donation_confirmation: {
|
||||
subject: 'Thank you for your {product_name} donation',
|
||||
@@ -31,7 +23,7 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
},
|
||||
dsa_report_resolved: {
|
||||
subject: 'We made a decision on your {product_name} report',
|
||||
body: "Hello,\n\nOur Safety Team has reviewed your Digital Services Act report (ID: {reportId}) and made a decision.{hasComment, select, yes {\n\nResponse from the Safety Team:\n{publicComment}} other {}}\n\nWe don't share details of any action taken against another person's account, because that is their personal data.\n\nIf you disagree with this decision, you can appeal free of charge within 60 days. {appeals_email, select, null {Contact the administrators of this instance, include your report ID, and explain why you think the decision is wrong.} other {Email {appeals_email} from this email address, include your report ID, and explain why you think the decision is wrong.}} If you are in the EU, you can also refer the dispute to a certified out-of-court dispute settlement body. None of this affects your right to go to court.\n\n– {product_name} Safety Team",
|
||||
body: "Hello,\n\nWe reviewed your report (ID: {reportId}).\n\n{outcome, select, removed {We removed the content you reported because it broke our rules.} restricted {We found that the account you reported broke our rules, and we restricted it. We don't share what action we took against another account, because that is their personal data.} action_taken {We found that what you reported broke our rules, and we took action. We don't share what action we took against another account, because that is their personal data.} no_violation {We found that the content or account you reported doesn't break our rules, so we left it as it is.} other {We closed your report.}}{hasComment, select, yes {\n\nNote from our team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nIf you disagree, email {appeals_email} within 60 days and include the report ID. If you're in the EU, you can also refer this to a certified out-of-court dispute settlement body or complain to the Digital Services Coordinator in your country.}}\n\nThe {product_name} team",
|
||||
},
|
||||
dsa_report_verification: {
|
||||
subject: 'Verify your email for a DSA report',
|
||||
@@ -57,6 +49,16 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
subject: 'Perks from your redeemed gift have been removed',
|
||||
body: "Hello {username},\n\nA gift code you redeemed was originally paid for by someone else. That payment has since been reversed (a chargeback).\n\nBecause of this, we've removed the perks that were added to your account when you redeemed the gift.\n\n{support_email, select, null {If you think this is a mistake, please contact the administrators of this instance and include any details you have about the gift code and when you redeemed it.} other {If you think this is a mistake, please contact our support team and include any details you have about the gift code and when you redeemed it.}}\n\n– {product_name} Team",
|
||||
},
|
||||
enforcement_ended: {
|
||||
subject:
|
||||
'{outcome, select, reversed {We reversed a decision about your {product_name} account} other {A restriction on your {product_name} account has ended}}',
|
||||
body: "Hello {username},\n\n{outcome, select, reversed {{decision_id, select, null {We looked again at a decision about your account and reversed it.} other {We looked again at decision {decision_id} and reversed it.}} We're sorry for the trouble.} other {{decision_id, select, null {We lifted the restriction on your account.} other {We lifted the restriction from decision {decision_id}.}}}}{restores_access, select, yes { You can sign in and use {product_name} again.} other {}}{statement, select, null {} other {\n\nNote from our team: {statement}}}{content_removed, select, yes {\n\nContent we removed can't be restored.} other {}}\n\nThe {product_name} team",
|
||||
},
|
||||
enforcement_notice: {
|
||||
subject:
|
||||
'{neutral, select, yes {An update about your {product_name} account} other {{kind_label, select, warning {A warning about your {product_name} account} suspension {Your {product_name} account is suspended} ban {Your {product_name} account is banned} closure {Your {product_name} account is closed} feature_limit {Some features are limited on your {product_name} account} content_removal {We removed messages you sent on {product_name}} profile_reset {We reset parts of your {product_name} profile} community_removal {We removed your community on {product_name}} bot {An update about your bot on {product_name}} other {An update about your {product_name} account}}}}',
|
||||
body: 'Hello {username},\n\n{summary}\n\n{effects}\n\nWhy\nRule: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNote from our team: {statement}}}\n\nHow we decided\n{how_we_decided}{appeals_email, select, null {} other {\n\nHow to appeal\nIf you think we got this wrong, email {appeals_email} by {appeal_deadline}. Write from the email address on your account if you can, and include the decision ID. Appeals are free, and a person reviews each one.{redress, select, null {} other {\n\n{redress}}}}}\n\nDecision ID: {decision_id}\n\nThe {product_name} team',
|
||||
},
|
||||
harvest_completed: {
|
||||
subject: 'Your {product_name} data export is ready to download',
|
||||
body: "Hello {username},\n\nYour data export is ready.\n\nDownload link:\n{downloadUrl}\n\nMessages included: {totalMessages, number}\nFile size: {fileSizeMB, number} MB\n\nThis link expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\n{support_email, select, null {If you didn't request this export, please change your password immediately and contact the administrators of this instance.} other {If you didn't request this export, please change your password immediately and contact our support team.}}\n\n– {product_name} Team",
|
||||
@@ -87,18 +89,10 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
},
|
||||
report_resolved: {
|
||||
subject: 'Your {product_name} report has been reviewed',
|
||||
body: 'Hello {username},\n\nYour report (ID: {reportId}) has been reviewed by our Safety Team.{hasComment, select, yes {\n\nResponse from the Safety Team:\n{publicComment}} other {}}\n\nThanks for helping keep {product_name} safe for everyone. We take all reports seriously and appreciate your contribution to the community.\n\n{safety_email, select, null {If you have any questions or concerns about this outcome, please contact the administrators of this instance.} other {If you have any questions or concerns about this outcome, please contact {safety_email}.}}\n\n– {product_name} Safety Team',
|
||||
},
|
||||
scheduled_deletion_notification: {
|
||||
subject: 'Your {product_name} account will be permanently deleted',
|
||||
body: 'Hello {username},\n\nYour {product_name} account has been scheduled for permanent deletion.\n\nScheduled deletion: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.\n\nIf you believe this enforcement decision was incorrect, you can submit an appeal. {appeals_email, select, null {Contact the administrators of this instance.} other {Email {appeals_email} from this email address.}}\n\n– {product_name} Safety Team',
|
||||
body: "Hello {username},\n\nWe reviewed your report (ID: {reportId}).\n\n{outcome, select, removed {We removed the content you reported because it broke our rules.} restricted {We found that the account you reported broke our rules, and we restricted it. We don't share what action we took against another account, because that is their personal data.} action_taken {We found that what you reported broke our rules, and we took action. We don't share what action we took against another account, because that is their personal data.} no_violation {We found that the content or account you reported doesn't break our rules, so we left it as it is.} other {We closed your report.}}{hasComment, select, yes {\n\nNote from our team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nIf you disagree, email {appeals_email} within 60 days and include the report ID. If you're in the EU, you can also refer this to a certified out-of-court dispute settlement body or complain to the Digital Services Coordinator in your country.}}\n\nThe {product_name} team",
|
||||
},
|
||||
self_deletion_scheduled: {
|
||||
subject: 'Your {product_name} account deletion is scheduled',
|
||||
body: "Hello {username},\n\nYou requested to delete your {product_name} account. Your account is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}\n\nIf you didn't request this, sign in to your account to cancel the deletion. We also recommend changing your password to secure your account.\n\n– {product_name} Team",
|
||||
},
|
||||
unban_notification: {
|
||||
subject: 'Your {product_name} account suspension has been lifted',
|
||||
body: 'Hello {username},\n\nGood news: your {product_name} account suspension has been lifted.\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}You can now log back in and continue using {product_name} as normal.\n\n– {product_name} Safety Team',
|
||||
},
|
||||
} as const;
|
||||
@@ -4,8 +4,6 @@ export type EmailTemplateKey =
|
||||
| 'account_deletion_cancelled'
|
||||
| 'account_deletion_scheduled_inactivity'
|
||||
| 'account_deletion_scheduled_requested'
|
||||
| 'account_scheduled_deletion'
|
||||
| 'account_temp_banned'
|
||||
| 'donation_confirmation'
|
||||
| 'donation_magic_link'
|
||||
| 'dsa_report_resolved'
|
||||
@@ -14,6 +12,8 @@ export type EmailTemplateKey =
|
||||
| 'email_change_original'
|
||||
| 'email_change_revert'
|
||||
| 'email_verification'
|
||||
| 'enforcement_ended'
|
||||
| 'enforcement_notice'
|
||||
| 'gift_chargeback_notification'
|
||||
| 'harvest_completed'
|
||||
| 'inactivity_warning'
|
||||
@@ -23,9 +23,7 @@ export type EmailTemplateKey =
|
||||
| 'password_reset'
|
||||
| 'report_received'
|
||||
| 'report_resolved'
|
||||
| 'scheduled_deletion_notification'
|
||||
| 'self_deletion_scheduled'
|
||||
| 'unban_notification';
|
||||
| 'self_deletion_scheduled';
|
||||
|
||||
export interface EmailTemplate {
|
||||
subject: string;
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
export type ReportReceivedTargetKind = 'message' | 'user' | 'guild';
|
||||
|
||||
export type EmailLegalLinks = 'both' | 'terms' | 'guidelines' | 'none';
|
||||
export type ReporterOutcome = 'removed' | 'restricted' | 'action_taken' | 'no_violation';
|
||||
|
||||
export type EnforcementNoticeKindLabel =
|
||||
| 'warning'
|
||||
| 'suspension'
|
||||
| 'ban'
|
||||
| 'closure'
|
||||
| 'feature_limit'
|
||||
| 'content_removal'
|
||||
| 'profile_reset'
|
||||
| 'community_removal'
|
||||
| 'bot';
|
||||
|
||||
export interface EmailTemplateVariables {
|
||||
account_deletion_cancelled: {
|
||||
@@ -11,35 +22,14 @@ export interface EmailTemplateVariables {
|
||||
};
|
||||
account_deletion_scheduled_inactivity: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
deletionDate: Date;
|
||||
safety_email: string | null;
|
||||
deletion_date: string;
|
||||
support_email: string | null;
|
||||
};
|
||||
account_deletion_scheduled_requested: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
deletionDate: Date;
|
||||
deletion_date: string;
|
||||
safety_email: string | null;
|
||||
};
|
||||
account_scheduled_deletion: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
deletionDate: Date;
|
||||
termsUrl: string | null;
|
||||
guidelinesUrl: string | null;
|
||||
legalLinks: EmailLegalLinks;
|
||||
appeals_email: string | null;
|
||||
};
|
||||
account_temp_banned: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
durationHours: number;
|
||||
bannedUntil: Date;
|
||||
termsUrl: string | null;
|
||||
guidelinesUrl: string | null;
|
||||
legalLinks: EmailLegalLinks;
|
||||
appeals_email: string | null;
|
||||
};
|
||||
donation_confirmation: {
|
||||
amount: string;
|
||||
currency: string;
|
||||
@@ -54,6 +44,7 @@ export interface EmailTemplateVariables {
|
||||
reportId: string;
|
||||
publicComment: string;
|
||||
hasComment: 'yes' | 'no';
|
||||
outcome: ReporterOutcome;
|
||||
appeals_email: string | null;
|
||||
};
|
||||
dsa_report_verification: {
|
||||
@@ -83,6 +74,30 @@ export interface EmailTemplateVariables {
|
||||
username: string;
|
||||
support_email: string | null;
|
||||
};
|
||||
enforcement_ended: {
|
||||
username: string;
|
||||
outcome: 'lifted' | 'reversed';
|
||||
decision_id: string | null;
|
||||
restores_access: 'yes' | 'no';
|
||||
statement: string | null;
|
||||
content_removed: 'yes' | 'no';
|
||||
};
|
||||
enforcement_notice: {
|
||||
username: string;
|
||||
kind_label: EnforcementNoticeKindLabel;
|
||||
neutral: 'yes' | 'no';
|
||||
summary: string;
|
||||
effects: string;
|
||||
rule_title: string;
|
||||
rule_url: string | null;
|
||||
facts: string;
|
||||
statement: string | null;
|
||||
how_we_decided: string;
|
||||
appeals_email: string | null;
|
||||
appeal_deadline: string;
|
||||
redress: string | null;
|
||||
decision_id: string;
|
||||
};
|
||||
harvest_completed: {
|
||||
username: string;
|
||||
downloadUrl: string;
|
||||
@@ -127,20 +142,11 @@ export interface EmailTemplateVariables {
|
||||
reportId: string;
|
||||
publicComment: string;
|
||||
hasComment: 'yes' | 'no';
|
||||
safety_email: string | null;
|
||||
};
|
||||
scheduled_deletion_notification: {
|
||||
username: string;
|
||||
deletionDate: Date;
|
||||
reason: string | null;
|
||||
outcome: ReporterOutcome;
|
||||
appeals_email: string | null;
|
||||
};
|
||||
self_deletion_scheduled: {
|
||||
username: string;
|
||||
deletionDate: Date;
|
||||
};
|
||||
unban_notification: {
|
||||
username: string;
|
||||
reason: string | null;
|
||||
};
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
|
||||
"body": "مرحبًا {username}،\n\nلم يكن حسابك في {product_name} نشطًا لفترة طويلة، لذا تمت جدولته للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\n{safety_email, select, null {إذا كنت ترغب في الاحتفاظ بحسابك، فتواصل مع مسؤولي هذا المثيل قبل ذلك التاريخ.} other {إذا كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.}}\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nلم يكن حسابك في {product_name} نشطًا لفترة طويلة، لذا جدولنا حذفه في {deletion_date}.\n\nللاحتفاظ بحسابك، سجّل الدخول قبل ذلك التاريخ. {support_email, select, null {إذا كانت لديك أسئلة، فتواصل مع مسؤولي هذا المثيل.} other {إذا كانت لديك أسئلة، فتواصل مع {support_email}.}}\n\nفريق {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "تمت جدولة حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nبناءً على طلبك، تمت جدولة حسابك في {product_name} للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}{reason, select, null {} other {\n\nالسبب: {reason}}}\n\nسيظل حسابك مقفلًا حتى ذلك الحين. {safety_email, select, null {إذا لم تطلب هذا، أو كنت ترغب في الاحتفاظ بحسابك، فتواصل مع مسؤولي هذا المثيل قبل ذلك التاريخ.} other {إذا لم تطلب هذا، أو كنت ترغب في الاحتفاظ بحسابك، فاتصل بـ{safety_email} من عنوان البريد الإلكتروني هذا قبل ذلك التاريخ.}}\n\n– فريق {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
|
||||
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}هذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\n{legalLinks, select,\n both {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n terms {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n\n}\n guidelines {يرجى مراجعة:\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n other {}\n}عملية الاستئناف:\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، لديك 60 يومًا لتقديم استئناف. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.}}\n\nفي استئنافك:\n- اشرح بوضوح سبب اعتقادك أن قرار الإنفاذ كان غير صحيح أو غير مبرر\n- قدم أي دليل أو سياق ذي صلة\n\nسيقوم عضو من فريق أمان {product_name} بمراجعة استئنافك وقد يوقف الحذف المعلق مؤقتًا إلى حين التوصل إلى قرار نهائي.\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "تم تعليق حسابك في {product_name} مؤقتًا",
|
||||
"body": "مرحبًا {username}،\n\nتم تعليق حسابك في {product_name} مؤقتًا لانتهاك شروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالمدة: {durationHours, plural,\n =1 {ساعة واحدة}\n zero {# ساعة}\n two {ساعتان}\n few {# ساعات}\n many {# ساعة}\n other {# ساعة}\n}\nمعلق حتى: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}خلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.\n\n{legalLinks, select,\n both {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n terms {يرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n\n}\n guidelines {يرجى مراجعة:\n- إرشادات المجتمع: {guidelinesUrl}\n\n}\n other {}\n}إذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، يمكنك تقديم استئناف. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل واشرح بوضوح سبب اعتقادك أن القرار كان غير صحيح.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا واشرح بوضوح سبب اعتقادك أن القرار كان غير صحيح.}} سنراجع استئنافك ونرد بقرارنا.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nبناءً على طلبك، جدولنا حذف حسابك في {product_name} في {deletion_date}.\n\nللاحتفاظ بحسابك، سجّل الدخول قبل {deletion_date}. {safety_email, select, null {إذا لم تطلب ذلك، فتواصل مع مسؤولي هذا المثيل.} other {إذا لم تطلب ذلك، فتواصل مع {safety_email}.}}\n\nفريق {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "شكرًا لك على تبرعك لـ{product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "اتخذنا قرارًا بشأن بلاغك في {product_name}",
|
||||
"body": "مرحبًا،\n\nراجع فريق الأمان لدينا بلاغك بموجب قانون الخدمات الرقمية (المعرف: {reportId}) واتخذ قرارًا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nلا نشارك تفاصيل أي إجراء يُتخذ بحق حساب شخص آخر، لأنها بياناته الشخصية.\n\nإذا كنت لا توافق على هذا القرار، يمكنك تقديم استئناف مجانًا خلال 60 يومًا. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل، واذكر معرف بلاغك، واشرح سبب اعتقادك أن القرار غير صحيح.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا، واذكر معرف بلاغك، واشرح سبب اعتقادك أن القرار غير صحيح.}} إذا كنت في الاتحاد الأوروبي، يمكنك أيضًا إحالة النزاع إلى هيئة معتمدة لتسوية المنازعات خارج المحكمة. لا يؤثر أي من ذلك على حقك في اللجوء إلى القضاء.\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا،\n\nراجعنا بلاغك (المعرّف: {reportId}).\n\n{outcome, select, removed {أزلنا المحتوى الذي أبلغت عنه لأنه خالف قواعدنا.} restricted {وجدنا أن الحساب الذي أبلغت عنه خالف قواعدنا، وقيّدناه. لا نشارك الإجراء الذي اتخذناه ضد حساب آخر، لأن ذلك من بيانات صاحبه الشخصية.} action_taken {وجدنا أن ما أبلغت عنه خالف قواعدنا، واتخذنا إجراءً. لا نشارك الإجراء الذي اتخذناه ضد حساب آخر، لأن ذلك من بيانات صاحبه الشخصية.} no_violation {وجدنا أن المحتوى أو الحساب الذي أبلغت عنه لا يخالف قواعدنا، لذا تركناه كما هو.} other {أغلقنا بلاغك.}}{hasComment, select, yes {\n\nملاحظة من فريقنا: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nإذا كنت لا توافق، فراسل {appeals_email} خلال 60 يومًا واذكر معرّف البلاغ. إذا كنت في الاتحاد الأوروبي، يمكنك أيضًا إحالة الأمر إلى هيئة معتمدة لتسوية النزاعات خارج المحاكم أو تقديم شكوى إلى منسق الخدمات الرقمية (Digital Services Coordinator) في بلدك.}}\n\nفريق {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "تحقق من بريدك الإلكتروني لتقديم بلاغ DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "تحقق من بريدك الإلكتروني في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nيرجى التحقق من عنوان البريد الإلكتروني لحسابك في {product_name} بالنقر على الرابط أدناه:\n\n{verifyUrl}\n\nإذا لم تقم بإنشاء حساب {product_name}، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة 24 ساعة.\n\n– فريق {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {ألغينا قرارًا بشأن حسابك في {product_name}} other {انتهى تقييد على حسابك في {product_name}}}",
|
||||
"body": "مرحبًا {username}،\n\n{outcome, select, reversed {{decision_id, select, null {راجعنا مجددًا قرارًا بشأن حسابك وألغيناه.} other {راجعنا مجددًا القرار {decision_id} وألغيناه.}} نعتذر عن الإزعاج.} other {{decision_id, select, null {رفعنا التقييد عن حسابك.} other {رفعنا التقييد الناتج عن القرار {decision_id}.}}}}{restores_access, select, yes { يمكنك تسجيل الدخول واستخدام {product_name} مجددًا.} other {}}{statement, select, null {} other {\n\nملاحظة من فريقنا: {statement}}}{content_removed, select, yes {\n\nلا يمكن استعادة المحتوى الذي أزلناه.} other {}}\n\nفريق {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {تحديث بشأن حسابك في {product_name}} other {{kind_label, select, warning {تحذير بشأن حسابك في {product_name}} suspension {تم تعليق حسابك في {product_name}} ban {تم حظر حسابك في {product_name}} closure {تم إغلاق حسابك في {product_name}} feature_limit {تم تقييد بعض الميزات في حسابك في {product_name}} content_removal {أزلنا رسائل أرسلتها في {product_name}} profile_reset {أعدنا تعيين أجزاء من ملفك الشخصي في {product_name}} community_removal {أزلنا مجتمعك في {product_name}} bot {تحديث بشأن البوت الخاص بك في {product_name}} other {تحديث بشأن حسابك في {product_name}}}}}",
|
||||
"body": "مرحبًا {username}،\n\n{summary}\n\n{effects}\n\nالسبب\nالقاعدة: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nملاحظة من فريقنا: {statement}}}\n\nكيف اتخذنا القرار\n{how_we_decided}{appeals_email, select, null {} other {\n\nكيفية الطعن\nإذا كنت تعتقد أننا أخطأنا، فراسل {appeals_email} في موعد أقصاه {appeal_deadline}. راسلنا من عنوان البريد الإلكتروني المرتبط بحسابك إن أمكن، واذكر معرّف القرار. الطعن مجاني، ويراجع كل طعن شخص من فريقنا.{redress, select, null {} other {\n\n{redress}}}}}\n\nمعرّف القرار: {decision_id}\n\nفريق {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "تمت إزالة مزايا الهدية التي استرددتها",
|
||||
"body": "مرحبًا {username}،\n\nدفع شخص آخر في الأصل ثمن رمز الهدية الذي استرددته، وقد تم التراجع عن تلك الدفعة لاحقًا (رد مبلغ مدفوع).\n\nولهذا السبب أزلنا المزايا التي أُضيفت إلى حسابك عند استرداد الهدية.\n\n{support_email, select, null {إذا كنت تعتقد أن هذا خطأ، يرجى التواصل مع مسؤولي هذا المثيل مع ذكر أي تفاصيل لديك عن رمز الهدية ووقت استردادك له.} other {إذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بفريق الدعم لدينا مع ذكر أي تفاصيل لديك عن رمز الهدية ووقت استردادك له.}}\n\n– فريق {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "تمت مراجعة بلاغك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nتمت مراجعة بلاغك (المعرف: {reportId}) من قبل فريق الأمان لدينا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nشكرًا لمساعدتك في الحفاظ على {product_name} آمنًا للجميع. نحن نأخذ جميع البلاغات على محمل الجد ونقدر مساهمتك في المجتمع.\n\n{safety_email, select, null {إذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى التواصل مع مسؤولي هذا المثيل.} other {إذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى الاتصال بـ{safety_email}.}}\n\n– فريق أمان {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
|
||||
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}هذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح، يمكنك تقديم استئناف. {appeals_email, select, null {تواصل مع مسؤولي هذا المثيل.} other {أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.}}\n\n– فريق أمان {product_name}"
|
||||
"body": "مرحبًا {username}،\n\nراجعنا بلاغك (المعرّف: {reportId}).\n\n{outcome, select, removed {أزلنا المحتوى الذي أبلغت عنه لأنه خالف قواعدنا.} restricted {وجدنا أن الحساب الذي أبلغت عنه خالف قواعدنا، وقيّدناه. لا نشارك الإجراء الذي اتخذناه ضد حساب آخر، لأن ذلك من بيانات صاحبه الشخصية.} action_taken {وجدنا أن ما أبلغت عنه خالف قواعدنا، واتخذنا إجراءً. لا نشارك الإجراء الذي اتخذناه ضد حساب آخر، لأن ذلك من بيانات صاحبه الشخصية.} no_violation {وجدنا أن المحتوى أو الحساب الذي أبلغت عنه لا يخالف قواعدنا، لذا تركناه كما هو.} other {أغلقنا بلاغك.}}{hasComment, select, yes {\n\nملاحظة من فريقنا: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nإذا كنت لا توافق، فراسل {appeals_email} خلال 60 يومًا واذكر معرّف البلاغ. إذا كنت في الاتحاد الأوروبي، يمكنك أيضًا إحالة الأمر إلى هيئة معتمدة لتسوية النزاعات خارج المحاكم أو تقديم شكوى إلى منسق الخدمات الرقمية (Digital Services Coordinator) في بلدك.}}\n\nفريق {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "تمت جدولة حذف حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nلقد طلبت حذف حسابك في {product_name}. تمت جدولة حسابك للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}\n\nإذا لم تطلب هذا، سجل الدخول إلى حسابك لإلغاء الحذف. نوصي أيضًا بتغيير كلمة المرور الخاصة بك لتأمين حسابك.\n\n– فريق {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "تم رفع تعليق حسابك في {product_name}",
|
||||
"body": "مرحبًا {username}،\n\nأخبار سارة: تم رفع تعليق حسابك في {product_name}.\n\n{reason, select,\n null {}\n other {السبب: {reason}\n\n}\n}يمكنك الآن تسجيل الدخول مرة أخرى ومواصلة استخدام {product_name} كالمعتاد.\n\n– فريق أمان {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит поради неактивност",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е неактивен от дълго време, затова е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\n{safety_email, select, null {Ако искаш да запазиш акаунта си, свържи се с администраторите на тази инстанция преди тази дата.} other {Ако искаш да запазиш акаунта си, свържи се с {safety_email} от този имейл адрес преди тази дата.}}\n\n– Екип на {product_name}"
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е неактивен от дълго време, затова насрочихме изтриването му за {deletion_date}.\n\nЗа да запазиш акаунта си, влез в него преди тази дата. {support_email, select, null {Ако имаш въпроси, свържи се с администраторите на тази инстанция.} other {Ако имаш въпроси, пиши на {support_email}.}}\n\nЕкипът на {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Изтриването на акаунта ти във {product_name} е насрочено",
|
||||
"body": "Здравей, {username},\n\nКакто поиска, акаунтът ти във {product_name} е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}{reason, select, null {} other {\n\nПричина: {reason}}}\n\nДотогава акаунтът ти е заключен. {safety_email, select, null {Ако не си поискал това или искаш да запазиш акаунта си, свържи се с администраторите на тази инстанция преди тази дата.} other {Ако не си поискал това или искаш да запазиш акаунта си, свържи се с {safety_email} от този имейл адрес преди тази дата.}}\n\n– Екип на {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване поради нарушения на нашите Общи условия или Насоки на общността.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}Това е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\n{legalLinks, select,\n both {Прегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\n}\n terms {Прегледай:\n- Общи условия: {termsUrl}\n\n}\n guidelines {Прегледай:\n- Насоки на общността: {guidelinesUrl}\n\n}\n other {}\n}Процес на обжалване:\nАко смяташ, че това решение е неправилно или необосновано, имаш 60 дни да подадеш обжалване. {appeals_email, select, null {Свържи се с администраторите на тази инстанция.} other {Изпрати имейл на {appeals_email} от този имейл адрес.}}\n\nВ обжалването си:\n- Обясни ясно защо смяташ, че решението е неправилно или необосновано\n- Приложи всички релевантни доказателства или контекст\n\nЧлен на екипа за безопасност на {product_name} ще прегледа обжалването ти и може да спре предстоящото изтриване, докато не бъде взето окончателно решение.\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Акаунтът ти във {product_name} е временно спрян",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е временно спрян поради нарушаване на нашите Общи условия или Насоки на общността.\n\nПродължителност: {durationHours, plural,\n =1 {1 час}\n other {# часа}\n}\nСпрян до: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}През това време няма да имаш достъп до акаунта си.\n\n{legalLinks, select,\n both {Прегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\n}\n terms {Прегледай:\n- Общи условия: {termsUrl}\n\n}\n guidelines {Прегледай:\n- Насоки на общността: {guidelinesUrl}\n\n}\n other {}\n}Ако смяташ, че това решение е неправилно или необосновано, можеш да подадеш обжалване. {appeals_email, select, null {Свържи се с администраторите на тази инстанция и ясно обясни защо смяташ, че решението е неправилно.} other {Изпрати имейл на {appeals_email} от този имейл адрес и ясно обясни защо смяташ, че решението е неправилно.}} Ще прегледаме обжалването ти и ще ти отговорим с решението си.\n\n– Екип за безопасност на {product_name}"
|
||||
"body": "Здравей, {username},\n\nПо твое желание насрочихме изтриването на акаунта ти във {product_name} за {deletion_date}.\n\nЗа да запазиш акаунта си, влез в него преди {deletion_date}. {safety_email, select, null {Ако заявката не е от теб, свържи се с администраторите на тази инстанция.} other {Ако заявката не е от теб, пиши на {safety_email}.}}\n\nЕкипът на {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Благодарим ти за дарението към {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Взехме решение по сигнала ти във {product_name}",
|
||||
"body": "Здравей,\n\nНашият екип за безопасност прегледа сигнала ти по Закона за цифровите услуги (ID: {reportId}) и взе решение.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nНе споделяме подробности за действия, предприети срещу акаунта на друг човек, защото това са негови лични данни.\n\nАко не си съгласен с това решение, можеш да го обжалваш безплатно в срок от 60 дни. {appeals_email, select, null {Свържи се с администраторите на тази инстанция, посочи ID на сигнала си и обясни защо смяташ, че решението е неправилно.} other {Изпрати имейл на {appeals_email} от този имейл адрес, посочи ID на сигнала си и обясни защо смяташ, че решението е неправилно.}} Ако се намираш в ЕС, можеш също да отнесеш спора към сертифициран орган за извънсъдебно решаване на спорове. Нищо от това не засяга правото ти да се обърнеш към съда.\n\n– Екип за безопасност на {product_name}"
|
||||
"body": "Здравей,\n\nПрегледахме сигнала ти (ID: {reportId}).\n\n{outcome, select, removed {Премахнахме съдържанието от сигнала ти, защото нарушаваше правилата ни.} restricted {Установихме, че акаунтът от сигнала ти е нарушил правилата ни, и го ограничихме. Не споделяме какви мерки сме предприели срещу друг акаунт, защото това са лични данни на собственика му.} action_taken {Установихме, че посоченото в сигнала ти е нарушило правилата ни, и предприехме мерки. Не споделяме какви мерки сме предприели срещу друг акаунт, защото това са лични данни на собственика му.} no_violation {Установихме, че съдържанието или акаунтът от сигнала ти не нарушава правилата ни, затова не променихме нищо.} other {Закрихме сигнала ти.}}{hasComment, select, yes {\n\nБележка от нашия екип: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nАко не приемаш решението, пиши на {appeals_email} в рамките на 60 дни и посочи ID на сигнала. Ако си в ЕС, можеш също да отнесеш случая до сертифициран орган за извънсъдебно решаване на спорове или да подадеш жалба до координатора за цифровите услуги в твоята държава.}}\n\nЕкипът на {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Потвърди имейла си за сигнал по DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Потвърди имейл адреса си във {product_name}",
|
||||
"body": "Здравей, {username},\n\nПотвърди имейл адреса за акаунта си във {product_name}, като кликнеш върху линка по-долу:\n\n{verifyUrl}\n\nАко не си създал акаунт във {product_name}, можеш спокойно да игнорираш този имейл.\n\nТози линк е валиден 24 часа.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Отменихме решение за акаунта ти във {product_name}} other {Ограничението на акаунта ти във {product_name} приключи}}",
|
||||
"body": "Здравей, {username},\n\n{outcome, select, reversed {{decision_id, select, null {Прегледахме отново решение за акаунта ти и го отменихме.} other {Прегледахме отново решение {decision_id} и го отменихме.}} Съжаляваме за неудобството.} other {{decision_id, select, null {Премахнахме ограничението на акаунта ти.} other {Премахнахме ограничението по решение {decision_id}.}}}}{restores_access, select, yes { Отново можеш да влизаш и да използваш {product_name}.} other {}}{statement, select, null {} other {\n\nБележка от нашия екип: {statement}}}{content_removed, select, yes {\n\nПремахнатото съдържание не може да бъде възстановено.} other {}}\n\nЕкипът на {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Информация за акаунта ти във {product_name}} other {{kind_label, select, warning {Предупреждение за акаунта ти във {product_name}} suspension {Акаунтът ти във {product_name} е спрян} ban {Акаунтът ти във {product_name} е блокиран} closure {Акаунтът ти във {product_name} е закрит} feature_limit {Някои функции на акаунта ти във {product_name} са ограничени} content_removal {Премахнахме твои съобщения във {product_name}} profile_reset {Нулирахме части от профила ти във {product_name}} community_removal {Премахнахме твоята общност във {product_name}} bot {Информация за твоя бот във {product_name}} other {Информация за акаунта ти във {product_name}}}}}",
|
||||
"body": "Здравей, {username},\n\n{summary}\n\n{effects}\n\nЗащо\nПравило: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nБележка от нашия екип: {statement}}}\n\nКак решихме\n{how_we_decided}{appeals_email, select, null {} other {\n\nКак да обжалваш\nАко смяташ, че сме сгрешили, пиши на {appeals_email} до {appeal_deadline}. Ако можеш, пиши от имейл адреса на акаунта си и посочи ID на решението. Обжалването е безплатно и всяка жалба се преглежда от човек.{redress, select, null {} other {\n\n{redress}}}}}\n\nID на решението: {decision_id}\n\nЕкипът на {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Предимствата от осребрения ти подарък са премахнати",
|
||||
"body": "Здравей, {username},\n\nКод за подарък, който си осребрил, първоначално е бил платен от някой друг. Оттогава плащането е върнато чрез оспорване на трансакция.\n\nПоради това премахнахме предимствата, които бяха добавени към акаунта ти, когато осребри подаръка.\n\n{support_email, select, null {Ако смяташ, че това е грешка, свържи се с администраторите на тази инстанция и посочи всички подробности, които имаш за кода за подарък и кога си го осребрил.} other {Ако смяташ, че това е грешка, свържи се с нашия екип за поддръжка и посочи всички подробности, които имаш за кода за подарък и кога си го осребрил.}}\n\n– Екип на {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Сигналът ти във {product_name} е прегледан",
|
||||
"body": "Здравей, {username},\n\nСигналът ти (ID: {reportId}) е прегледан от нашия екип за безопасност.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nБлагодарим ти, че помагаш на {product_name} да остане безопасно място за всички. Приемаме всички сигнали сериозно и ценим приноса ти към общността.\n\n{safety_email, select, null {Ако имаш въпроси или притеснения относно този резултат, свържи се с администраторите на тази инстанция.} other {Ако имаш въпроси или притеснения относно този резултат, свържи се с {safety_email}.}}\n\n– Екип за безопасност на {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
|
||||
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}Това е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\nАко смяташ, че това решение е неправилно, можеш да подадеш обжалване. {appeals_email, select, null {Свържи се с администраторите на тази инстанция.} other {Изпрати имейл на {appeals_email} от този имейл адрес.}}\n\n– Екип за безопасност на {product_name}"
|
||||
"body": "Здравей, {username},\n\nПрегледахме сигнала ти (ID: {reportId}).\n\n{outcome, select, removed {Премахнахме съдържанието от сигнала ти, защото нарушаваше правилата ни.} restricted {Установихме, че акаунтът от сигнала ти е нарушил правилата ни, и го ограничихме. Не споделяме какви мерки сме предприели срещу друг акаунт, защото това са лични данни на собственика му.} action_taken {Установихме, че посоченото в сигнала ти е нарушило правилата ни, и предприехме мерки. Не споделяме какви мерки сме предприели срещу друг акаунт, защото това са лични данни на собственика му.} no_violation {Установихме, че съдържанието или акаунтът от сигнала ти не нарушава правилата ни, затова не променихме нищо.} other {Закрихме сигнала ти.}}{hasComment, select, yes {\n\nБележка от нашия екип: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nАко не приемаш решението, пиши на {appeals_email} в рамките на 60 дни и посочи ID на сигнала. Ако си в ЕС, можеш също да отнесеш случая до сертифициран орган за извънсъдебно решаване на спорове или да подадеш жалба до координатора за цифровите услуги в твоята държава.}}\n\nЕкипът на {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Изтриването на акаунта ти във {product_name} е насрочено",
|
||||
"body": "Здравей, {username},\n\nПоискал си изтриване на акаунта си във {product_name}. Акаунтът ти е насрочен за постоянно изтриване на:\n\n{deletionDate, date, full} в {deletionDate, time, short}\n\nАко не си поискал това, влез в акаунта си, за да отмениш изтриването. Препоръчваме също да смениш паролата си, за да защитиш акаунта си.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Спирането на акаунта ти във {product_name} е отменено",
|
||||
"body": "Здравей, {username},\n\nДобра новина: спирането на акаунта ти във {product_name} е отменено.\n\n{reason, select,\n null {}\n other {Причина: {reason}\n\n}\n}Вече можеш да влезеш отново и да продължиш да използваш {product_name} както обикновено.\n\n– Екип за безопасност на {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Váš účet {product_name} bude smazán kvůli neaktivitě",
|
||||
"body": "Dobrý den, {username},\n\nVáš účet {product_name} byl dlouho neaktivní, a proto je jeho trvalé smazání naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}{reason, select, null {} other {\n\nDůvod: {reason}}}\n\n{safety_email, select, null {Pokud si chcete účet ponechat, kontaktujte před tímto datem správce této instance.} other {Pokud si chcete účet ponechat, kontaktujte před tímto datem {safety_email} z této e-mailové adresy.}}\n\n– Tým {product_name}"
|
||||
"body": "Dobrý den, {username},\n\nVáš účet {product_name} byl dlouho neaktivní, a proto jsme jeho smazání naplánovali na {deletion_date}.\n\nPokud si chcete účet ponechat, přihlaste se před tímto datem. {support_email, select, null {Pokud máte dotazy, kontaktujte správce této instance.} other {Pokud máte dotazy, kontaktujte {support_email}.}}\n\nTým {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Smazání vašeho účtu {product_name} je naplánováno",
|
||||
"body": "Dobrý den, {username},\n\nNa vaši žádost je trvalé smazání vašeho účtu {product_name} naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}{reason, select, null {} other {\n\nDůvod: {reason}}}\n\nDo té doby je váš účet uzamčen. {safety_email, select, null {Pokud jste o smazání nežádali nebo si chcete účet ponechat, kontaktujte před tímto datem správce této instance.} other {Pokud jste o smazání nežádali nebo si chcete účet ponechat, kontaktujte před tímto datem {safety_email} z této e-mailové adresy.}}\n\n– Tým {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Váš účet {product_name} bude trvale smazán",
|
||||
"body": "Dobrý den, {username},\n\nKvůli porušení našich podmínek služby nebo komunitních pravidel bylo naplánováno trvalé smazání vašeho účtu {product_name}.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}\n\n}\n}Jedná se o závažné opatření. Data vašeho účtu budou v naplánovaný den trvale smazána.\n\n{legalLinks, select,\n both {Přečtěte si:\n- Podmínky služby: {termsUrl}\n- Komunitní pravidla: {guidelinesUrl}\n\n}\n terms {Přečtěte si:\n- Podmínky služby: {termsUrl}\n\n}\n guidelines {Přečtěte si:\n- Komunitní pravidla: {guidelinesUrl}\n\n}\n other {}\n}Postup odvolání:\nPokud se domníváte, že toto rozhodnutí bylo nesprávné nebo neopodstatněné, máte 60 dní na podání odvolání. {appeals_email, select, null {Kontaktujte správce této instance.} other {Pošlete e-mail na {appeals_email} z této e-mailové adresy.}}\n\nV odvolání:\n- Jasně vysvětlete, proč se domníváte, že rozhodnutí bylo nesprávné nebo neopodstatněné\n- Uveďte všechny relevantní důkazy a souvislosti\n\nČlen bezpečnostního týmu {product_name} vaše odvolání přezkoumá a může pozastavit plánované smazání, dokud nepadne konečné rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Váš účet {product_name} byl dočasně pozastaven",
|
||||
"body": "Dobrý den, {username},\n\nVáš účet {product_name} byl dočasně pozastaven kvůli porušení našich podmínek služby nebo komunitních pravidel.\n\nDoba trvání: {durationHours, plural,\n one {# hodina}\n few {# hodiny}\n many {# hodiny}\n other {# hodin}\n}\nPozastaven do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}\n\n}\n}Během této doby nebudete mít přístup ke svému účtu.\n\n{legalLinks, select,\n both {Přečtěte si:\n- Podmínky služby: {termsUrl}\n- Komunitní pravidla: {guidelinesUrl}\n\n}\n terms {Přečtěte si:\n- Podmínky služby: {termsUrl}\n\n}\n guidelines {Přečtěte si:\n- Komunitní pravidla: {guidelinesUrl}\n\n}\n other {}\n}Pokud se domníváte, že toto rozhodnutí bylo nesprávné nebo neopodstatněné, můžete podat odvolání. {appeals_email, select, null {Kontaktujte správce této instance a jasně vysvětlete, proč považujete rozhodnutí za nesprávné.} other {Pošlete e-mail na {appeals_email} z této e-mailové adresy a jasně vysvětlete, proč považujete rozhodnutí za nesprávné.}} Vaše odvolání přezkoumáme a sdělíme vám své rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
|
||||
"body": "Dobrý den, {username},\n\nNa vaši žádost jsme naplánovali smazání vašeho účtu {product_name} na {deletion_date}.\n\nPokud si chcete účet ponechat, přihlaste se před {deletion_date}. {safety_email, select, null {Pokud tato žádost nepochází od vás, kontaktujte správce této instance.} other {Pokud tato žádost nepochází od vás, kontaktujte {safety_email}.}}\n\nTým {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Děkujeme za váš dar pro {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Rozhodli jsme o vašem nahlášení na {product_name}",
|
||||
"body": "Dobrý den,\n\nNáš bezpečnostní tým přezkoumal vaše nahlášení podle nařízení o digitálních službách (ID: {reportId}) a rozhodl o něm.{hasComment, select, yes {\n\nOdpověď bezpečnostního týmu:\n{publicComment}} other {}}\n\nPodrobnosti o opatřeních přijatých vůči účtu jiné osoby nesdělujeme, protože jde o její osobní údaje.\n\nPokud s tímto rozhodnutím nesouhlasíte, můžete se do 60 dnů bezplatně odvolat. {appeals_email, select, null {Kontaktujte správce této instance, uveďte ID svého nahlášení a vysvětlete, proč považujete rozhodnutí za nesprávné.} other {Pošlete e-mail na {appeals_email} z této e-mailové adresy, uveďte ID svého nahlášení a vysvětlete, proč považujete rozhodnutí za nesprávné.}} Pokud jste v EU, můžete spor také předložit certifikovanému subjektu pro mimosoudní řešení sporů. Nic z toho nemá vliv na vaše právo obrátit se na soud.\n\n– Bezpečnostní tým {product_name}"
|
||||
"body": "Dobrý den,\n\nPosoudili jsme vaše nahlášení (ID: {reportId}).\n\n{outcome, select, removed {Nahlášený obsah jsme odstranili, protože porušoval naše pravidla.} restricted {Zjistili jsme, že nahlášený účet porušil naše pravidla, a omezili jsme ho. Nesdělujeme, jaké opatření jsme vůči jinému účtu přijali, protože jde o osobní údaje jeho majitele.} action_taken {Zjistili jsme, že nahlášený obsah nebo účet porušil naše pravidla, a přijali jsme opatření. Nesdělujeme, jaké opatření jsme vůči jinému účtu přijali, protože jde o osobní údaje jeho majitele.} no_violation {Zjistili jsme, že nahlášený obsah nebo účet naše pravidla neporušuje, a proto jsme vše ponechali beze změny.} other {Vaše nahlášení jsme uzavřeli.}}{hasComment, select, yes {\n\nPoznámka od našeho týmu: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nPokud nesouhlasíte, napište do 60 dnů na {appeals_email} a uveďte ID nahlášení. Pokud se nacházíte v EU, můžete věc také předložit certifikovanému subjektu pro mimosoudní řešení sporů nebo podat stížnost koordinátorovi digitálních služeb ve své zemi.}}\n\nTým {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Ověřte svůj e-mail pro nahlášení podle DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Ověřte svou e-mailovou adresu pro {product_name}",
|
||||
"body": "Dobrý den, {username},\n\nKliknutím na odkaz níže ověřte e-mailovou adresu svého účtu {product_name}:\n\n{verifyUrl}\n\nPokud jste si účet {product_name} nevytvořili, můžete tento e-mail bez obav ignorovat.\n\nTento odkaz je platný 24 hodin.\n\n– Tým {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Zrušili jsme rozhodnutí týkající se vašeho účtu {product_name}} other {Omezení vašeho účtu {product_name} skončilo}}",
|
||||
"body": "Dobrý den, {username},\n\n{outcome, select, reversed {{decision_id, select, null {Znovu jsme posoudili rozhodnutí týkající se vašeho účtu a zrušili ho.} other {Znovu jsme posoudili rozhodnutí {decision_id} a zrušili ho.}} Omlouváme se za potíže.} other {{decision_id, select, null {Ukončili jsme omezení vašeho účtu.} other {Ukončili jsme omezení uložené rozhodnutím {decision_id}.}}}}{restores_access, select, yes { Můžete se znovu přihlásit a používat {product_name}.} other {}}{statement, select, null {} other {\n\nPoznámka od našeho týmu: {statement}}}{content_removed, select, yes {\n\nOdstraněný obsah nelze obnovit.} other {}}\n\nTým {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Informace o vašem účtu {product_name}} other {{kind_label, select, warning {Varování týkající se vašeho účtu {product_name}} suspension {Váš účet {product_name} je pozastaven} ban {Váš účet {product_name} je zablokován} closure {Váš účet {product_name} je zrušen} feature_limit {Některé funkce vašeho účtu {product_name} jsou omezeny} content_removal {Odstranili jsme vaše zprávy na {product_name}} profile_reset {Resetovali jsme část vašeho profilu {product_name}} community_removal {Odstranili jsme vaši komunitu na {product_name}} bot {Informace o vašem botovi na {product_name}} other {Informace o vašem účtu {product_name}}}}}",
|
||||
"body": "Dobrý den, {username},\n\n{summary}\n\n{effects}\n\nProč\nPravidlo: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nPoznámka od našeho týmu: {statement}}}\n\nJak jsme rozhodli\n{how_we_decided}{appeals_email, select, null {} other {\n\nJak se odvolat\nPokud si myslíte, že jsme udělali chybu, napište na {appeals_email} do {appeal_deadline}. Pokud můžete, pište z e-mailové adresy svého účtu a uveďte ID rozhodnutí. Odvolání je zdarma a každé posoudí člověk.{redress, select, null {} other {\n\n{redress}}}}}\n\nID rozhodnutí: {decision_id}\n\nTým {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Výhody z uplatněného dárku byly odstraněny",
|
||||
"body": "Dobrý den, {username},\n\nDárkový kód, který jste uplatnili, původně zaplatil někdo jiný. Tato platba byla následně vrácena na základě reklamace (chargeback).\n\nProto jsme odebrali výhody, které váš účet získal při uplatnění dárku.\n\n{support_email, select, null {Pokud se domníváte, že jde o chybu, kontaktujte správce této instance a uveďte všechny dostupné podrobnosti o dárkovém kódu a o tom, kdy jste ho uplatnili.} other {Pokud se domníváte, že jde o chybu, kontaktujte náš tým podpory a uveďte všechny dostupné podrobnosti o dárkovém kódu a o tom, kdy jste ho uplatnili.}}\n\n– Tým {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Vaše nahlášení na {product_name} bylo přezkoumáno",
|
||||
"body": "Dobrý den, {username},\n\nNáš bezpečnostní tým přezkoumal vaše nahlášení (ID: {reportId}).{hasComment, select, yes {\n\nOdpověď bezpečnostního týmu:\n{publicComment}} other {}}\n\nDěkujeme, že pomáháte udržovat {product_name} bezpečný pro všechny. Všechna nahlášení bereme vážně a vážíme si vašeho přínosu komunitě.\n\n{safety_email, select, null {Pokud máte k tomuto výsledku jakékoli dotazy nebo výhrady, kontaktujte správce této instance.} other {Pokud máte k tomuto výsledku jakékoli dotazy nebo výhrady, kontaktujte {safety_email}.}}\n\n– Bezpečnostní tým {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Váš účet {product_name} bude trvale smazán",
|
||||
"body": "Dobrý den, {username},\n\nBylo naplánováno trvalé smazání vašeho účtu {product_name}.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}\n\n}\n}Jedná se o závažné opatření. Data vašeho účtu budou v naplánovaný den trvale smazána.\n\nPokud se domníváte, že toto rozhodnutí bylo nesprávné, můžete podat odvolání. {appeals_email, select, null {Kontaktujte správce této instance.} other {Pošlete e-mail na {appeals_email} z této e-mailové adresy.}}\n\n– Bezpečnostní tým {product_name}"
|
||||
"body": "Dobrý den, {username},\n\nPosoudili jsme vaše nahlášení (ID: {reportId}).\n\n{outcome, select, removed {Nahlášený obsah jsme odstranili, protože porušoval naše pravidla.} restricted {Zjistili jsme, že nahlášený účet porušil naše pravidla, a omezili jsme ho. Nesdělujeme, jaké opatření jsme vůči jinému účtu přijali, protože jde o osobní údaje jeho majitele.} action_taken {Zjistili jsme, že nahlášený obsah nebo účet porušil naše pravidla, a přijali jsme opatření. Nesdělujeme, jaké opatření jsme vůči jinému účtu přijali, protože jde o osobní údaje jeho majitele.} no_violation {Zjistili jsme, že nahlášený obsah nebo účet naše pravidla neporušuje, a proto jsme vše ponechali beze změny.} other {Vaše nahlášení jsme uzavřeli.}}{hasComment, select, yes {\n\nPoznámka od našeho týmu: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nPokud nesouhlasíte, napište do 60 dnů na {appeals_email} a uveďte ID nahlášení. Pokud se nacházíte v EU, můžete věc také předložit certifikovanému subjektu pro mimosoudní řešení sporů nebo podat stížnost koordinátorovi digitálních služeb ve své zemi.}}\n\nTým {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Smazání vašeho účtu {product_name} je naplánováno",
|
||||
"body": "Dobrý den, {username},\n\nPožádali jste o smazání svého účtu {product_name}. Trvalé smazání je naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}\n\nPokud jste o smazání nežádali, přihlaste se ke svému účtu a smazání zrušte. Doporučujeme také změnit heslo, abyste svůj účet zabezpečili.\n\n– Tým {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Pozastavení vašeho účtu {product_name} bylo zrušeno",
|
||||
"body": "Dobrý den, {username},\n\nDobrá zpráva: pozastavení vašeho účtu {product_name} bylo zrušeno.\n\n{reason, select,\n null {}\n other {Důvod: {reason}\n\n}\n}Nyní se můžete znovu přihlásit a používat {product_name} jako obvykle.\n\n– Bezpečnostní tým {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Din {product_name}-konto slettes på grund af inaktivitet",
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto har været inaktiv i lang tid, så den slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsag: {reason}}}\n\n{safety_email, select, null {Hvis du vil beholde din konto, skal du kontakte administratorerne af denne instans inden denne dato.} other {Hvis du vil beholde din konto, skal du kontakte {safety_email} fra denne e-mailadresse inden denne dato.}}\n\n– Teamet bag {product_name}"
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto har været inaktiv i lang tid, så vi har planlagt sletning af kontoen den {deletion_date}.\n\nHvis du vil beholde din konto, skal du logge ind før den dato. {support_email, select, null {Hvis du har spørgsmål, så kontakt administratorerne af denne instans.} other {Hvis du har spørgsmål, så kontakt {support_email}.}}\n\nTeamet bag {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Sletningen af din {product_name}-konto er planlagt",
|
||||
"body": "Hej {username},\n\nSom du har anmodet om, slettes din {product_name}-konto permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsag: {reason}}}\n\nDin konto er låst indtil da. {safety_email, select, null {Hvis du ikke har anmodet om dette, eller hvis du vil beholde din konto, skal du kontakte administratorerne af denne instans inden denne dato.} other {Hvis du ikke har anmodet om dette, eller hvis du vil beholde din konto, skal du kontakte {safety_email} fra denne e-mailadresse inden denne dato.}}\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Din {product_name}-konto slettes permanent",
|
||||
"body": "Hej {username},\n\nVi har planlagt at slette din {product_name}-konto permanent på grund af overtrædelser af vores servicevilkår eller retningslinjer for fællesskabet.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}\n\n}\n}Dette er en alvorlig sanktion. Dine kontodata bliver permanent slettet på den planlagte dato.\n\n{legalLinks, select,\n both {Gennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\n}\n terms {Gennemgå:\n- Servicevilkår: {termsUrl}\n\n}\n guidelines {Gennemgå:\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\n}\n other {}\n}Klageproces:\nHvis du mener, at denne beslutning om sanktioner var forkert eller uberettiget, har du 60 dage til at indsende en klage. {appeals_email, select, null {Kontakt administratorerne af denne instans.} other {Send en e-mail til {appeals_email} fra denne e-mailadresse.}}\n\nI din klage:\n- Forklar tydeligt, hvorfor du mener, at beslutning om sanktioneren var forkert eller uberettiget\n- Fremlæg relevant dokumentation eller kontekst\n\nEt medlem af sikkerhedsteamet hos {product_name} vil gennemgå din klage og kan sætte den afventende sletning på pause, indtil en endelig beslutning er truffet.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Din {product_name}-konto er midlertidigt suspenderet",
|
||||
"body": "Hej {username},\n\nDin {product_name}-konto er midlertidigt suspenderet på grund af overtrædelser af vores servicevilkår eller retningslinjer for fællesskabet.\n\nVarighed: {durationHours, plural,\n =1 {1 time}\n other {# timer}\n}\nSuspenderet indtil: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}\n\n}\n}I denne periode vil du ikke kunne få adgang til din konto.\n\n{legalLinks, select,\n both {Gennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\n}\n terms {Gennemgå:\n- Servicevilkår: {termsUrl}\n\n}\n guidelines {Gennemgå:\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\n}\n other {}\n}Hvis du mener, at denne beslutning om sanktioner var forkert eller uberettiget, kan du indsende en klage. {appeals_email, select, null {Kontakt administratorerne af denne instans og forklar tydeligt, hvorfor du mener, at beslutningen var forkert.} other {Send en e-mail til {appeals_email} fra denne e-mailadresse og forklar tydeligt, hvorfor du mener, at beslutningen var forkert.}} Vi gennemgår din klage og giver dig besked om vores afgørelse.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
"body": "Hej {username},\n\nSom du har bedt om, har vi planlagt sletning af din {product_name}-konto den {deletion_date}.\n\nHvis du vil beholde din konto, skal du logge ind før {deletion_date}. {safety_email, select, null {Hvis du ikke har bedt om dette, så kontakt administratorerne af denne instans.} other {Hvis du ikke har bedt om dette, så kontakt {safety_email}.}}\n\nTeamet bag {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Tak for din {product_name}-donation",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Vi har truffet en afgørelse om din anmeldelse på {product_name}",
|
||||
"body": "Hej,\n\nVores sikkerhedsteam har behandlet din anmeldelse i henhold til Digital Services Act (ID: {reportId}) og truffet en afgørelse.{hasComment, select, yes {\n\nSvar fra sikkerhedsteamet:\n{publicComment}} other {}}\n\nVi deler ikke oplysninger om tiltag mod en anden persons konto, fordi det er den persons personoplysninger.\n\nHvis du er uenig i afgørelsen, kan du klage gratis inden for 60 dage. {appeals_email, select, null {Kontakt administratorerne af denne instans, oplys dit anmeldelses-ID, og forklar, hvorfor du mener, at afgørelsen er forkert.} other {Send en e-mail til {appeals_email} fra denne e-mailadresse, oplys dit anmeldelses-ID, og forklar, hvorfor du mener, at afgørelsen er forkert.}} Hvis du befinder dig i EU, kan du også indbringe tvisten for et certificeret organ for udenretslig tvistbilæggelse. Intet af dette påvirker din ret til at gå til domstolene.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
"body": "Hej,\n\nVi har gennemgået din anmeldelse (ID: {reportId}).\n\n{outcome, select, removed {Vi har fjernet det indhold, du anmeldte, fordi det brød vores regler.} restricted {Vi har konstateret, at den konto, du anmeldte, brød vores regler, og vi har begrænset den. Vi oplyser ikke, hvilke tiltag vi har truffet over for en anden konto, fordi det er den persons personoplysninger.} action_taken {Vi har konstateret, at det, du anmeldte, brød vores regler, og vi har grebet ind. Vi oplyser ikke, hvilke tiltag vi har truffet over for en anden konto, fordi det er den persons personoplysninger.} no_violation {Vi har konstateret, at det indhold eller den konto, du anmeldte, ikke bryder vores regler, så vi har ikke foretaget os noget.} other {Vi har lukket din anmeldelse.}}{hasComment, select, yes {\n\nBesked fra vores team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nHvis du er uenig, så send en e-mail til {appeals_email} inden for 60 dage, og oplys anmeldelses-ID'et. Hvis du befinder dig i EU, kan du også indbringe sagen for et certificeret organ for udenretslig tvistbilæggelse eller klage til koordinatoren for digitale tjenester i dit land.}}\n\nTeamet bag {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Bekræft din e-mailadresse til en DSA-anmeldelse",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Bekræft din {product_name}-e-mailadresse",
|
||||
"body": "Hej {username},\n\nBekræft e-mailadressen for din {product_name}-konto ved at klikke på linket nedenfor:\n\n{verifyUrl}\n\nHvis du ikke har oprettet en {product_name}-konto, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 24 timer.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Vi har omgjort en afgørelse om din {product_name}-konto} other {En begrænsning på din {product_name}-konto er ophørt}}",
|
||||
"body": "Hej {username},\n\n{outcome, select, reversed {{decision_id, select, null {Vi har set på en afgørelse om din konto igen og omgjort den.} other {Vi har set på afgørelse {decision_id} igen og omgjort den.}} Vi beklager besværet.} other {{decision_id, select, null {Vi har ophævet begrænsningen på din konto.} other {Vi har ophævet begrænsningen fra afgørelse {decision_id}.}}}}{restores_access, select, yes { Du kan logge ind og bruge {product_name} igen.} other {}}{statement, select, null {} other {\n\nBesked fra vores team: {statement}}}{content_removed, select, yes {\n\nIndhold, vi har fjernet, kan ikke gendannes.} other {}}\n\nTeamet bag {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Nyt om din {product_name}-konto} other {{kind_label, select, warning {En advarsel om din {product_name}-konto} suspension {Din {product_name}-konto er suspenderet} ban {Din {product_name}-konto er udelukket} closure {Din {product_name}-konto er lukket} feature_limit {Nogle funktioner er begrænset på din {product_name}-konto} content_removal {Vi har fjernet beskeder, du har sendt på {product_name}} profile_reset {Vi har nulstillet dele af din {product_name}-profil} community_removal {Vi har fjernet dit fællesskab på {product_name}} bot {Nyt om din bot på {product_name}} other {Nyt om din {product_name}-konto}}}}",
|
||||
"body": "Hej {username},\n\n{summary}\n\n{effects}\n\nHvorfor\nRegel: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nBesked fra vores team: {statement}}}\n\nSådan traf vi afgørelsen\n{how_we_decided}{appeals_email, select, null {} other {\n\nSådan klager du\nHvis du mener, at vi har taget fejl, så send en e-mail til {appeals_email} senest {appeal_deadline}. Skriv fra e-mailadressen på din konto, hvis du kan, og oplys afgørelses-ID'et. Det er gratis at klage, og en person gennemgår hver klage.{redress, select, null {} other {\n\n{redress}}}}}\n\nAfgørelses-ID: {decision_id}\n\nTeamet bag {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Fordele fra din indløste gave er fjernet",
|
||||
"body": "Hej {username},\n\nEn gavekode, du indløste, blev oprindeligt betalt af en anden. Den betaling er siden annulleret (en tilbageførsel).\n\nDerfor har vi fjernet de fordele, der blev tilføjet din konto, da du indløste gaven.\n\n{support_email, select, null {Hvis du mener, at dette er en fejl, kan du kontakte administratorerne af denne instans og oplyse alle detaljer, du har om gavekoden, og hvornår du indløste den.} other {Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam og oplyse alle detaljer, du har om gavekoden, og hvornår du indløste den.}}\n\n– Teamet bag {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Din anmeldelse på {product_name} er blevet behandlet",
|
||||
"body": "Hej {username},\n\nDin anmeldelse (ID: {reportId}) er blevet behandlet af vores sikkerhedsteam.{hasComment, select, yes {\n\nSvar fra sikkerhedsteamet:\n{publicComment}} other {}}\n\nTak for din hjælp til at holde {product_name} sikkert for alle. Vi tager alle anmeldelser alvorligt og værdsætter dit bidrag til fællesskabet.\n\n{safety_email, select, null {Hvis du har spørgsmål eller bekymringer vedrørende dette resultat, kan du kontakte administratorerne af denne instans.} other {Hvis du har spørgsmål eller bekymringer vedrørende dette resultat, kan du kontakte {safety_email}.}}\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Din {product_name}-konto slettes permanent",
|
||||
"body": "Hej {username},\n\nVi har planlagt at slette din {product_name}-konto permanent.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}\n\n}\n}Dette er en alvorlig sanktion. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nHvis du mener, at denne beslutning om sanktioner var forkert, kan du indsende en klage. {appeals_email, select, null {Kontakt administratorerne af denne instans.} other {Send en e-mail til {appeals_email} fra denne e-mailadresse.}}\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
"body": "Hej {username},\n\nVi har gennemgået din anmeldelse (ID: {reportId}).\n\n{outcome, select, removed {Vi har fjernet det indhold, du anmeldte, fordi det brød vores regler.} restricted {Vi har konstateret, at den konto, du anmeldte, brød vores regler, og vi har begrænset den. Vi oplyser ikke, hvilke tiltag vi har truffet over for en anden konto, fordi det er den persons personoplysninger.} action_taken {Vi har konstateret, at det, du anmeldte, brød vores regler, og vi har grebet ind. Vi oplyser ikke, hvilke tiltag vi har truffet over for en anden konto, fordi det er den persons personoplysninger.} no_violation {Vi har konstateret, at det indhold eller den konto, du anmeldte, ikke bryder vores regler, så vi har ikke foretaget os noget.} other {Vi har lukket din anmeldelse.}}{hasComment, select, yes {\n\nBesked fra vores team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nHvis du er uenig, så send en e-mail til {appeals_email} inden for 60 dage, og oplys anmeldelses-ID'et. Hvis du befinder dig i EU, kan du også indbringe sagen for et certificeret organ for udenretslig tvistbilæggelse eller klage til koordinatoren for digitale tjenester i dit land.}}\n\nTeamet bag {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Sletningen af din {product_name}-konto er planlagt",
|
||||
"body": "Hej {username},\n\nDu har anmodet om at slette din {product_name}-konto. Din konto slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}\n\nHvis du ikke har anmodet om dette, skal du logge ind på din konto for at annullere sletningen. Vi anbefaler også, at du ændrer din adgangskode for at sikre din konto.\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Suspensionen af din {product_name}-konto er ophævet",
|
||||
"body": "Hej {username},\n\nGode nyheder: Suspensionen af din {product_name}-konto er ophævet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}\n\n}\n}Du kan nu logge ind igen og fortsætte med at bruge {product_name} som normalt.\n\n– Sikkerhedsteamet hos {product_name}"
|
||||
}
|
||||
}
|
||||
+14
-22
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Dein {product_name}-Account wird wegen Inaktivität gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account war lange inaktiv und wird deshalb an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}{reason, select, null {} other {\n\nGrund: {reason}}}\n\n{safety_email, select, null {Wenn du deinen Account behalten möchtest, kontaktiere vor diesem Termin die Administratoren dieser Instanz.} other {Wenn du deinen Account behalten möchtest, kontaktiere vor diesem Termin {safety_email} von dieser E-Mail-Adresse aus.}}\n\n– {product_name}-Team"
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account war lange inaktiv. Deshalb haben wir seine Löschung für den {deletion_date} geplant.\n\nWenn du deinen Account behalten möchtest, melde dich vor diesem Datum an. {support_email, select, null {Wenn du Fragen hast, kontaktiere die Administratoren dieser Instanz.} other {Wenn du Fragen hast, kontaktiere {support_email}.}}\n\nDein {product_name}-Team"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Die Löschung deines {product_name}-Accounts ist geplant",
|
||||
"body": "Hallo {username},\n\nwie von dir beantragt, wird dein {product_name}-Account an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}{reason, select, null {} other {\n\nGrund: {reason}}}\n\nDein Account ist bis dahin gesperrt. {safety_email, select, null {Wenn du das nicht beantragt hast oder deinen Account behalten möchtest, kontaktiere vor diesem Termin die Administratoren dieser Instanz.} other {Wenn du das nicht beantragt hast oder deinen Account behalten möchtest, kontaktiere vor diesem Termin {safety_email} von dieser E-Mail-Adresse aus.}}\n\n– {product_name}-Team"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}\n\n}\n}Dies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\n{legalLinks, select,\n both {Bitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\n}\n terms {Bitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n\n}\n guidelines {Bitte lies dir Folgendes durch:\n- Community-Richtlinien: {guidelinesUrl}\n\n}\n other {}\n}Einspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. {appeals_email, select, null {Wende dich an die Administratoren dieser Instanz.} other {Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.}}\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die geplante Löschung möglicherweise aussetzen, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Dein {product_name}-Account wurde vorübergehend gesperrt",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde vorübergehend gesperrt, weil du gegen unsere Nutzungsbedingungen oder Community-Richtlinien verstoßen hast.\n\nDauer: {durationHours, plural,\n one {1 Stunde}\n other {# Stunden}\n}\nGesperrt bis: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}\n\n}\n}Während dieser Zeit kannst du nicht auf deinen Account zugreifen.\n\n{legalLinks, select,\n both {Bitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\n}\n terms {Bitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n\n}\n guidelines {Bitte lies dir Folgendes durch:\n- Community-Richtlinien: {guidelinesUrl}\n\n}\n other {}\n}Wenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, kannst du Einspruch einlegen. {appeals_email, select, null {Wende dich an die Administratoren dieser Instanz und erkläre klar, warum du die Entscheidung für falsch hältst.} other {Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email} und erkläre klar, warum du die Entscheidung für falsch hältst.}} Wir werden deinen Einspruch prüfen und dir unsere Entscheidung mitteilen.\n\n– {product_name}-Sicherheitsteam"
|
||||
"body": "Hallo {username},\n\nwie gewünscht haben wir die Löschung deines {product_name}-Accounts für den {deletion_date} geplant.\n\nWenn du deinen Account behalten möchtest, melde dich vor dem {deletion_date} an. {safety_email, select, null {Wenn du das nicht beantragt hast, kontaktiere die Administratoren dieser Instanz.} other {Wenn du das nicht beantragt hast, kontaktiere {safety_email}.}}\n\nDein {product_name}-Team"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Vielen Dank für deine {product_name}-Spende",
|
||||
@@ -28,8 +20,8 @@
|
||||
"body": "Hallo,\n\nklicke auf den Link unten, um auf dein Spendenportal zuzugreifen:\n\n{manageUrl}\n\nIm Portal kannst du Abonnements verwalten, Rechnungen herunterladen und deine Spendenübersicht einsehen.\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Link nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Team"
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Wir haben über deine {product_name}-Meldung entschieden",
|
||||
"body": "Hallo,\n\nunser Sicherheitsteam hat deine Meldung nach dem Digital Services Act (ID: {reportId}) geprüft und eine Entscheidung getroffen.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nWir geben keine Einzelheiten zu Maßnahmen gegen den Account einer anderen Person weiter, da es sich um deren personenbezogene Daten handelt.\n\nWenn du mit dieser Entscheidung nicht einverstanden bist, kannst du innerhalb von 60 Tagen kostenlos Einspruch einlegen. {appeals_email, select, null {Wende dich an die Administratoren dieser Instanz, gib deine Meldungs-ID an und erkläre, warum du die Entscheidung für falsch hältst.} other {Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}, gib deine Meldungs-ID an und erkläre, warum du die Entscheidung für falsch hältst.}} Wenn du dich in der EU befindest, kannst du die Streitigkeit außerdem einer zertifizierten außergerichtlichen Streitbeilegungsstelle vorlegen. Dein Recht, vor Gericht zu gehen, bleibt davon unberührt.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Wir haben über deine Meldung auf {product_name} entschieden",
|
||||
"body": "Hallo,\n\nwir haben deine Meldung (ID: {reportId}) geprüft.\n\n{outcome, select, removed {Wir haben den gemeldeten Inhalt entfernt, weil er gegen unsere Regeln verstoßen hat.} restricted {Wir haben festgestellt, dass der gemeldete Account gegen unsere Regeln verstoßen hat, und ihn eingeschränkt. Welche Maßnahme wir gegen einen anderen Account ergriffen haben, teilen wir nicht mit, weil das personenbezogene Daten dieser Person sind.} action_taken {Wir haben festgestellt, dass das, was du gemeldet hast, gegen unsere Regeln verstoßen hat, und haben Maßnahmen ergriffen. Welche Maßnahme wir gegen einen anderen Account ergriffen haben, teilen wir nicht mit, weil das personenbezogene Daten dieser Person sind.} no_violation {Wir haben festgestellt, dass der gemeldete Inhalt oder Account nicht gegen unsere Regeln verstößt. Deshalb haben wir nichts geändert.} other {Wir haben deine Meldung abgeschlossen.}}{hasComment, select, yes {\n\nHinweis von unserem Team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nWenn du nicht einverstanden bist, sende innerhalb von 60 Tagen eine E-Mail an {appeals_email} und gib die Meldungs-ID an. Wenn du dich in der EU befindest, kannst du die Sache auch einer zertifizierten außergerichtlichen Streitbeilegungsstelle vorlegen oder dich beim Koordinator für digitale Dienste in deinem Land beschweren.}}\n\nDein {product_name}-Team"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Bestätige deine E-Mail-Adresse für eine DSA-Meldung",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Bestätige deine {product_name}-E-Mail-Adresse",
|
||||
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für deinen {product_name}-Account, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du keinen {product_name}-Account erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name}-Team"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Wir haben eine Entscheidung zu deinem {product_name}-Account zurückgenommen} other {Eine Einschränkung deines {product_name}-Accounts ist beendet}}",
|
||||
"body": "Hallo {username},\n\n{outcome, select, reversed {{decision_id, select, null {wir haben eine Entscheidung zu deinem Account noch einmal geprüft und zurückgenommen.} other {wir haben die Entscheidung {decision_id} noch einmal geprüft und zurückgenommen.}} Entschuldige bitte die Umstände.} other {{decision_id, select, null {wir haben die Einschränkung deines Accounts aufgehoben.} other {wir haben die Einschränkung aus der Entscheidung {decision_id} aufgehoben.}}}}{restores_access, select, yes { Du kannst dich wieder anmelden und {product_name} nutzen.} other {}}{statement, select, null {} other {\n\nHinweis von unserem Team: {statement}}}{content_removed, select, yes {\n\nInhalte, die wir entfernt haben, können nicht wiederhergestellt werden.} other {}}\n\nDein {product_name}-Team"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Eine Mitteilung zu deinem {product_name}-Account} other {{kind_label, select, warning {Eine Verwarnung zu deinem {product_name}-Account} suspension {Dein {product_name}-Account ist gesperrt} ban {Dein {product_name}-Account ist dauerhaft gesperrt} closure {Dein {product_name}-Account ist geschlossen} feature_limit {Einige Funktionen deines {product_name}-Accounts sind eingeschränkt} content_removal {Wir haben Nachrichten entfernt, die du auf {product_name} gesendet hast} profile_reset {Wir haben Teile deines {product_name}-Profils zurückgesetzt} community_removal {Wir haben deine Community auf {product_name} entfernt} bot {Eine Mitteilung zu deinem Bot auf {product_name}} other {Eine Mitteilung zu deinem {product_name}-Account}}}}",
|
||||
"body": "Hallo {username},\n\n{summary}\n\n{effects}\n\nWarum\nRegel: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nHinweis von unserem Team: {statement}}}\n\nSo haben wir entschieden\n{how_we_decided}{appeals_email, select, null {} other {\n\nSo legst du Einspruch ein\nWenn du glaubst, dass wir falsch entschieden haben, sende bis zum {appeal_deadline} eine E-Mail an {appeals_email}. Schreib nach Möglichkeit von der E-Mail-Adresse deines Accounts aus und gib die Entscheidungs-ID an. Ein Einspruch ist kostenlos und wird immer von einer Person geprüft.{redress, select, null {} other {\n\n{redress}}}}}\n\nEntscheidungs-ID: {decision_id}\n\nDein {product_name}-Team"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Vorteile deines eingelösten Geschenks wurden entfernt",
|
||||
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Account hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\n{support_email, select, null {Wenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte die Administratoren dieser Instanz und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.} other {Wenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.}}\n\n– {product_name}-Team"
|
||||
@@ -84,19 +84,11 @@
|
||||
"body": "Hallo,\n\nwir haben deine Meldung nach dem Digital Services Act (DSA) über {targetKind, select, message {eine Nachricht} user {einen Account} guild {eine Community} other {Inhalte}} auf {product_name} erhalten.\n\nMeldungs-ID: {reportId}\n\nUnser Sicherheitsteam prüft deine Meldung. Sobald wir eine Entscheidung getroffen haben, schreiben wir dir an diese E-Mail-Adresse. Bitte bewahre diese E-Mail für deine Unterlagen auf.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Deine {product_name}-Meldung wurde geprüft",
|
||||
"body": "Hallo {username},\n\ndeine Meldung (ID: {reportId}) wurde von unserem Sicherheitsteam geprüft.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nDanke, dass du hilfst, {product_name} für alle sicher zu halten. Wir nehmen alle Meldungen ernst und schätzen deinen Beitrag zur Community.\n\n{safety_email, select, null {Wenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte die Administratoren dieser Instanz.} other {Wenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte {safety_email}.}}\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}\n\n}\n}Dies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nWenn du glaubst, dass diese Entscheidung falsch war, kannst du Einspruch einlegen. {appeals_email, select, null {Wende dich an die Administratoren dieser Instanz.} other {Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.}}\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Deine Meldung auf {product_name} wurde geprüft",
|
||||
"body": "Hallo {username},\n\nwir haben deine Meldung (ID: {reportId}) geprüft.\n\n{outcome, select, removed {Wir haben den gemeldeten Inhalt entfernt, weil er gegen unsere Regeln verstoßen hat.} restricted {Wir haben festgestellt, dass der gemeldete Account gegen unsere Regeln verstoßen hat, und ihn eingeschränkt. Welche Maßnahme wir gegen einen anderen Account ergriffen haben, teilen wir nicht mit, weil das personenbezogene Daten dieser Person sind.} action_taken {Wir haben festgestellt, dass das, was du gemeldet hast, gegen unsere Regeln verstoßen hat, und haben Maßnahmen ergriffen. Welche Maßnahme wir gegen einen anderen Account ergriffen haben, teilen wir nicht mit, weil das personenbezogene Daten dieser Person sind.} no_violation {Wir haben festgestellt, dass der gemeldete Inhalt oder Account nicht gegen unsere Regeln verstößt. Deshalb haben wir nichts geändert.} other {Wir haben deine Meldung abgeschlossen.}}{hasComment, select, yes {\n\nHinweis von unserem Team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nWenn du nicht einverstanden bist, sende innerhalb von 60 Tagen eine E-Mail an {appeals_email} und gib die Meldungs-ID an. Wenn du dich in der EU befindest, kannst du die Sache auch einer zertifizierten außergerichtlichen Streitbeilegungsstelle vorlegen oder dich beim Koordinator für digitale Dienste in deinem Land beschweren.}}\n\nDein {product_name}-Team"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Die Löschung deines {product_name}-Accounts ist geplant",
|
||||
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Accounts beantragt. Dein Account wird an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du diese Löschung nicht beantragt hast, melde dich in deinem Account an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um deinen Account abzusichern.\n\n– {product_name}-Team"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Die Sperrung deines {product_name}-Accounts wurde aufgehoben",
|
||||
"body": "Hallo {username},\n\ngute Nachrichten: Die Sperrung deines {product_name}-Accounts wurde aufgehoben.\n\n{reason, select,\n null {}\n other {Grund: {reason}\n\n}\n}Du kannst dich jetzt wieder anmelden und {product_name} wie gewohnt nutzen.\n\n– {product_name}-Sicherheitsteam"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί λόγω αδράνειας",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} είναι ανενεργός για μεγάλο χρονικό διάστημα, γι' αυτό έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}{reason, select, null {} other {\n\nΛόγος: {reason}}}\n\n{safety_email, select, null {Εάν θέλεις να διατηρήσεις τον λογαριασμό σου, επικοινώνησε με τους διαχειριστές αυτής της παρουσίας πριν από αυτή την ημερομηνία.} other {Εάν θέλεις να διατηρήσεις τον λογαριασμό σου, στείλε email στο {safety_email} από αυτή τη διεύθυνση email πριν από αυτή την ημερομηνία.}}\n\n– Η ομάδα του {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} είναι ανενεργός εδώ και πολύ καιρό, γι' αυτό προγραμματίσαμε τη διαγραφή του για τις {deletion_date}.\n\nΓια να κρατήσεις τον λογαριασμό σου, συνδέσου πριν από αυτή την ημερομηνία. {support_email, select, null {Αν έχεις ερωτήσεις, επικοινώνησε με τους διαχειριστές αυτής της παρουσίας.} other {Αν έχεις ερωτήσεις, επικοινώνησε με το {support_email}.}}\n\nΗ ομάδα του {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Η διαγραφή του λογαριασμού σου στο {product_name} είναι προγραμματισμένη",
|
||||
"body": "Γεια σου {username},\n\nΌπως ζήτησες, ο λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}{reason, select, null {} other {\n\nΛόγος: {reason}}}\n\nΜέχρι τότε ο λογαριασμός σου είναι κλειδωμένος. {safety_email, select, null {Εάν δεν το ζήτησες ή θέλεις να διατηρήσεις τον λογαριασμό σου, επικοινώνησε με τους διαχειριστές αυτής της παρουσίας πριν από αυτή την ημερομηνία.} other {Εάν δεν το ζήτησες ή θέλεις να διατηρήσεις τον λογαριασμό σου, στείλε email στο {safety_email} από αυτή τη διεύθυνση email πριν από αυτή την ημερομηνία.}}\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί οριστικά",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή λόγω παραβιάσεων των Όρων παροχής υπηρεσιών ή των Οδηγιών κοινότητας.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}\n\n}\n}Πρόκειται για σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\n{legalLinks, select,\n both {Έλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\n}\n terms {Έλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n\n}\n guidelines {Έλεγξε:\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\n}\n other {}\n}Διαδικασία προσφυγής:\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, έχεις 60 ημέρες για να υποβάλεις προσφυγή. {appeals_email, select, null {Επικοινώνησε με τους διαχειριστές αυτής της παρουσίας.} other {Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.}}\n\nΣτην προσφυγή σου:\n- Εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη ή αδικαιολόγητη\n- Παράθεσε τυχόν σχετικά αποδεικτικά στοιχεία ή πλαίσιο\n\nΈνα μέλος της ομάδας ασφαλείας του {product_name} θα εξετάσει την προσφυγή σου και ενδέχεται να αναστείλει την εκκρεμή διαγραφή μέχρι να ληφθεί τελική απόφαση.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά για παραβίαση των Όρων παροχής υπηρεσιών ή των Οδηγιών κοινότητας.\n\nΔιάρκεια: {durationHours, plural,\n =1 {μία ώρα}\n other {# ώρες}\n}\nΑναστολή μέχρι: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}\n\n}\n}Κατά τη διάρκεια αυτής της περιόδου, δεν θα έχεις πρόσβαση στον λογαριασμό σου.\n\n{legalLinks, select,\n both {Έλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\n}\n terms {Έλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n\n}\n guidelines {Έλεγξε:\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\n}\n other {}\n}Εάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, μπορείς να υποβάλεις προσφυγή. {appeals_email, select, null {Επικοινώνησε με τους διαχειριστές αυτής της παρουσίας και εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη.} other {Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email και εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη.}} Θα εξετάσουμε την προσφυγή σου και θα σου απαντήσουμε με την απόφασή μας.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΌπως ζήτησες, προγραμματίσαμε τη διαγραφή του λογαριασμού σου στο {product_name} για τις {deletion_date}.\n\nΓια να κρατήσεις τον λογαριασμό σου, συνδέσου πριν από τις {deletion_date}. {safety_email, select, null {Αν δεν το ζήτησες εσύ, επικοινώνησε με τους διαχειριστές αυτής της παρουσίας.} other {Αν δεν το ζήτησες εσύ, επικοινώνησε με το {safety_email}.}}\n\nΗ ομάδα του {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Λάβαμε απόφαση για την αναφορά σου στο {product_name}",
|
||||
"body": "Γεια σου,\n\nΗ ομάδα ασφαλείας μας εξέτασε την αναφορά σου βάσει της Πράξης για τις ψηφιακές υπηρεσίες (ID: {reportId}) και έλαβε απόφαση.{hasComment, select, yes {\n\nΑπάντηση από την ομάδα ασφαλείας:\n{publicComment}} other {}}\n\nΔεν κοινοποιούμε λεπτομέρειες για ενέργειες που έγιναν σε βάρος του λογαριασμού άλλου ατόμου, επειδή πρόκειται για δικά του προσωπικά δεδομένα.\n\nΕάν διαφωνείς με αυτήν την απόφαση, μπορείς να υποβάλεις προσφυγή δωρεάν εντός 60 ημερών. {appeals_email, select, null {Επικοινώνησε με τους διαχειριστές αυτής της παρουσίας, ανάφερε το ID της αναφοράς σου και εξήγησε γιατί πιστεύεις ότι η απόφαση είναι λανθασμένη.} other {Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email, ανάφερε το ID της αναφοράς σου και εξήγησε γιατί πιστεύεις ότι η απόφαση είναι λανθασμένη.}} Εάν βρίσκεσαι στην ΕΕ, μπορείς επίσης να παραπέμψεις τη διαφορά σε πιστοποιημένο όργανο εξωδικαστικής επίλυσης διαφορών. Τίποτα από αυτά δεν επηρεάζει το δικαίωμά σου να προσφύγεις στα δικαστήρια.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
"body": "Γεια σου,\n\nΕξετάσαμε την αναφορά σου (ID: {reportId}).\n\n{outcome, select, removed {Αφαιρέσαμε το περιεχόμενο που ανέφερες, επειδή παραβίαζε τους κανόνες μας.} restricted {Διαπιστώσαμε ότι ο λογαριασμός που ανέφερες παραβίασε τους κανόνες μας και τον περιορίσαμε. Δεν κοινοποιούμε ποια μέτρα λάβαμε κατά άλλου λογαριασμού, επειδή αυτό αποτελεί προσωπικά δεδομένα του κατόχου του.} action_taken {Διαπιστώσαμε ότι αυτό που ανέφερες παραβίασε τους κανόνες μας και λάβαμε μέτρα. Δεν κοινοποιούμε ποια μέτρα λάβαμε κατά άλλου λογαριασμού, επειδή αυτό αποτελεί προσωπικά δεδομένα του κατόχου του.} no_violation {Διαπιστώσαμε ότι το περιεχόμενο ή ο λογαριασμός που ανέφερες δεν παραβιάζει τους κανόνες μας, οπότε δεν κάναμε καμία αλλαγή.} other {Κλείσαμε την αναφορά σου.}}{hasComment, select, yes {\n\nΣημείωση από την ομάδα μας: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nΑν διαφωνείς, στείλε email στο {appeals_email} εντός 60 ημερών και ανάφερε το ID της αναφοράς. Αν βρίσκεσαι στην ΕΕ, μπορείς επίσης να παραπέμψεις το θέμα σε πιστοποιημένο φορέα εξωδικαστικής επίλυσης διαφορών ή να υποβάλεις καταγγελία στον συντονιστή ψηφιακών υπηρεσιών της χώρας σου.}}\n\nΗ ομάδα του {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Επαλήθευσε το email σου για αναφορά DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Επαλήθευσε τη διεύθυνση email σου στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΕπαλήθευσε τη διεύθυνση email για τον λογαριασμό σου στο {product_name} κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{verifyUrl}\n\nΕάν δεν δημιούργησες λογαριασμό στο {product_name}, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 24 ώρες.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Ανακαλέσαμε μια απόφαση για τον λογαριασμό σου στο {product_name}} other {Ένας περιορισμός στον λογαριασμό σου στο {product_name} έληξε}}",
|
||||
"body": "Γεια σου {username},\n\n{outcome, select, reversed {{decision_id, select, null {Εξετάσαμε ξανά μια απόφαση για τον λογαριασμό σου και την ανακαλέσαμε.} other {Εξετάσαμε ξανά την απόφαση {decision_id} και την ανακαλέσαμε.}} Ζητούμε συγγνώμη για την ταλαιπωρία.} other {{decision_id, select, null {Ήραμε τον περιορισμό στον λογαριασμό σου.} other {Ήραμε τον περιορισμό της απόφασης {decision_id}.}}}}{restores_access, select, yes { Μπορείς να συνδεθείς και να χρησιμοποιείς ξανά το {product_name}.} other {}}{statement, select, null {} other {\n\nΣημείωση από την ομάδα μας: {statement}}}{content_removed, select, yes {\n\nΤο περιεχόμενο που αφαιρέσαμε δεν μπορεί να επανέλθει.} other {}}\n\nΗ ομάδα του {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Ενημέρωση για τον λογαριασμό σου στο {product_name}} other {{kind_label, select, warning {Προειδοποίηση για τον λογαριασμό σου στο {product_name}} suspension {Ο λογαριασμός σου στο {product_name} ανεστάλη} ban {Ο λογαριασμός σου στο {product_name} ανεστάλη οριστικά} closure {Ο λογαριασμός σου στο {product_name} έκλεισε} feature_limit {Ορισμένες λειτουργίες του λογαριασμού σου στο {product_name} περιορίστηκαν} content_removal {Αφαιρέσαμε μηνύματα που έστειλες στο {product_name}} profile_reset {Επαναφέραμε ορισμένα μέρη του προφίλ σου στο {product_name}} community_removal {Αφαιρέσαμε την κοινότητά σου από το {product_name}} bot {Ενημέρωση για το bot σου στο {product_name}} other {Ενημέρωση για τον λογαριασμό σου στο {product_name}}}}}",
|
||||
"body": "Γεια σου {username},\n\n{summary}\n\n{effects}\n\nΓιατί\nΚανόνας: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nΣημείωση από την ομάδα μας: {statement}}}\n\nΠώς αποφασίσαμε\n{how_we_decided}{appeals_email, select, null {} other {\n\nΠώς να κάνεις ένσταση\nΑν πιστεύεις ότι κάναμε λάθος, στείλε email στο {appeals_email} έως τις {appeal_deadline}. Αν μπορείς, γράψε από τη διεύθυνση email του λογαριασμού σου και ανάφερε το αναγνωριστικό της απόφασης. Οι ενστάσεις είναι δωρεάν και κάθε μία την εξετάζει ένας άνθρωπος.{redress, select, null {} other {\n\n{redress}}}}}\n\nΑναγνωριστικό απόφασης: {decision_id}\n\nΗ ομάδα του {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Τα προνόμια από το δώρο που εξαργύρωσες έχουν αφαιρεθεί",
|
||||
"body": "Γεια σου {username},\n\nΈνας κωδικός δώρου που εξαργύρωσες πληρώθηκε αρχικά από κάποιον άλλο. Αυτή η πληρωμή έχει έκτοτε αντιστραφεί (αμφισβήτηση χρέωσης).\n\nΓι' αυτόν τον λόγο, αφαιρέσαμε τα προνόμια που προστέθηκαν στον λογαριασμό σου όταν εξαργύρωσες το δώρο.\n\n{support_email, select, null {Εάν πιστεύεις ότι πρόκειται για λάθος, επικοινώνησε με τους διαχειριστές αυτής της παρουσίας και ανάφερε όσες λεπτομέρειες έχεις σχετικά με τον κωδικό δώρου και το πότε τον εξαργύρωσες.} other {Εάν πιστεύεις ότι πρόκειται για λάθος, επικοινώνησε με την ομάδα υποστήριξής μας και ανάφερε όσες λεπτομέρειες έχεις σχετικά με τον κωδικό δώρου και το πότε τον εξαργύρωσες.}}\n\n– Η ομάδα του {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Η αναφορά σου στο {product_name} έχει εξεταστεί",
|
||||
"body": "Γεια σου {username},\n\nΗ αναφορά σου (ID: {reportId}) εξετάστηκε από την ομάδα ασφαλείας μας.{hasComment, select, yes {\n\nΑπάντηση από την ομάδα ασφαλείας:\n{publicComment}} other {}}\n\nΕυχαριστούμε που βοηθάς να παραμείνει το {product_name} ασφαλές για όλους. Λαμβάνουμε σοβαρά υπόψη κάθε αναφορά και εκτιμούμε τη συμβολή σου στην κοινότητα.\n\n{safety_email, select, null {Εάν έχεις ερωτήσεις ή απορίες σχετικά με αυτό το αποτέλεσμα, επικοινώνησε με τους διαχειριστές αυτής της παρουσίας.} other {Εάν έχεις ερωτήσεις ή απορίες σχετικά με αυτό το αποτέλεσμα, στείλε email στο {safety_email}.}}\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί οριστικά",
|
||||
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}\n\n}\n}Πρόκειται για σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη, μπορείς να υποβάλεις προσφυγή. {appeals_email, select, null {Επικοινώνησε με τους διαχειριστές αυτής της παρουσίας.} other {Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.}}\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
"body": "Γεια σου {username},\n\nΕξετάσαμε την αναφορά σου (ID: {reportId}).\n\n{outcome, select, removed {Αφαιρέσαμε το περιεχόμενο που ανέφερες, επειδή παραβίαζε τους κανόνες μας.} restricted {Διαπιστώσαμε ότι ο λογαριασμός που ανέφερες παραβίασε τους κανόνες μας και τον περιορίσαμε. Δεν κοινοποιούμε ποια μέτρα λάβαμε κατά άλλου λογαριασμού, επειδή αυτό αποτελεί προσωπικά δεδομένα του κατόχου του.} action_taken {Διαπιστώσαμε ότι αυτό που ανέφερες παραβίασε τους κανόνες μας και λάβαμε μέτρα. Δεν κοινοποιούμε ποια μέτρα λάβαμε κατά άλλου λογαριασμού, επειδή αυτό αποτελεί προσωπικά δεδομένα του κατόχου του.} no_violation {Διαπιστώσαμε ότι το περιεχόμενο ή ο λογαριασμός που ανέφερες δεν παραβιάζει τους κανόνες μας, οπότε δεν κάναμε καμία αλλαγή.} other {Κλείσαμε την αναφορά σου.}}{hasComment, select, yes {\n\nΣημείωση από την ομάδα μας: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nΑν διαφωνείς, στείλε email στο {appeals_email} εντός 60 ημερών και ανάφερε το ID της αναφοράς. Αν βρίσκεσαι στην ΕΕ, μπορείς επίσης να παραπέμψεις το θέμα σε πιστοποιημένο φορέα εξωδικαστικής επίλυσης διαφορών ή να υποβάλεις καταγγελία στον συντονιστή ψηφιακών υπηρεσιών της χώρας σου.}}\n\nΗ ομάδα του {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Η διαγραφή του λογαριασμού σου στο {product_name} είναι προγραμματισμένη",
|
||||
"body": "Γεια σου {username},\n\nΖήτησες τη διαγραφή του λογαριασμού σου στο {product_name}. Ο λογαριασμός σου έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}\n\nΕάν δεν το ζήτησες, συνδέσου στον λογαριασμό σου για να ακυρώσεις τη διαγραφή. Σου προτείνουμε επίσης να αλλάξεις τον κωδικό πρόσβασής σου για να ασφαλίσεις τον λογαριασμό σου.\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί",
|
||||
"body": "Γεια σου {username},\n\nΚαλά νέα: η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}\n\n}\n}Μπορείς τώρα να συνδεθείς ξανά και να συνεχίσεις να χρησιμοποιείς το {product_name} κανονικά.\n\n– Η ομάδα ασφαλείας του {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Your {product_name} account will be deleted due to inactivity",
|
||||
"body": "Hello {username},\n\nYour {product_name} account has been inactive for a long time, so it is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\n{safety_email, select, null {If you want to keep your account, contact the administrators of this instance before that date.} other {If you want to keep your account, contact {safety_email} from this email address before that date.}}\n\n– {product_name} Team"
|
||||
"body": "Hello {username},\n\nYour {product_name} account has been inactive for a long time, so we scheduled it for deletion on {deletion_date}.\n\nTo keep your account, sign in before that date. {support_email, select, null {If you have questions, contact the administrators of this instance.} other {If you have questions, contact {support_email}.}}\n\nThe {product_name} team"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Your {product_name} account deletion is scheduled",
|
||||
"body": "Hello {username},\n\nAs you requested, your {product_name} account is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}{reason, select, null {} other {\n\nReason: {reason}}}\n\nYour account is locked until then. {safety_email, select, null {If you didn't request this, or you want to keep your account, contact the administrators of this instance before that date.} other {If you didn't request this, or you want to keep your account, contact {safety_email} from this email address before that date.}}\n\n– {product_name} Team"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Your {product_name} account will be permanently deleted",
|
||||
"body": "Hello {username},\n\nYour {product_name} account has been scheduled for permanent deletion due to violations of our Terms of Service or Community Guidelines.\n\nScheduled deletion: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.\n\n{legalLinks, select,\n both {Please review:\n- Terms of Service: {termsUrl}\n- Community Guidelines: {guidelinesUrl}\n\n}\n terms {Please review:\n- Terms of Service: {termsUrl}\n\n}\n guidelines {Please review:\n- Community Guidelines: {guidelinesUrl}\n\n}\n other {}\n}Appeals process:\nIf you believe this enforcement decision was incorrect or unjustified, you have 60 days to submit an appeal. {appeals_email, select, null {Contact the administrators of this instance.} other {Email {appeals_email} from this email address.}}\n\nIn your appeal:\n- Clearly explain why you believe the enforcement decision was incorrect or unjustified\n- Provide any relevant evidence or context\n\nA member of the {product_name} Safety Team will review your appeal and may pause the pending deletion until a final decision has been reached.\n\n– {product_name} Safety Team"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Your {product_name} account has been temporarily suspended",
|
||||
"body": "Hello {username},\n\nYour {product_name} account has been temporarily suspended for violating our Terms of Service or Community Guidelines.\n\nDuration: {durationHours, plural,\n =1 {1 hour}\n other {# hours}\n}\nSuspended until: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}During this time, you won't be able to access your account.\n\n{legalLinks, select,\n both {Please review:\n- Terms of Service: {termsUrl}\n- Community Guidelines: {guidelinesUrl}\n\n}\n terms {Please review:\n- Terms of Service: {termsUrl}\n\n}\n guidelines {Please review:\n- Community Guidelines: {guidelinesUrl}\n\n}\n other {}\n}If you believe this enforcement decision was incorrect or unjustified, you can submit an appeal. {appeals_email, select, null {Contact the administrators of this instance and clearly explain why you believe the decision was incorrect.} other {Email {appeals_email} from this email address and clearly explain why you believe the decision was incorrect.}} We'll review your appeal and respond with our decision.\n\n– {product_name} Safety Team"
|
||||
"body": "Hello {username},\n\nAs you asked, we scheduled your {product_name} account for deletion on {deletion_date}.\n\nTo keep your account, sign in before {deletion_date}. {safety_email, select, null {If you didn't ask for this, contact the administrators of this instance.} other {If you didn't ask for this, contact {safety_email}.}}\n\nThe {product_name} team"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Thank you for your {product_name} donation",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "We made a decision on your {product_name} report",
|
||||
"body": "Hello,\n\nOur Safety Team has reviewed your Digital Services Act report (ID: {reportId}) and made a decision.{hasComment, select, yes {\n\nResponse from the Safety Team:\n{publicComment}} other {}}\n\nWe don't share details of any action taken against another person's account, because that is their personal data.\n\nIf you disagree with this decision, you can appeal free of charge within 60 days. {appeals_email, select, null {Contact the administrators of this instance, include your report ID, and explain why you think the decision is wrong.} other {Email {appeals_email} from this email address, include your report ID, and explain why you think the decision is wrong.}} If you are in the EU, you can also refer the dispute to a certified out-of-court dispute settlement body. None of this affects your right to go to court.\n\n– {product_name} Safety Team"
|
||||
"body": "Hello,\n\nWe reviewed your report (ID: {reportId}).\n\n{outcome, select, removed {We removed the content you reported because it broke our rules.} restricted {We found that the account you reported broke our rules, and we restricted it. We don't share what action we took against another account, because that is their personal data.} action_taken {We found that what you reported broke our rules, and we took action. We don't share what action we took against another account, because that is their personal data.} no_violation {We found that the content or account you reported doesn't break our rules, so we left it as it is.} other {We closed your report.}}{hasComment, select, yes {\n\nNote from our team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nIf you disagree, email {appeals_email} within 60 days and include the report ID. If you're in the EU, you can also refer this to a certified out-of-court dispute settlement body or complain to the Digital Services Coordinator in your country.}}\n\nThe {product_name} team"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verify your email for a DSA report",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Verify your {product_name} email address",
|
||||
"body": "Hello {username},\n\nPlease verify the email address for your {product_name} account by clicking the link below:\n\n{verifyUrl}\n\nIf you didn't create a {product_name} account, you can safely ignore this email.\n\nThis link is valid for 24 hours.\n\n– {product_name} Team"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {We reversed a decision about your {product_name} account} other {A restriction on your {product_name} account has ended}}",
|
||||
"body": "Hello {username},\n\n{outcome, select, reversed {{decision_id, select, null {We looked again at a decision about your account and reversed it.} other {We looked again at decision {decision_id} and reversed it.}} We're sorry for the trouble.} other {{decision_id, select, null {We lifted the restriction on your account.} other {We lifted the restriction from decision {decision_id}.}}}}{restores_access, select, yes { You can sign in and use {product_name} again.} other {}}{statement, select, null {} other {\n\nNote from our team: {statement}}}{content_removed, select, yes {\n\nContent we removed can't be restored.} other {}}\n\nThe {product_name} team"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {An update about your {product_name} account} other {{kind_label, select, warning {A warning about your {product_name} account} suspension {Your {product_name} account is suspended} ban {Your {product_name} account is banned} closure {Your {product_name} account is closed} feature_limit {Some features are limited on your {product_name} account} content_removal {We removed messages you sent on {product_name}} profile_reset {We reset parts of your {product_name} profile} community_removal {We removed your community on {product_name}} bot {An update about your bot on {product_name}} other {An update about your {product_name} account}}}}",
|
||||
"body": "Hello {username},\n\n{summary}\n\n{effects}\n\nWhy\nRule: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNote from our team: {statement}}}\n\nHow we decided\n{how_we_decided}{appeals_email, select, null {} other {\n\nHow to appeal\nIf you think we got this wrong, email {appeals_email} by {appeal_deadline}. Write from the email address on your account if you can, and include the decision ID. Appeals are free, and a person reviews each one.{redress, select, null {} other {\n\n{redress}}}}}\n\nDecision ID: {decision_id}\n\nThe {product_name} team"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Perks from your redeemed gift have been removed",
|
||||
"body": "Hello {username},\n\nA gift code you redeemed was originally paid for by someone else. That payment has since been reversed (a chargeback).\n\nBecause of this, we've removed the perks that were added to your account when you redeemed the gift.\n\n{support_email, select, null {If you think this is a mistake, please contact the administrators of this instance and include any details you have about the gift code and when you redeemed it.} other {If you think this is a mistake, please contact our support team and include any details you have about the gift code and when you redeemed it.}}\n\n– {product_name} Team"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Your {product_name} report has been reviewed",
|
||||
"body": "Hello {username},\n\nYour report (ID: {reportId}) has been reviewed by our Safety Team.{hasComment, select, yes {\n\nResponse from the Safety Team:\n{publicComment}} other {}}\n\nThanks for helping keep {product_name} safe for everyone. We take all reports seriously and appreciate your contribution to the community.\n\n{safety_email, select, null {If you have any questions or concerns about this outcome, please contact the administrators of this instance.} other {If you have any questions or concerns about this outcome, please contact {safety_email}.}}\n\n– {product_name} Safety Team"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Your {product_name} account will be permanently deleted",
|
||||
"body": "Hello {username},\n\nYour {product_name} account has been scheduled for permanent deletion.\n\nScheduled deletion: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.\n\nIf you believe this enforcement decision was incorrect, you can submit an appeal. {appeals_email, select, null {Contact the administrators of this instance.} other {Email {appeals_email} from this email address.}}\n\n– {product_name} Safety Team"
|
||||
"body": "Hello {username},\n\nWe reviewed your report (ID: {reportId}).\n\n{outcome, select, removed {We removed the content you reported because it broke our rules.} restricted {We found that the account you reported broke our rules, and we restricted it. We don't share what action we took against another account, because that is their personal data.} action_taken {We found that what you reported broke our rules, and we took action. We don't share what action we took against another account, because that is their personal data.} no_violation {We found that the content or account you reported doesn't break our rules, so we left it as it is.} other {We closed your report.}}{hasComment, select, yes {\n\nNote from our team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nIf you disagree, email {appeals_email} within 60 days and include the report ID. If you're in the EU, you can also refer this to a certified out-of-court dispute settlement body or complain to the Digital Services Coordinator in your country.}}\n\nThe {product_name} team"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Your {product_name} account deletion is scheduled",
|
||||
"body": "Hello {username},\n\nYou requested to delete your {product_name} account. Your account is scheduled for permanent deletion on:\n\n{deletionDate, date, full} at {deletionDate, time, short}\n\nIf you didn't request this, sign in to your account to cancel the deletion. We also recommend changing your password to secure your account.\n\n– {product_name} Team"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Your {product_name} account suspension has been lifted",
|
||||
"body": "Hello {username},\n\nGood news: your {product_name} account suspension has been lifted.\n\n{reason, select,\n null {}\n other {Reason: {reason}\n\n}\n}You can now log back in and continue using {product_name} as normal.\n\n– {product_name} Safety Team"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada debido a inactividad",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} lleva mucho tiempo inactiva, por lo que su eliminación permanente está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\n{safety_email, select, null {Si quieres conservar tu cuenta, comunícate con los administradores de esta instancia antes de esa fecha.} other {Si quieres conservar tu cuenta, comunícate con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.}}\n\n– Equipo de {product_name}"
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} lleva mucho tiempo inactiva, por lo que programamos su eliminación para el {deletion_date}.\n\nPara conservar tu cuenta, inicia sesión antes de esa fecha. {support_email, select, null {Si tienes preguntas, comunícate con los administradores de esta instancia.} other {Si tienes preguntas, comunícate con {support_email}.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nSegún lo que solicitaste, la eliminación permanente de tu cuenta de {product_name} está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nTu cuenta permanecerá bloqueada hasta entonces. {safety_email, select, null {Si no solicitaste esto, o si quieres conservar tu cuenta, comunícate con los administradores de esta instancia antes de esa fecha.} other {Si no solicitaste esto, o si quieres conservar tu cuenta, comunícate con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.}}\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name} debido a infracciones de nuestros Términos del servicio o Normas de la comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Esta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\n{legalLinks, select,\n both {Por favor, revisa:\n- Términos del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n terms {Por favor, revisa:\n- Términos del servicio: {termsUrl}\n\n}\n guidelines {Por favor, revisa:\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n other {}\n}Proceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. {appeals_email, select, null {Comunícate con los administradores de esta instancia.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.}}\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Aporta las pruebas o el contexto que consideres relevantes\n\nUn miembro del Equipo de seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tu cuenta de {product_name} ha sido suspendida temporalmente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por infringir nuestros Términos del servicio o Normas de la comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Durante este tiempo, no podrás acceder a tu cuenta.\n\n{legalLinks, select,\n both {Por favor, revisa:\n- Términos del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n terms {Por favor, revisa:\n- Términos del servicio: {termsUrl}\n\n}\n guidelines {Por favor, revisa:\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n other {}\n}Si crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. {appeals_email, select, null {Comunícate con los administradores de esta instancia y explica claramente por qué crees que la decisión fue incorrecta.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta.}} Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nTal como lo pediste, programamos la eliminación de tu cuenta de {product_name} para el {deletion_date}.\n\nPara conservar tu cuenta, inicia sesión antes del {deletion_date}. {safety_email, select, null {Si no lo pediste tú, comunícate con los administradores de esta instancia.} other {Si no lo pediste tú, comunícate con {safety_email}.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Tomamos una decisión sobre tu reporte en {product_name}",
|
||||
"body": "Hola:\n\nNuestro equipo de seguridad revisó tu reporte conforme a la Ley de Servicios Digitales (ID: {reportId}) y tomó una decisión.{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nNo compartimos detalles sobre las medidas tomadas contra la cuenta de otra persona, porque son sus datos personales.\n\nSi no estás de acuerdo con esta decisión, puedes apelar sin costo dentro de un plazo de 60 días. {appeals_email, select, null {Comunícate con los administradores de esta instancia, incluye el ID de tu reporte y explica por qué crees que la decisión es incorrecta.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico, incluye el ID de tu reporte y explica por qué crees que la decisión es incorrecta.}} Si te encuentras en la UE, también puedes remitir la disputa a un órgano certificado de resolución extrajudicial de litigios. Nada de esto afecta tu derecho a acudir a los tribunales.\n\n– Equipo de seguridad de {product_name}"
|
||||
"body": "Hola:\n\nRevisamos tu reporte (ID: {reportId}).\n\n{outcome, select, removed {Eliminamos el contenido que reportaste porque infringía nuestras reglas.} restricted {Comprobamos que la cuenta que reportaste infringía nuestras reglas y la restringimos. No compartimos qué medidas tomamos contra otra cuenta, porque son datos personales de su titular.} action_taken {Comprobamos que lo que reportaste infringía nuestras reglas y tomamos medidas. No compartimos qué medidas tomamos contra otra cuenta, porque son datos personales de su titular.} no_violation {Comprobamos que el contenido o la cuenta que reportaste no infringe nuestras reglas, así que no cambiamos nada.} other {Cerramos tu reporte.}}{hasComment, select, yes {\n\nNota de nuestro equipo: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSi no estás de acuerdo, escribe a {appeals_email} en un plazo de 60 días e indica el ID del reporte. Si estás en la UE, también puedes someter el asunto a un órgano de resolución extrajudicial de litigios certificado o presentar una reclamación ante el coordinador de servicios digitales de tu país.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifica tu correo electrónico para enviar un reporte conforme a la Ley de Servicios Digitales",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Verifica tu dirección de correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nPor favor, verifica la dirección de correo electrónico de tu cuenta de {product_name} haciendo clic en el siguiente enlace:\n\n{verifyUrl}\n\nSi no creaste una cuenta de {product_name}, puedes ignorar este correo electrónico de forma segura.\n\nEste enlace es válido por 24 horas.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Revocamos una decisión sobre tu cuenta de {product_name}} other {Terminó una restricción de tu cuenta de {product_name}}}",
|
||||
"body": "Hola {username}:\n\n{outcome, select, reversed {{decision_id, select, null {Volvimos a revisar una decisión sobre tu cuenta y la revocamos.} other {Volvimos a revisar la decisión {decision_id} y la revocamos.}} Lamentamos las molestias.} other {{decision_id, select, null {Levantamos la restricción de tu cuenta.} other {Levantamos la restricción de la decisión {decision_id}.}}}}{restores_access, select, yes { Ya puedes iniciar sesión y volver a usar {product_name}.} other {}}{statement, select, null {} other {\n\nNota de nuestro equipo: {statement}}}{content_removed, select, yes {\n\nEl contenido que eliminamos no se puede restaurar.} other {}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Información sobre tu cuenta de {product_name}} other {{kind_label, select, warning {Una advertencia sobre tu cuenta de {product_name}} suspension {Tu cuenta de {product_name} está suspendida} ban {Tu cuenta de {product_name} está suspendida de forma permanente} closure {Tu cuenta de {product_name} está cerrada} feature_limit {Algunas funciones de tu cuenta de {product_name} están limitadas} content_removal {Eliminamos mensajes que enviaste en {product_name}} profile_reset {Restablecimos partes de tu perfil de {product_name}} community_removal {Eliminamos tu comunidad en {product_name}} bot {Información sobre tu bot en {product_name}} other {Información sobre tu cuenta de {product_name}}}}}",
|
||||
"body": "Hola {username}:\n\n{summary}\n\n{effects}\n\nPor qué\nRegla: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNota de nuestro equipo: {statement}}}\n\nCómo lo decidimos\n{how_we_decided}{appeals_email, select, null {} other {\n\nCómo apelar\nSi crees que nos equivocamos, escribe a {appeals_email} a más tardar el {appeal_deadline}. Si puedes, escribe desde la dirección de correo electrónico de tu cuenta e indica el ID de la decisión. Apelar es gratis, y una persona revisa cada apelación.{redress, select, null {} other {\n\n{redress}}}}}\n\nID de la decisión: {decision_id}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Los beneficios de tu regalo canjeado han sido eliminados",
|
||||
"body": "Hola {username}:\n\nUn código de regalo que canjeaste fue pagado originalmente por otra persona. Ese pago ha sido revertido (un contracargo).\n\nDebido a esto, hemos eliminado los beneficios que se agregaron a tu cuenta cuando canjeaste el regalo.\n\n{support_email, select, null {Si crees que esto es un error, comunícate con los administradores de esta instancia e incluye cualquier detalle que tengas sobre el código de regalo y cuándo lo canjeaste.} other {Si crees que esto es un error, comunícate con nuestro equipo de soporte e incluye cualquier detalle que tengas sobre el código de regalo y cuándo lo canjeaste.}}\n\n– Equipo de {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Se revisó tu reporte en {product_name}",
|
||||
"body": "Hola {username}:\n\nNuestro equipo de seguridad revisó tu reporte (ID: {reportId}).{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nGracias por ayudar a mantener {product_name} seguro para todos. Nos tomamos todos los reportes en serio y agradecemos tu contribución a la comunidad.\n\n{safety_email, select, null {Si tienes alguna pregunta o inquietud sobre este resultado, comunícate con los administradores de esta instancia.} other {Si tienes alguna pregunta o inquietud sobre este resultado, comunícate con {safety_email}.}}\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name}.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Esta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. {appeals_email, select, null {Comunícate con los administradores de esta instancia.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.}}\n\n– Equipo de seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nRevisamos tu reporte (ID: {reportId}).\n\n{outcome, select, removed {Eliminamos el contenido que reportaste porque infringía nuestras reglas.} restricted {Comprobamos que la cuenta que reportaste infringía nuestras reglas y la restringimos. No compartimos qué medidas tomamos contra otra cuenta, porque son datos personales de su titular.} action_taken {Comprobamos que lo que reportaste infringía nuestras reglas y tomamos medidas. No compartimos qué medidas tomamos contra otra cuenta, porque son datos personales de su titular.} no_violation {Comprobamos que el contenido o la cuenta que reportaste no infringe nuestras reglas, así que no cambiamos nada.} other {Cerramos tu reporte.}}{hasComment, select, yes {\n\nNota de nuestro equipo: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSi no estás de acuerdo, escribe a {appeals_email} en un plazo de 60 días e indica el ID del reporte. Si estás en la UE, también puedes someter el asunto a un órgano de resolución extrajudicial de litigios certificado o presentar una reclamación ante el coordinador de servicios digitales de tu país.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. La eliminación permanente de tu cuenta está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Se levantó la suspensión de tu cuenta de {product_name}",
|
||||
"body": "Hola {username}:\n\nBuenas noticias: se levantó la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Ahora puedes iniciar sesión de nuevo y seguir usando {product_name} con normalidad.\n\n– Equipo de seguridad de {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada debido a la inactividad",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} lleva mucho tiempo inactiva, por lo que su eliminación permanente está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\n{safety_email, select, null {Si quieres conservar tu cuenta, ponte en contacto con los administradores de esta instancia antes de esa fecha.} other {Si quieres conservar tu cuenta, ponte en contacto con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.}}\n\n– Equipo de {product_name}"
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} lleva mucho tiempo inactiva, por lo que hemos programado su eliminación para el {deletion_date}.\n\nPara conservar tu cuenta, inicia sesión antes de esa fecha. {support_email, select, null {Si tienes preguntas, ponte en contacto con los administradores de esta instancia.} other {Si tienes preguntas, ponte en contacto con {support_email}.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nTal y como solicitaste, la eliminación permanente de tu cuenta de {product_name} está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nTu cuenta permanecerá bloqueada hasta entonces. {safety_email, select, null {Si no solicitaste esto, o si quieres conservar tu cuenta, ponte en contacto con los administradores de esta instancia antes de esa fecha.} other {Si no solicitaste esto, o si quieres conservar tu cuenta, ponte en contacto con {safety_email} desde esta dirección de correo electrónico antes de esa fecha.}}\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name} debido a infracciones de nuestras Condiciones del servicio o Normas de la comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Esta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\n{legalLinks, select,\n both {Por favor, revisa:\n- Condiciones del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n terms {Por favor, revisa:\n- Condiciones del servicio: {termsUrl}\n\n}\n guidelines {Por favor, revisa:\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n other {}\n}Proceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. {appeals_email, select, null {Ponte en contacto con los administradores de esta instancia.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.}}\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Aporta las pruebas o el contexto que consideres relevantes\n\nUn miembro del Equipo de seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tu cuenta de {product_name} ha sido suspendida temporalmente",
|
||||
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por infringir nuestras Condiciones del servicio o Normas de la comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Durante este tiempo, no podrás acceder a tu cuenta.\n\n{legalLinks, select,\n both {Por favor, revisa:\n- Condiciones del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n terms {Por favor, revisa:\n- Condiciones del servicio: {termsUrl}\n\n}\n guidelines {Por favor, revisa:\n- Normas de la comunidad: {guidelinesUrl}\n\n}\n other {}\n}Si crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. {appeals_email, select, null {Ponte en contacto con los administradores de esta instancia y explica claramente por qué crees que la decisión fue incorrecta.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta.}} Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nTal como pediste, hemos programado la eliminación de tu cuenta de {product_name} para el {deletion_date}.\n\nPara conservar tu cuenta, inicia sesión antes del {deletion_date}. {safety_email, select, null {Si no lo has pedido tú, ponte en contacto con los administradores de esta instancia.} other {Si no lo has pedido tú, ponte en contacto con {safety_email}.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Hemos tomado una decisión sobre tu denuncia en {product_name}",
|
||||
"body": "Hola:\n\nNuestro equipo de seguridad ha revisado tu denuncia conforme a la Ley de Servicios Digitales (ID: {reportId}) y ha tomado una decisión.{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nNo compartimos detalles sobre las medidas adoptadas contra la cuenta de otra persona, porque son sus datos personales.\n\nSi no estás de acuerdo con esta decisión, puedes presentar una apelación de forma gratuita en un plazo de 60 días. {appeals_email, select, null {Ponte en contacto con los administradores de esta instancia, incluye el ID de tu denuncia y explica por qué crees que la decisión es incorrecta.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico, incluye el ID de tu denuncia y explica por qué crees que la decisión es incorrecta.}} Si te encuentras en la UE, también puedes someter el litigio a un órgano certificado de resolución extrajudicial de litigios. Nada de esto afecta a tu derecho a acudir a los tribunales.\n\n– Equipo de seguridad de {product_name}"
|
||||
"body": "Hola:\n\nHemos revisado tu denuncia (ID: {reportId}).\n\n{outcome, select, removed {Hemos eliminado el contenido que denunciaste porque infringía nuestras normas.} restricted {Hemos comprobado que la cuenta que denunciaste infringía nuestras normas y la hemos restringido. No compartimos qué medidas hemos tomado contra otra cuenta, porque son datos personales de su titular.} action_taken {Hemos comprobado que lo que denunciaste infringía nuestras normas y hemos tomado medidas. No compartimos qué medidas hemos tomado contra otra cuenta, porque son datos personales de su titular.} no_violation {Hemos comprobado que el contenido o la cuenta que denunciaste no infringe nuestras normas, así que no hemos cambiado nada.} other {Hemos cerrado tu denuncia.}}{hasComment, select, yes {\n\nNota de nuestro equipo: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSi no estás de acuerdo, escribe a {appeals_email} en un plazo de 60 días e indica el ID de la denuncia. Si estás en la UE, también puedes someter el asunto a un órgano de resolución extrajudicial de litigios certificado o presentar una reclamación ante el coordinador de servicios digitales de tu país.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifica tu correo electrónico para enviar una denuncia conforme a la Ley de Servicios Digitales",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Verifica tu dirección de correo electrónico en {product_name}",
|
||||
"body": "Hola {username}:\n\nVerifica la dirección de correo electrónico de tu cuenta de {product_name} haciendo clic en el siguiente enlace:\n\n{verifyUrl}\n\nSi no creaste una cuenta de {product_name}, puedes ignorar este correo electrónico.\n\nEste enlace es válido durante 24 horas.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Hemos revocado una decisión sobre tu cuenta de {product_name}} other {Ha terminado una restricción de tu cuenta de {product_name}}}",
|
||||
"body": "Hola {username}:\n\n{outcome, select, reversed {{decision_id, select, null {Hemos vuelto a revisar una decisión sobre tu cuenta y la hemos revocado.} other {Hemos vuelto a revisar la decisión {decision_id} y la hemos revocado.}} Sentimos las molestias.} other {{decision_id, select, null {Hemos levantado la restricción de tu cuenta.} other {Hemos levantado la restricción de la decisión {decision_id}.}}}}{restores_access, select, yes { Ya puedes iniciar sesión y volver a usar {product_name}.} other {}}{statement, select, null {} other {\n\nNota de nuestro equipo: {statement}}}{content_removed, select, yes {\n\nEl contenido que hemos eliminado no se puede restaurar.} other {}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Información sobre tu cuenta de {product_name}} other {{kind_label, select, warning {Una advertencia sobre tu cuenta de {product_name}} suspension {Tu cuenta de {product_name} está suspendida} ban {Tu cuenta de {product_name} está suspendida de forma permanente} closure {Tu cuenta de {product_name} está cerrada} feature_limit {Algunas funciones de tu cuenta de {product_name} están limitadas} content_removal {Hemos eliminado mensajes que enviaste en {product_name}} profile_reset {Hemos restablecido partes de tu perfil de {product_name}} community_removal {Hemos eliminado tu comunidad en {product_name}} bot {Información sobre tu bot en {product_name}} other {Información sobre tu cuenta de {product_name}}}}}",
|
||||
"body": "Hola {username}:\n\n{summary}\n\n{effects}\n\nPor qué\nNorma: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNota de nuestro equipo: {statement}}}\n\nCómo lo hemos decidido\n{how_we_decided}{appeals_email, select, null {} other {\n\nCómo apelar\nSi crees que nos hemos equivocado, escribe a {appeals_email} a más tardar el {appeal_deadline}. Si puedes, escribe desde la dirección de correo electrónico de tu cuenta e indica el ID de la decisión. Apelar es gratis, y una persona revisa cada apelación.{redress, select, null {} other {\n\n{redress}}}}}\n\nID de la decisión: {decision_id}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Se han eliminado las ventajas obtenidas con tu regalo canjeado",
|
||||
"body": "Hola {username}:\n\nUn código de regalo que canjeaste fue pagado originalmente por otra persona. Ese pago ha sido revertido (una devolución de cargo).\n\nDebido a esto, hemos eliminado las ventajas que se añadieron a tu cuenta cuando canjeaste el regalo.\n\n{support_email, select, null {Si crees que esto es un error, ponte en contacto con los administradores de esta instancia e incluye cualquier detalle que tengas sobre el código de regalo y cuándo lo canjeaste.} other {Si crees que esto es un error, ponte en contacto con nuestro equipo de soporte e incluye cualquier detalle que tengas sobre el código de regalo y cuándo lo canjeaste.}}\n\n– Equipo de {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Se ha revisado tu denuncia en {product_name}",
|
||||
"body": "Hola {username}:\n\nNuestro equipo de seguridad ha revisado tu denuncia (ID: {reportId}).{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nGracias por ayudarnos a mantener {product_name} seguro para todos. Nos tomamos todas las denuncias en serio y agradecemos tu contribución a la comunidad.\n\n{safety_email, select, null {Si tienes alguna pregunta o inquietud sobre este resultado, ponte en contacto con los administradores de esta instancia.} other {Si tienes alguna pregunta o inquietud sobre este resultado, ponte en contacto con {safety_email}.}}\n\n– Equipo de seguridad de {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
|
||||
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name}.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Esta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. {appeals_email, select, null {Ponte en contacto con los administradores de esta instancia.} other {Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.}}\n\n– Equipo de seguridad de {product_name}"
|
||||
"body": "Hola {username}:\n\nHemos revisado tu denuncia (ID: {reportId}).\n\n{outcome, select, removed {Hemos eliminado el contenido que denunciaste porque infringía nuestras normas.} restricted {Hemos comprobado que la cuenta que denunciaste infringía nuestras normas y la hemos restringido. No compartimos qué medidas hemos tomado contra otra cuenta, porque son datos personales de su titular.} action_taken {Hemos comprobado que lo que denunciaste infringía nuestras normas y hemos tomado medidas. No compartimos qué medidas hemos tomado contra otra cuenta, porque son datos personales de su titular.} no_violation {Hemos comprobado que el contenido o la cuenta que denunciaste no infringe nuestras normas, así que no hemos cambiado nada.} other {Hemos cerrado tu denuncia.}}{hasComment, select, yes {\n\nNota de nuestro equipo: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSi no estás de acuerdo, escribe a {appeals_email} en un plazo de 60 días e indica el ID de la denuncia. Si estás en la UE, también puedes someter el asunto a un órgano de resolución extrajudicial de litigios certificado o presentar una reclamación ante el coordinador de servicios digitales de tu país.}}\n\nEl equipo de {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "La eliminación de tu cuenta de {product_name} está programada",
|
||||
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. La eliminación permanente de tu cuenta está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Se ha levantado la suspensión de tu cuenta de {product_name}",
|
||||
"body": "Hola {username}:\n\nBuenas noticias: se ha levantado la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Ahora puedes volver a iniciar sesión y seguir usando {product_name} con normalidad.\n\n– Equipo de seguridad de {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}-tilisi poistetaan käyttämättömyyden vuoksi",
|
||||
"body": "Hei {username},\n\n{product_name}-tilisi on ollut pitkään käyttämättä, joten se on ajoitettu poistettavaksi pysyvästi:\n\n{deletionDate, date, full} klo {deletionDate, time, short}{reason, select, null {} other {\n\nSyy: {reason}}}\n\n{safety_email, select, null {Jos haluat säilyttää tilisi, ota yhteyttä tämän instanssin ylläpitäjiin ennen tätä päivämäärää.} other {Jos haluat säilyttää tilisi, ota yhteyttä osoitteeseen {safety_email} tästä sähköpostiosoitteesta ennen tätä päivämäärää.}}\n\n– {product_name}-tiimi"
|
||||
"body": "Hei {username},\n\n{product_name}-tilisi on ollut pitkään käyttämättä, joten ajoitimme sen poistettavaksi {deletion_date}.\n\nJos haluat säilyttää tilisi, kirjaudu sisään ennen tätä päivämäärää. {support_email, select, null {Jos sinulla on kysyttävää, ota yhteyttä tämän instanssin ylläpitäjiin.} other {Jos sinulla on kysyttävää, ota yhteyttä osoitteeseen {support_email}.}}\n\n{product_name}-tiimi"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name}-tilisi poistaminen on ajoitettu",
|
||||
"body": "Hei {username},\n\nPyyntösi mukaisesti {product_name}-tilisi on ajoitettu poistettavaksi pysyvästi:\n\n{deletionDate, date, full} klo {deletionDate, time, short}{reason, select, null {} other {\n\nSyy: {reason}}}\n\nTilisi on lukittu siihen asti. {safety_email, select, null {Jos et pyytänyt tätä tai haluat säilyttää tilisi, ota yhteyttä tämän instanssin ylläpitäjiin ennen tätä päivämäärää.} other {Jos et pyytänyt tätä tai haluat säilyttää tilisi, ota yhteyttä osoitteeseen {safety_email} tästä sähköpostiosoitteesta ennen tätä päivämäärää.}}\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa poistetaan pysyvästi",
|
||||
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on ajoitettu poistettavaksi pysyvästi käyttöehtojemme tai yhteisösääntöjemme rikkomisen vuoksi.\n\nAjoitettu poisto: {deletionDate, date, full} klo {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Syy: {reason}\n\n}\n}Tämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi ajoitettuna päivänä.\n\n{legalLinks, select,\n both {Tutustu:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\n}\n terms {Tutustu:\n- Käyttöehdot: {termsUrl}\n\n}\n guidelines {Tutustu:\n- Yhteisön säännöt: {guidelinesUrl}\n\n}\n other {}\n}Valitusprosessi:\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, sinulla on 60 päivää aikaa tehdä valitus. {appeals_email, select, null {Ota yhteyttä tämän instanssin ylläpitäjiin.} other {Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta.}}\n\nValituksessasi:\n- Selitä selkeästi, miksi uskot päätöksen olleen virheellinen tai perusteeton\n- Liitä mukaan asiaankuuluvat todisteet tai taustatiedot\n\n{product_name}-turvallisuustiimin jäsen käsittelee valituksesi ja voi keskeyttää vireillä olevan poiston, kunnes lopullinen päätös on tehty.\n\n– {product_name}-turvallisuustiimi"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa on tilapäisesti jäädytetty",
|
||||
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on jäädytetty tilapäisesti käyttöehtojemme tai yhteisösääntöjemme rikkomisen vuoksi.\n\nKesto: {durationHours, plural,\n =1 {1 tunti}\n other {# tuntia}\n}\nJäädytettynä {bannedUntil, date, full} klo {bannedUntil, time, short} asti\n\n{reason, select,\n null {}\n other {Syy: {reason}\n\n}\n}Tänä aikana et voi käyttää tiliäsi.\n\n{legalLinks, select,\n both {Tutustu:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\n}\n terms {Tutustu:\n- Käyttöehdot: {termsUrl}\n\n}\n guidelines {Tutustu:\n- Yhteisön säännöt: {guidelinesUrl}\n\n}\n other {}\n}Jos uskot, että tämä päätös oli virheellinen tai perusteeton, voit tehdä valituksen. {appeals_email, select, null {Ota yhteyttä tämän instanssin ylläpitäjiin ja selitä selkeästi, miksi uskot päätöksen olleen virheellinen.} other {Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta ja selitä selkeästi, miksi uskot päätöksen olleen virheellinen.}} Käsittelemme valituksesi ja ilmoitamme sinulle päätöksemme.\n\n– {product_name}-turvallisuustiimi"
|
||||
"body": "Hei {username},\n\nPyyntösi mukaisesti ajoitimme {product_name}-tilisi poistettavaksi {deletion_date}.\n\nJos haluat säilyttää tilisi, kirjaudu sisään ennen {deletion_date}. {safety_email, select, null {Jos et pyytänyt tätä, ota yhteyttä tämän instanssin ylläpitäjiin.} other {Jos et pyytänyt tätä, ota yhteyttä osoitteeseen {safety_email}.}}\n\n{product_name}-tiimi"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Kiitos {product_name}-lahjoituksestasi",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Teimme päätöksen {product_name}-ilmoituksestasi",
|
||||
"body": "Hei,\n\nTurvallisuustiimimme on käsitellyt digipalvelusäädöksen mukaisen ilmoituksesi (ID: {reportId}) ja tehnyt päätöksen.{hasComment, select, yes {\n\nTurvallisuustiimin vastaus:\n{publicComment}} other {}}\n\nEmme kerro toisen henkilön tiliin kohdistuneista toimista, koska ne ovat hänen henkilötietojaan.\n\nJos olet eri mieltä päätöksestä, voit valittaa siitä maksutta 60 päivän kuluessa. {appeals_email, select, null {Ota yhteyttä tämän instanssin ylläpitäjiin, kerro ilmoituksesi tunnus ja selitä, miksi pidät päätöstä virheellisenä.} other {Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta, kerro ilmoituksesi tunnus ja selitä, miksi pidät päätöstä virheellisenä.}} Jos olet EU:ssa, voit myös viedä riidan sertifioidun tuomioistuimen ulkopuolisen riidanratkaisuelimen käsiteltäväksi. Mikään tästä ei vaikuta oikeuteesi viedä asia tuomioistuimeen.\n\n– {product_name}-turvallisuustiimi"
|
||||
"body": "Hei,\n\nKävimme läpi ilmoituksesi (tunnus: {reportId}).\n\n{outcome, select, removed {Poistimme ilmoittamasi sisällön, koska se rikkoi sääntöjämme.} restricted {Totesimme, että ilmoittamasi tili rikkoi sääntöjämme, ja rajoitimme sitä. Emme kerro, mihin toimiin ryhdyimme toista tiliä kohtaan, koska se on kyseisen henkilön henkilötietoa.} action_taken {Totesimme, että ilmoittamasi asia rikkoi sääntöjämme, ja ryhdyimme toimiin. Emme kerro, mihin toimiin ryhdyimme toista tiliä kohtaan, koska se on kyseisen henkilön henkilötietoa.} no_violation {Totesimme, että ilmoittamasi sisältö tai tili ei riko sääntöjämme, joten jätimme sen ennalleen.} other {Suljimme ilmoituksesi.}}{hasComment, select, yes {\n\nTiimimme huomautus: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJos olet eri mieltä, lähetä sähköpostia osoitteeseen {appeals_email} 60 päivän kuluessa ja mainitse ilmoituksen tunnus. Jos olet EU:ssa, voit myös saattaa asian sertifioidun tuomioistuinten ulkopuolisen riitojenratkaisuelimen käsiteltäväksi tai tehdä valituksen maasi digitaalisten palvelujen koordinaattorille.}}\n\n{product_name}-tiimi"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Vahvista sähköpostiosoitteesi DSA-ilmoitusta varten",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Vahvista sähköpostiosoitteesi {product_name}-tilillä",
|
||||
"body": "Hei {username},\n\nVahvista {product_name}-tilisi sähköpostiosoite napsauttamalla alla olevaa linkkiä:\n\n{verifyUrl}\n\nJos et luonut {product_name}-tiliä, voit jättää tämän sähköpostin turvallisesti huomiotta.\n\nTämä linkki on voimassa 24 tuntia.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Kumosimme {product_name}-tiliäsi koskevan päätöksen} other {{product_name}-tilisi rajoitus on päättynyt}}",
|
||||
"body": "Hei {username},\n\n{outcome, select, reversed {{decision_id, select, null {Tarkastelimme uudelleen tiliäsi koskevaa päätöstä ja kumosimme sen.} other {Tarkastelimme uudelleen päätöstä {decision_id} ja kumosimme sen.}} Pahoittelemme aiheutunutta harmia.} other {{decision_id, select, null {Poistimme tiliisi kohdistuneen rajoituksen.} other {Poistimme päätökseen {decision_id} perustuneen rajoituksen.}}}}{restores_access, select, yes { Voit taas kirjautua sisään ja käyttää palvelua {product_name}.} other {}}{statement, select, null {} other {\n\nTiimimme huomautus: {statement}}}{content_removed, select, yes {\n\nPoistamaamme sisältöä ei voi palauttaa.} other {}}\n\n{product_name}-tiimi"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Tietoa {product_name}-tilistäsi} other {{kind_label, select, warning {Varoitus {product_name}-tilisi toiminnasta} suspension {{product_name}-tilisi on jäädytetty} ban {{product_name}-tilisi on jäädytetty pysyvästi} closure {{product_name}-tilisi on suljettu} feature_limit {Joitakin {product_name}-tilisi ominaisuuksia on rajoitettu} content_removal {Poistimme viestejä, jotka lähetit palvelussa {product_name}} profile_reset {Nollasimme osia {product_name}-profiilistasi} community_removal {Poistimme yhteisösi palvelusta {product_name}} bot {Tietoa botistasi palvelussa {product_name}} other {Tietoa {product_name}-tilistäsi}}}}",
|
||||
"body": "Hei {username},\n\n{summary}\n\n{effects}\n\nMiksi\nSääntö: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nTiimimme huomautus: {statement}}}\n\nMiten päätimme\n{how_we_decided}{appeals_email, select, null {} other {\n\nNäin valitat päätöksestä\nJos uskot, että teimme virheen, lähetä sähköpostia osoitteeseen {appeals_email} viimeistään {appeal_deadline}. Kirjoita tilisi sähköpostiosoitteesta, jos voit, ja mainitse päätöksen tunnus. Valittaminen on maksutonta, ja jokaisen valituksen käsittelee ihminen.{redress, select, null {} other {\n\n{redress}}}}}\n\nPäätöksen tunnus: {decision_id}\n\n{product_name}-tiimi"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Lunastamasi lahjan edut on poistettu",
|
||||
"body": "Hei {username},\n\nLunastamasi lahjakoodin oli alun perin maksanut joku muu. Kyseinen maksu on sittemmin peruutettu (takaisinperintä).\n\nTämän vuoksi olemme poistaneet edut, jotka lisättiin tilillesi, kun lunastit lahjan.\n\n{support_email, select, null {Jos uskot tämän olevan virhe, ota yhteyttä tämän instanssin ylläpitäjiin ja liitä mukaan kaikki tiedot lahjakoodista ja sen lunastusajankohdasta.} other {Jos uskot tämän olevan virhe, ota yhteyttä tukitiimiimme ja liitä mukaan kaikki tiedot lahjakoodista ja sen lunastusajankohdasta.}}\n\n– {product_name}-tiimi"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "{product_name}-ilmoituksesi on käsitelty",
|
||||
"body": "Hei {username},\n\nTurvallisuustiimimme on käsitellyt ilmoituksesi (ID: {reportId}).{hasComment, select, yes {\n\nTurvallisuustiimin vastaus:\n{publicComment}} other {}}\n\nKiitos, että autat pitämään {product_name}-palvelun turvallisena kaikille. Otamme kaikki ilmoitukset vakavasti ja arvostamme panostasi yhteisöön.\n\n{safety_email, select, null {Jos sinulla on kysyttävää tai huolenaiheita tästä päätöksestä, ota yhteyttä tämän instanssin ylläpitäjiin.} other {Jos sinulla on kysyttävää tai huolenaiheita tästä päätöksestä, ota yhteyttä osoitteeseen {safety_email}.}}\n\n– {product_name}-turvallisuustiimi"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Käyttäjätilisi {product_name}-palvelussa poistetaan pysyvästi",
|
||||
"body": "Hei {username},\n\n{product_name}-tilisi on ajoitettu poistettavaksi pysyvästi.\n\nAjoitettu poisto: {deletionDate, date, full} klo {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Syy: {reason}\n\n}\n}Tämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi ajoitettuna päivänä.\n\nJos uskot, että tämä päätös oli virheellinen, voit tehdä valituksen. {appeals_email, select, null {Ota yhteyttä tämän instanssin ylläpitäjiin.} other {Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta.}}\n\n– {product_name}-turvallisuustiimi"
|
||||
"body": "Hei {username},\n\nKävimme läpi ilmoituksesi (tunnus: {reportId}).\n\n{outcome, select, removed {Poistimme ilmoittamasi sisällön, koska se rikkoi sääntöjämme.} restricted {Totesimme, että ilmoittamasi tili rikkoi sääntöjämme, ja rajoitimme sitä. Emme kerro, mihin toimiin ryhdyimme toista tiliä kohtaan, koska se on kyseisen henkilön henkilötietoa.} action_taken {Totesimme, että ilmoittamasi asia rikkoi sääntöjämme, ja ryhdyimme toimiin. Emme kerro, mihin toimiin ryhdyimme toista tiliä kohtaan, koska se on kyseisen henkilön henkilötietoa.} no_violation {Totesimme, että ilmoittamasi sisältö tai tili ei riko sääntöjämme, joten jätimme sen ennalleen.} other {Suljimme ilmoituksesi.}}{hasComment, select, yes {\n\nTiimimme huomautus: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJos olet eri mieltä, lähetä sähköpostia osoitteeseen {appeals_email} 60 päivän kuluessa ja mainitse ilmoituksen tunnus. Jos olet EU:ssa, voit myös saattaa asian sertifioidun tuomioistuinten ulkopuolisen riitojenratkaisuelimen käsiteltäväksi tai tehdä valituksen maasi digitaalisten palvelujen koordinaattorille.}}\n\n{product_name}-tiimi"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "{product_name}-tilisi poistaminen on ajoitettu",
|
||||
"body": "Hei {username},\n\nPyysit {product_name}-tilisi poistamista. Tilisi on ajoitettu poistettavaksi pysyvästi:\n\n{deletionDate, date, full} klo {deletionDate, time, short}\n\nJos et pyytänyt tätä, kirjaudu tilillesi peruuttaaksesi poiston. Suosittelemme myös vaihtamaan salasanasi tilisi turvaamiseksi.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "{product_name}-tilisi jäädytys on poistettu",
|
||||
"body": "Hei {username},\n\nHyviä uutisia: {product_name}-tilisi jäädytys on poistettu.\n\n{reason, select,\n null {}\n other {Syy: {reason}\n\n}\n}Voit nyt kirjautua takaisin sisään ja jatkaa {product_name}-palvelun käyttöä normaalisti.\n\n– {product_name}-turvallisuustiimi"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Votre compte {product_name} sera supprimé pour inactivité",
|
||||
"body": "Bonjour {username},\n\nVotre compte {product_name} est inactif depuis longtemps. Sa suppression définitive est donc prévue le :\n\n{deletionDate, date, full} à {deletionDate, time, short}{reason, select, null {} other {\n\nMotif : {reason}}}\n\n{safety_email, select, null {Si vous souhaitez conserver votre compte, contactez les administrateurs de cette instance avant cette date.} other {Si vous souhaitez conserver votre compte, contactez {safety_email} depuis cette adresse e-mail avant cette date.}}\n\n– L’équipe {product_name}"
|
||||
"body": "Bonjour {username},\n\nVotre compte {product_name} est inactif depuis longtemps. Nous avons donc programmé sa suppression pour le {deletion_date}.\n\nPour conserver votre compte, connectez-vous avant cette date. {support_email, select, null {Si vous avez des questions, contactez les administrateurs de cette instance.} other {Si vous avez des questions, contactez {support_email}.}}\n\nL’équipe {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "La suppression de votre compte {product_name} est programmée",
|
||||
"body": "Bonjour {username},\n\nComme vous l’avez demandé, la suppression définitive de votre compte {product_name} est prévue le :\n\n{deletionDate, date, full} à {deletionDate, time, short}{reason, select, null {} other {\n\nMotif : {reason}}}\n\nVotre compte est verrouillé d’ici là. {safety_email, select, null {Si vous n’êtes pas à l’origine de cette demande, ou si vous souhaitez conserver votre compte, contactez les administrateurs de cette instance avant cette date.} other {Si vous n’êtes pas à l’origine de cette demande, ou si vous souhaitez conserver votre compte, contactez {safety_email} depuis cette adresse e-mail avant cette date.}}\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Votre compte {product_name} sera définitivement supprimé",
|
||||
"body": "Bonjour {username},\n\nLa suppression définitive de votre compte {product_name} a été programmée en raison de violations de nos conditions d’utilisation ou des règles de la communauté.\n\nSuppression prévue : {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select, null {} other {Motif : {reason}\n\n}}Il s’agit d’une sanction importante. Les données de votre compte seront définitivement supprimées à la date prévue.\n\n{legalLinks, select,\n both {Veuillez consulter :\n- Conditions d’utilisation : {termsUrl}\n- Règles de la communauté : {guidelinesUrl}\n\n}\n terms {Veuillez consulter :\n- Conditions d’utilisation : {termsUrl}\n\n}\n guidelines {Veuillez consulter :\n- Règles de la communauté : {guidelinesUrl}\n\n}\n other {}\n}Procédure de recours :\nSi vous estimez que cette décision est incorrecte ou injustifiée, vous disposez de 60 jours pour déposer un recours. {appeals_email, select, null {Contactez les administrateurs de cette instance.} other {Écrivez à {appeals_email} depuis cette adresse e-mail.}}\n\nDans votre recours :\n- Expliquez clairement pourquoi vous estimez la décision incorrecte ou injustifiée\n- Fournissez tout élément de preuve ou de contexte pertinent\n\nUn membre de l’équipe de sécurité de {product_name} examinera votre recours et pourra suspendre la suppression prévue jusqu’à la décision finale.\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Votre compte {product_name} a été temporairement suspendu",
|
||||
"body": "Bonjour {username},\n\nVotre compte {product_name} a été temporairement suspendu pour violation de nos conditions d’utilisation ou des règles de la communauté.\n\nDurée : {durationHours, plural, =1 {1 heure} other {# heures}}\nSuspension jusqu’au : {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select, null {} other {Motif : {reason}\n\n}}Vous ne pourrez pas accéder à votre compte pendant cette période.\n\n{legalLinks, select,\n both {Veuillez consulter :\n- Conditions d’utilisation : {termsUrl}\n- Règles de la communauté : {guidelinesUrl}\n\n}\n terms {Veuillez consulter :\n- Conditions d’utilisation : {termsUrl}\n\n}\n guidelines {Veuillez consulter :\n- Règles de la communauté : {guidelinesUrl}\n\n}\n other {}\n}Si vous estimez que cette décision est incorrecte ou injustifiée, vous pouvez déposer un recours. {appeals_email, select, null {Contactez les administrateurs de cette instance et expliquez clairement pourquoi vous contestez cette décision.} other {Écrivez à {appeals_email} depuis cette adresse e-mail et expliquez clairement pourquoi vous contestez cette décision.}} Nous examinerons votre recours et vous communiquerons notre décision.\n\n– L’équipe de sécurité de {product_name}"
|
||||
"body": "Bonjour {username},\n\nComme vous l’avez demandé, la suppression de votre compte {product_name} est programmée pour le {deletion_date}.\n\nPour conserver votre compte, connectez-vous avant le {deletion_date}. {safety_email, select, null {Si vous n’êtes pas à l’origine de cette demande, contactez les administrateurs de cette instance.} other {Si vous n’êtes pas à l’origine de cette demande, contactez {safety_email}.}}\n\nL’équipe {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Merci pour votre don à {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Nous avons pris une décision concernant votre signalement sur {product_name}",
|
||||
"body": "Bonjour,\n\nNotre équipe de sécurité a examiné votre signalement au titre du règlement sur les services numériques (ID : {reportId}) et a pris une décision.{hasComment, select, yes {\n\nRéponse de l’équipe de sécurité :\n{publicComment}} other {}}\n\nNous ne communiquons pas le détail des mesures prises à l’encontre du compte d’une autre personne, car il s’agit de ses données personnelles.\n\nSi vous n’êtes pas d’accord avec cette décision, vous pouvez déposer un recours gratuitement dans un délai de 60 jours. {appeals_email, select, null {Contactez les administrateurs de cette instance, indiquez l’ID de votre signalement et expliquez pourquoi vous estimez que la décision est incorrecte.} other {Écrivez à {appeals_email} depuis cette adresse e-mail, indiquez l’ID de votre signalement et expliquez pourquoi vous estimez que la décision est incorrecte.}} Si vous êtes dans l’UE, vous pouvez également soumettre le litige à un organe certifié de règlement extrajudiciaire des litiges. Rien de tout cela n’affecte votre droit de saisir un tribunal.\n\n– L’équipe de sécurité de {product_name}"
|
||||
"body": "Bonjour,\n\nNous avons examiné votre signalement (ID : {reportId}).\n\n{outcome, select, removed {Nous avons supprimé le contenu que vous avez signalé, car il enfreignait nos règles.} restricted {Nous avons constaté que le compte que vous avez signalé enfreignait nos règles, et nous l’avons restreint. Nous ne communiquons pas les mesures prises contre un autre compte, car il s’agit de données personnelles de son titulaire.} action_taken {Nous avons constaté que ce que vous avez signalé enfreignait nos règles, et nous avons pris des mesures. Nous ne communiquons pas les mesures prises contre un autre compte, car il s’agit de données personnelles de son titulaire.} no_violation {Nous avons constaté que le contenu ou le compte que vous avez signalé n’enfreint pas nos règles, et nous n’avons donc rien changé.} other {Nous avons clôturé votre signalement.}}{hasComment, select, yes {\n\nNote de notre équipe : {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSi vous n’êtes pas d’accord, écrivez à {appeals_email} dans un délai de 60 jours en indiquant l’ID du signalement. Si vous êtes dans l’UE, vous pouvez également soumettre la question à un organe de règlement extrajudiciaire des litiges certifié ou introduire une réclamation auprès du coordinateur pour les services numériques de votre pays.}}\n\nL’équipe {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Vérifiez votre e-mail pour un signalement DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Vérifiez votre adresse e-mail {product_name}",
|
||||
"body": "Bonjour {username},\n\nVérifiez l’adresse e-mail de votre compte {product_name} en cliquant sur le lien ci-dessous :\n\n{verifyUrl}\n\nSi vous n’avez pas créé de compte {product_name}, vous pouvez ignorer cet e-mail.\n\nCe lien est valable pendant 24 heures.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Nous avons annulé une décision concernant votre compte {product_name}} other {Une restriction appliquée à votre compte {product_name} a pris fin}}",
|
||||
"body": "Bonjour {username},\n\n{outcome, select, reversed {{decision_id, select, null {Nous avons réexaminé une décision concernant votre compte et l’avons annulée.} other {Nous avons réexaminé la décision {decision_id} et l’avons annulée.}} Nous sommes désolés pour la gêne occasionnée.} other {{decision_id, select, null {Nous avons levé la restriction appliquée à votre compte.} other {Nous avons levé la restriction liée à la décision {decision_id}.}}}}{restores_access, select, yes { Vous pouvez de nouveau vous connecter et utiliser {product_name}.} other {}}{statement, select, null {} other {\n\nNote de notre équipe : {statement}}}{content_removed, select, yes {\n\nLe contenu que nous avons supprimé ne peut pas être restauré.} other {}}\n\nL’équipe {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Information concernant votre compte {product_name}} other {{kind_label, select, warning {Avertissement concernant votre compte {product_name}} suspension {Votre compte {product_name} est suspendu} ban {Votre compte {product_name} est banni} closure {Votre compte {product_name} est fermé} feature_limit {Certaines fonctionnalités de votre compte {product_name} sont limitées} content_removal {Nous avons supprimé des messages que vous avez envoyés sur {product_name}} profile_reset {Nous avons réinitialisé une partie de votre profil {product_name}} community_removal {Nous avons supprimé votre communauté sur {product_name}} bot {Information concernant votre bot sur {product_name}} other {Information concernant votre compte {product_name}}}}}",
|
||||
"body": "Bonjour {username},\n\n{summary}\n\n{effects}\n\nPourquoi\nRègle : {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNote de notre équipe : {statement}}}\n\nComment nous avons décidé\n{how_we_decided}{appeals_email, select, null {} other {\n\nComment contester la décision\nSi vous pensez que nous nous sommes trompés, écrivez à {appeals_email} au plus tard le {appeal_deadline}. Utilisez si possible l’adresse e-mail de votre compte, et indiquez l’ID de la décision. La contestation est gratuite, et une personne examine chaque demande.{redress, select, null {} other {\n\n{redress}}}}}\n\nID de la décision : {decision_id}\n\nL’équipe {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Les avantages de votre cadeau activé ont été retirés",
|
||||
"body": "Bonjour {username},\n\nUn code cadeau que vous avez utilisé avait été payé par une autre personne. Ce paiement a depuis été annulé à la suite d’une contestation bancaire.\n\nNous avons donc retiré les avantages ajoutés à votre compte lors de l’activation du cadeau.\n\n{support_email, select, null {Si vous pensez qu’il s’agit d’une erreur, contactez les administrateurs de cette instance et indiquez les informations dont vous disposez sur le code cadeau et sa date d’utilisation.} other {Si vous pensez qu’il s’agit d’une erreur, contactez notre équipe d’assistance et indiquez les informations dont vous disposez sur le code cadeau et sa date d’utilisation.}}\n\n– L’équipe {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Votre signalement sur {product_name} a été examiné",
|
||||
"body": "Bonjour {username},\n\nVotre signalement (ID : {reportId}) a été examiné par notre équipe de sécurité.{hasComment, select, yes {\n\nRéponse de l’équipe de sécurité :\n{publicComment}} other {}}\n\nMerci de nous aider à préserver la sécurité de {product_name} pour tous. Nous prenons tous les signalements au sérieux et apprécions votre contribution à la communauté.\n\n{safety_email, select, null {Pour toute question ou préoccupation concernant cette décision, contactez les administrateurs de cette instance.} other {Pour toute question ou préoccupation concernant cette décision, contactez {safety_email}.}}\n\n– L’équipe de sécurité de {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Votre compte {product_name} sera définitivement supprimé",
|
||||
"body": "Bonjour {username},\n\nLa suppression définitive de votre compte {product_name} a été programmée.\n\nSuppression prévue : {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select, null {} other {Motif : {reason}\n\n}}Il s’agit d’une sanction importante. Les données de votre compte seront définitivement supprimées à la date prévue.\n\nSi vous estimez que cette décision est incorrecte, vous pouvez déposer un recours. {appeals_email, select, null {Contactez les administrateurs de cette instance.} other {Écrivez à {appeals_email} depuis cette adresse e-mail.}}\n\n– L’équipe de sécurité de {product_name}"
|
||||
"body": "Bonjour {username},\n\nNous avons examiné votre signalement (ID : {reportId}).\n\n{outcome, select, removed {Nous avons supprimé le contenu que vous avez signalé, car il enfreignait nos règles.} restricted {Nous avons constaté que le compte que vous avez signalé enfreignait nos règles, et nous l’avons restreint. Nous ne communiquons pas les mesures prises contre un autre compte, car il s’agit de données personnelles de son titulaire.} action_taken {Nous avons constaté que ce que vous avez signalé enfreignait nos règles, et nous avons pris des mesures. Nous ne communiquons pas les mesures prises contre un autre compte, car il s’agit de données personnelles de son titulaire.} no_violation {Nous avons constaté que le contenu ou le compte que vous avez signalé n’enfreint pas nos règles, et nous n’avons donc rien changé.} other {Nous avons clôturé votre signalement.}}{hasComment, select, yes {\n\nNote de notre équipe : {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSi vous n’êtes pas d’accord, écrivez à {appeals_email} dans un délai de 60 jours en indiquant l’ID du signalement. Si vous êtes dans l’UE, vous pouvez également soumettre la question à un organe de règlement extrajudiciaire des litiges certifié ou introduire une réclamation auprès du coordinateur pour les services numériques de votre pays.}}\n\nL’équipe {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "La suppression de votre compte {product_name} est programmée",
|
||||
"body": "Bonjour {username},\n\nVous avez demandé la suppression de votre compte {product_name}. Sa suppression définitive est prévue le :\n\n{deletionDate, date, full} à {deletionDate, time, short}\n\nSi vous n’êtes pas à l’origine de cette demande, connectez-vous à votre compte pour annuler la suppression. Nous vous recommandons également de changer votre mot de passe pour sécuriser votre compte.\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "La suspension de votre compte {product_name} a été levée",
|
||||
"body": "Bonjour {username},\n\nBonne nouvelle : la suspension de votre compte {product_name} a été levée.\n\n{reason, select, null {} other {Motif : {reason}\n\n}}Vous pouvez maintenant vous reconnecter et utiliser {product_name} normalement.\n\n– L’équipe de sécurité de {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק עקב חוסר פעילות",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם לא היה פעיל זמן רב, ולכן הוא מתוכנן להימחק לצמיתות בתאריך:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}{reason, select, null {} other {\n\nסיבה: {reason}}}\n\n{safety_email, select, null {אם ברצונכם לשמור על החשבון, צרו קשר עם מנהלי המערכת של המופע הזה לפני תאריך זה.} other {אם ברצונכם לשמור על החשבון, צרו קשר עם {safety_email} מכתובת האימייל הזו לפני תאריך זה.}}\n\nצוות {product_name}"
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם לא היה פעיל זמן רב, ולכן תזמנו את מחיקתו ל-{deletion_date}.\n\nכדי לשמור על החשבון, התחברו לפני התאריך הזה. {support_email, select, null {אם יש לכם שאלות, צרו קשר עם מנהלי המערכת של המופע הזה.} other {אם יש לכם שאלות, צרו קשר עם {support_email}.}}\n\nצוות {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "מחיקת חשבון ה-{product_name} שלכם מתוזמנת",
|
||||
"body": "שלום {username},\n\nבהתאם לבקשתכם, חשבון ה-{product_name} שלכם מתוכנן להימחק לצמיתות בתאריך:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}{reason, select, null {} other {\n\nסיבה: {reason}}}\n\nעד אז החשבון שלכם נעול. {safety_email, select, null {אם לא ביקשתם זאת, או שברצונכם לשמור על החשבון, צרו קשר עם מנהלי המערכת של המופע הזה לפני תאריך זה.} other {אם לא ביקשתם זאת, או שברצונכם לשמור על החשבון, צרו קשר עם {safety_email} מכתובת האימייל הזו לפני תאריך זה.}}\n\nצוות {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק לצמיתות",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם נקבע למחיקה לצמיתות עקב הפרות של תנאי השירות או הנחיות הקהילה שלנו.\n\nמחיקה מתוזמנת: {deletionDate, date, full} בשעה {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}\n\n}\n}זוהי פעולת אכיפה חמורה. נתוני החשבון שלכם יימחקו לצמיתות בתאריך המתוכנן.\n\n{legalLinks, select,\n both {אנא עיינו במסמכים הבאים:\n- תנאי השירות: {termsUrl}\n- הנחיות הקהילה: {guidelinesUrl}\n\n}\n terms {אנא עיינו במסמכים הבאים:\n- תנאי השירות: {termsUrl}\n\n}\n guidelines {אנא עיינו במסמכים הבאים:\n- הנחיות הקהילה: {guidelinesUrl}\n\n}\n other {}\n}תהליך ערעור:\nאם אתם חושבים שהחלטת האכיפה שגויה או בלתי מוצדקת, יש לכם 60 יום להגיש ערעור. {appeals_email, select, null {צרו קשר עם מנהלי המערכת של המופע הזה.} other {שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו.}}\n\nבערעור:\n- הסבירו בבירור מדוע לדעתכם החלטת האכיפה שגויה או בלתי מוצדקת\n- צרפו כל ראיה או הקשר רלוונטיים\n\nחבר בצוות הבטיחות של {product_name} יבדוק את הערעור שלכם ועשוי להשהות את המחיקה הממתינה עד לקבלת החלטה סופית.\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "חשבון ה-{product_name} שלכם הושעה זמנית",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם הושעה זמנית עקב הפרה של תנאי השירות או הנחיות הקהילה שלנו.\n\nמשך ההשעיה: {durationHours, plural,\n =1 {שעה אחת}\n other {# שעות}\n}\nמושעה עד: {bannedUntil, date, full} בשעה {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}\n\n}\n}במהלך תקופה זו לא תהיה לכם גישה לחשבון.\n\n{legalLinks, select,\n both {אנא עיינו במסמכים הבאים:\n- תנאי השירות: {termsUrl}\n- הנחיות הקהילה: {guidelinesUrl}\n\n}\n terms {אנא עיינו במסמכים הבאים:\n- תנאי השירות: {termsUrl}\n\n}\n guidelines {אנא עיינו במסמכים הבאים:\n- הנחיות הקהילה: {guidelinesUrl}\n\n}\n other {}\n}אם אתם חושבים שהחלטת האכיפה שגויה או בלתי מוצדקת, תוכלו להגיש ערעור. {appeals_email, select, null {צרו קשר עם מנהלי המערכת של המופע הזה והסבירו בבירור מדוע לדעתכם ההחלטה שגויה.} other {שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו והסבירו בבירור מדוע לדעתכם ההחלטה שגויה.}} נבדוק את הערעור ונעדכן אתכם בהחלטה שלנו.\n\nצוות הבטיחות של {product_name}"
|
||||
"body": "שלום {username},\n\nכפי שביקשתם, תזמנו את מחיקת חשבון ה-{product_name} שלכם ל-{deletion_date}.\n\nכדי לשמור על החשבון, התחברו לפני {deletion_date}. {safety_email, select, null {אם לא ביקשתם את זה, צרו קשר עם מנהלי המערכת של המופע הזה.} other {אם לא ביקשתם את זה, צרו קשר עם {safety_email}.}}\n\nצוות {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "תודה על תרומתכם ל-{product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "קיבלנו החלטה בנוגע לדיווח שלכם ב-{product_name}",
|
||||
"body": "שלום,\n\nצוות הבטיחות שלנו בדק את הדיווח שלכם לפי חוק השירותים הדיגיטליים (מזהה: {reportId}) וקיבל החלטה.{hasComment, select, yes {\n\nתגובה מצוות הבטיחות:\n{publicComment}} other {}}\n\nאיננו משתפים פרטים על פעולות שננקטו נגד חשבון של אדם אחר, מכיוון שאלה נתונים אישיים שלו.\n\nאם אינכם מסכימים עם ההחלטה, תוכלו להגיש ערעור ללא תשלום בתוך 60 יום. {appeals_email, select, null {צרו קשר עם מנהלי המערכת של המופע הזה, ציינו את מזהה הדיווח והסבירו מדוע לדעתכם ההחלטה שגויה.} other {שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו, ציינו את מזהה הדיווח והסבירו מדוע לדעתכם ההחלטה שגויה.}} אם אתם נמצאים באיחוד האירופי, תוכלו גם להפנות את המחלוקת לגוף מוסמך ליישוב סכסוכים מחוץ לבית המשפט. אין בכך כדי לפגוע בזכותכם לפנות לבית המשפט.\n\nצוות הבטיחות של {product_name}"
|
||||
"body": "שלום,\n\nבדקנו את הדיווח שלכם (מזהה: {reportId}).\n\n{outcome, select, removed {הסרנו את התוכן שדיווחתם עליו כי הוא הפר את הכללים שלנו.} restricted {מצאנו שהחשבון שדיווחתם עליו הפר את הכללים שלנו, והגבלנו אותו. אנחנו לא משתפים איזו פעולה נקטנו נגד חשבון אחר, כי זה מידע אישי של בעליו.} action_taken {מצאנו שמה שדיווחתם עליו הפר את הכללים שלנו, ונקטנו פעולה. אנחנו לא משתפים איזו פעולה נקטנו נגד חשבון אחר, כי זה מידע אישי של בעליו.} no_violation {מצאנו שהתוכן או החשבון שדיווחתם עליו לא מפר את הכללים שלנו, ולכן השארנו אותו כמו שהוא.} other {סגרנו את הדיווח שלכם.}}{hasComment, select, yes {\n\nהערה מהצוות שלנו: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nאם אתם לא מסכימים, שלחו אימייל אל {appeals_email} תוך 60 יום וציינו את מזהה הדיווח. אם אתם באיחוד האירופי, אפשר גם להפנות את העניין לגוף מוסמך ליישוב סכסוכים מחוץ לבית המשפט או להגיש תלונה לרכז השירותים הדיגיטליים במדינה שלכם.}}\n\nצוות {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "אימות האימייל שלכם לדיווח DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "אימות האימייל שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nאנא אמתו את כתובת האימייל של חשבון ה-{product_name} שלכם באמצעות לחיצה על הקישור למטה:\n\n{verifyUrl}\n\nאם לא יצרתם חשבון {product_name}, אתם יכולים להתעלם בבטחה מאימייל זה.\n\nקישור זה תקף למשך 24 שעות.\n\nצוות {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {ביטלנו החלטה לגבי חשבון ה-{product_name} שלכם} other {הגבלה על חשבון ה-{product_name} שלכם הסתיימה}}",
|
||||
"body": "שלום {username},\n\n{outcome, select, reversed {{decision_id, select, null {בדקנו שוב החלטה לגבי החשבון שלכם וביטלנו אותה.} other {בדקנו שוב את החלטה {decision_id} וביטלנו אותה.}} אנחנו מתנצלים על אי הנוחות.} other {{decision_id, select, null {הסרנו את ההגבלה מהחשבון שלכם.} other {הסרנו את ההגבלה של החלטה {decision_id}.}}}}{restores_access, select, yes { אפשר שוב להתחבר ולהשתמש ב-{product_name}.} other {}}{statement, select, null {} other {\n\nהערה מהצוות שלנו: {statement}}}{content_removed, select, yes {\n\nאי אפשר לשחזר תוכן שהסרנו.} other {}}\n\nצוות {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {עדכון לגבי חשבון ה-{product_name} שלכם} other {{kind_label, select, warning {אזהרה לגבי חשבון ה-{product_name} שלכם} suspension {חשבון ה-{product_name} שלכם הושעה} ban {חשבון ה-{product_name} שלכם נחסם} closure {חשבון ה-{product_name} שלכם נסגר} feature_limit {חלק מהתכונות בחשבון ה-{product_name} שלכם הוגבלו} content_removal {הסרנו הודעות ששלחתם ב-{product_name}} profile_reset {איפסנו חלקים מפרופיל ה-{product_name} שלכם} community_removal {הסרנו את הקהילה שלכם ב-{product_name}} bot {עדכון לגבי הבוט שלכם ב-{product_name}} other {עדכון לגבי חשבון ה-{product_name} שלכם}}}}",
|
||||
"body": "שלום {username},\n\n{summary}\n\n{effects}\n\nלמה\nכלל: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nהערה מהצוות שלנו: {statement}}}\n\nאיך החלטנו\n{how_we_decided}{appeals_email, select, null {} other {\n\nאיך לערער\nאם לדעתכם טעינו, שלחו אימייל אל {appeals_email} עד {appeal_deadline}. אם אפשר, כתבו מכתובת האימייל של החשבון שלכם, וציינו את מזהה ההחלטה. הערעור ללא תשלום, ואדם בודק כל ערעור.{redress, select, null {} other {\n\n{redress}}}}}\n\nמזהה החלטה: {decision_id}\n\nצוות {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "ההטבות מהמתנה שמימשתם הוסרו",
|
||||
"body": "שלום {username},\n\nקוד מתנה שמימשתם שולם במקור על ידי מישהו אחר. התשלום הזה בוטל מאז (החזר חיוב).\n\nבשל כך, הסרנו את ההטבות שנוספו לחשבונכם כאשר מימשתם את המתנה.\n\n{support_email, select, null {אם אתם חושבים שזו טעות, אנא צרו קשר עם מנהלי המערכת של המופע הזה וצרפו כל פרט שידוע לכם לגבי קוד המתנה ומתי מימשתם אותו.} other {אם אתם חושבים שזו טעות, אנא צרו קשר עם צוות התמיכה שלנו וצרפו כל פרט שידוע לכם לגבי קוד המתנה ומתי מימשתם אותו.}}\n\nצוות {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "הדיווח שלכם ב-{product_name} נבדק",
|
||||
"body": "שלום {username},\n\nהדיווח שלכם (מזהה: {reportId}) נבדק על ידי צוות הבטיחות שלנו.{hasComment, select, yes {\n\nתגובה מצוות הבטיחות:\n{publicComment}} other {}}\n\nתודה שעזרתם לשמור על {product_name} כמקום בטוח לכולם. אנו מתייחסים לכל הדיווחים ברצינות ומעריכים את תרומתכם לקהילה.\n\n{safety_email, select, null {אם יש לכם שאלות או חששות לגבי תוצאה זו, אנא צרו קשר עם מנהלי המערכת של המופע הזה.} other {אם יש לכם שאלות או חששות לגבי תוצאה זו, אנא צרו קשר עם {safety_email}.}}\n\nצוות הבטיחות של {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "חשבון ה-{product_name} שלכם יימחק לצמיתות",
|
||||
"body": "שלום {username},\n\nחשבון ה-{product_name} שלכם נקבע למחיקה לצמיתות.\n\nמחיקה מתוזמנת: {deletionDate, date, full} בשעה {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {סיבה: {reason}\n\n}\n}זוהי פעולת אכיפה חמורה. נתוני החשבון שלכם יימחקו לצמיתות בתאריך המתוכנן.\n\nאם אתם חושבים שהחלטת אכיפה זו שגויה, אתם יכולים להגיש ערעור. {appeals_email, select, null {צרו קשר עם מנהלי המערכת של המופע הזה.} other {שלחו אימייל לכתובת {appeals_email} מכתובת האימייל הזו.}}\n\nצוות הבטיחות של {product_name}"
|
||||
"body": "שלום {username},\n\nבדקנו את הדיווח שלכם (מזהה: {reportId}).\n\n{outcome, select, removed {הסרנו את התוכן שדיווחתם עליו כי הוא הפר את הכללים שלנו.} restricted {מצאנו שהחשבון שדיווחתם עליו הפר את הכללים שלנו, והגבלנו אותו. אנחנו לא משתפים איזו פעולה נקטנו נגד חשבון אחר, כי זה מידע אישי של בעליו.} action_taken {מצאנו שמה שדיווחתם עליו הפר את הכללים שלנו, ונקטנו פעולה. אנחנו לא משתפים איזו פעולה נקטנו נגד חשבון אחר, כי זה מידע אישי של בעליו.} no_violation {מצאנו שהתוכן או החשבון שדיווחתם עליו לא מפר את הכללים שלנו, ולכן השארנו אותו כמו שהוא.} other {סגרנו את הדיווח שלכם.}}{hasComment, select, yes {\n\nהערה מהצוות שלנו: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nאם אתם לא מסכימים, שלחו אימייל אל {appeals_email} תוך 60 יום וציינו את מזהה הדיווח. אם אתם באיחוד האירופי, אפשר גם להפנות את העניין לגוף מוסמך ליישוב סכסוכים מחוץ לבית המשפט או להגיש תלונה לרכז השירותים הדיגיטליים במדינה שלכם.}}\n\nצוות {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "מחיקת חשבון ה-{product_name} שלכם מתוזמנת",
|
||||
"body": "שלום {username},\n\nביקשתם למחוק את חשבון ה-{product_name} שלכם. החשבון שלכם מתוכנן להימחק לצמיתות בתאריך:\n\n{deletionDate, date, full} בשעה {deletionDate, time, short}\n\nאם לא ביקשתם זאת, התחברו לחשבונכם כדי לבטל את המחיקה. אנו ממליצים גם לשנות את הסיסמה שלכם כדי לאבטח את החשבון.\n\nצוות {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "השעיית חשבון ה-{product_name} שלכם בוטלה",
|
||||
"body": "שלום {username},\n\nחדשות טובות: השעיית חשבון ה-{product_name} שלכם בוטלה.\n\n{reason, select,\n null {}\n other {סיבה: {reason}\n\n}\n}כעת אתם יכולים להתחבר שוב ולהמשיך להשתמש ב-{product_name} כרגיל.\n\nצוות הבטיחות של {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "आपका {product_name} अकाउंट इनएक्टिविटी के कारण डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपका {product_name} अकाउंट लंबे समय से इनएक्टिव है, इसलिए इसे हमेशा के लिए डिलीट करने के लिए शेड्यूल कर दिया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर{reason, select, null {} other {\n\nवजह: {reason}}}\n\n{safety_email, select, null {अगर आप अपना अकाउंट रखना चाहते हैं, तो उस तारीख से पहले इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {अगर आप अपना अकाउंट रखना चाहते हैं, तो उस तारीख से पहले इसी ईमेल पते से {safety_email} पर संपर्क करें।}}\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते {username},\n\nआपका {product_name} अकाउंट लंबे समय से इनएक्टिव है, इसलिए हमने इसे {deletion_date} को डिलीट करने के लिए शेड्यूल कर दिया है।\n\nअपना अकाउंट रखने के लिए, उस तारीख से पहले साइन इन करें। {support_email, select, null {अगर आपके कोई सवाल हैं, तो इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {अगर आपके कोई सवाल हैं, तो {support_email} से संपर्क करें।}}\n\n{product_name} टीम"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "आपके {product_name} अकाउंट का डिलीशन शेड्यूल कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपके अनुरोध के अनुसार, आपके {product_name} अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल कर दिया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर{reason, select, null {} other {\n\nवजह: {reason}}}\n\nतब तक आपका अकाउंट लॉक रहेगा। {safety_email, select, null {अगर आपने इसका अनुरोध नहीं किया था, या आप अपना अकाउंट रखना चाहते हैं, तो उस तारीख से पहले इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {अगर आपने इसका अनुरोध नहीं किया था, या आप अपना अकाउंट रखना चाहते हैं, तो उस तारीख से पहले इसी ईमेल पते से {safety_email} पर संपर्क करें।}}\n\n– {product_name} टीम"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "आपका {product_name} अकाउंट हमेशा के लिए डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमारी सेवा की शर्तों या कम्युनिटी गाइडलाइंस के उल्लंघन के कारण हमेशा के लिए डिलीट करने के लिए शेड्यूल किया गया है।\n\nडिलीशन की निर्धारित तारीख: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {वजह: {reason}\n\n}\n}यह एक गंभीर कार्रवाई है। आपके अकाउंट का डेटा निर्धारित तारीख पर हमेशा के लिए डिलीट कर दिया जाएगा।\n\n{legalLinks, select,\n both {कृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\n}\n terms {कृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n\n}\n guidelines {कृपया देखें:\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\n}\n other {}\n}अपील प्रक्रिया:\nअगर आपको लगता है कि यह फैसला गलत या अनुचित था, तो आपके पास अपील सबमिट करने के लिए 60 दिन हैं। {appeals_email, select, null {इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {इस ईमेल पते से {appeals_email} पर ईमेल करें।}}\n\nअपनी अपील में:\n- साफ़ तौर पर बताएं कि आपको क्यों लगता है कि यह फैसला गलत या अनुचित था\n- कोई भी प्रासंगिक सबूत या जानकारी दें\n\n{product_name} सेफ्टी टीम का एक सदस्य आपकी अपील का रिव्यू करेगा और अंतिम फैसला आने तक डिलीट होने की प्रक्रिया को रोक सकता है।\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "आपका {product_name} अकाउंट अस्थायी रूप से सस्पेंड कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमारी सेवा की शर्तों या कम्युनिटी गाइडलाइंस का उल्लंघन करने के लिए अस्थायी रूप से सस्पेंड कर दिया गया है।\n\nअवधि: {durationHours, plural,\n =1 {1 घंटा}\n other {# घंटे}\n}\nसस्पेंड रहेगा: {bannedUntil, date, full} {bannedUntil, time, short} तक\n\n{reason, select,\n null {}\n other {वजह: {reason}\n\n}\n}इस दौरान, आप अपने अकाउंट को एक्सेस नहीं कर पाएँगे।\n\n{legalLinks, select,\n both {कृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\n}\n terms {कृपया देखें:\n- सेवा की शर्तें: {termsUrl}\n\n}\n guidelines {कृपया देखें:\n- कम्युनिटी गाइडलाइंस: {guidelinesUrl}\n\n}\n other {}\n}अगर आपको लगता है कि यह फैसला गलत या अनुचित था, तो आप अपील सबमिट कर सकते हैं। {appeals_email, select, null {इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें और साफ़ तौर पर बताएं कि आपको क्यों लगता है कि फैसला गलत था।} other {इस ईमेल पते से {appeals_email} पर ईमेल करें और साफ़ तौर पर बताएं कि आपको क्यों लगता है कि फैसला गलत था।}} हम आपकी अपील का रिव्यू करेंगे और अपने फैसले के साथ जवाब देंगे।\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते {username},\n\nआपके कहने पर, हमने आपके {product_name} अकाउंट को {deletion_date} को डिलीट करने के लिए शेड्यूल कर दिया है।\n\nअपना अकाउंट रखने के लिए, {deletion_date} से पहले साइन इन करें। {safety_email, select, null {अगर आपने यह नहीं कहा था, तो इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {अगर आपने यह नहीं कहा था, तो {safety_email} से संपर्क करें।}}\n\n{product_name} टीम"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "आपके {product_name} डोनेशन के लिए धन्यवाद",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "हमने आपकी {product_name} रिपोर्ट पर फैसला ले लिया है",
|
||||
"body": "नमस्ते,\n\nहमारी सेफ्टी टीम ने आपकी Digital Services Act रिपोर्ट (ID: {reportId}) का रिव्यू करके फैसला ले लिया है।{hasComment, select, yes {\n\nसेफ्टी टीम का जवाब:\n{publicComment}} other {}}\n\nहम किसी दूसरे व्यक्ति के अकाउंट पर की गई कार्रवाई की जानकारी शेयर नहीं करते, क्योंकि वह उनका निजी डेटा है।\n\nअगर आप इस फैसले से सहमत नहीं हैं, तो आप 60 दिनों के अंदर मुफ़्त में अपील कर सकते हैं। {appeals_email, select, null {इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें, अपनी रिपोर्ट ID शामिल करें और बताएं कि आपको फैसला गलत क्यों लगता है।} other {इस ईमेल पते से {appeals_email} पर ईमेल करें, अपनी रिपोर्ट ID शामिल करें और बताएं कि आपको फैसला गलत क्यों लगता है।}} अगर आप EU में हैं, तो आप इस विवाद को अदालत के बाहर विवाद निपटाने वाली किसी प्रमाणित संस्था के पास भी ले जा सकते हैं। इनमें से किसी भी बात का आपके अदालत जाने के अधिकार पर कोई असर नहीं पड़ता।\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते,\n\nहमने आपकी रिपोर्ट (ID: {reportId}) रिव्यू कर ली है।\n\n{outcome, select, removed {आपने जिस कंटेंट की रिपोर्ट की थी, उसे हमने हटा दिया है क्योंकि उसने हमारे नियम तोड़े थे।} restricted {हमने पाया कि आपने जिस अकाउंट की रिपोर्ट की थी, उसने हमारे नियम तोड़े, और हमने उस पर पाबंदी लगा दी है। हम यह नहीं बताते कि किसी दूसरे अकाउंट पर हमने क्या कार्रवाई की, क्योंकि यह उनका निजी डेटा है।} action_taken {हमने पाया कि आपने जिसकी रिपोर्ट की थी, उसने हमारे नियम तोड़े, और हमने कार्रवाई की है। हम यह नहीं बताते कि किसी दूसरे अकाउंट पर हमने क्या कार्रवाई की, क्योंकि यह उनका निजी डेटा है।} no_violation {हमने पाया कि आपने जिस कंटेंट या अकाउंट की रिपोर्ट की थी, वह हमारे नियम नहीं तोड़ता, इसलिए हमने उसे वैसे ही रहने दिया है।} other {हमने आपकी रिपोर्ट बंद कर दी है।}}{hasComment, select, yes {\n\nहमारी टीम का नोट: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nअगर आप इससे सहमत नहीं हैं, तो 60 दिनों के अंदर {appeals_email} पर ईमेल करें और रिपोर्ट ID लिखें। अगर आप EU में हैं, तो आप यह मामला अदालत के बाहर विवाद निपटाने वाली किसी प्रमाणित संस्था के पास भी भेज सकते हैं, या अपने देश के डिजिटल सेवा समन्वयक से शिकायत कर सकते हैं।}}\n\n{product_name} टीम"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "DSA रिपोर्ट के लिए अपना ईमेल वेरिफ़ाई करें",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "अपना {product_name} ईमेल पता वेरिफ़ाई करें",
|
||||
"body": "नमस्ते {username},\n\nकृपया नीचे दिए गए लिंक पर क्लिक करके अपने {product_name} अकाउंट के लिए ईमेल पता वेरिफ़ाई करें:\n\n{verifyUrl}\n\nअगर आपने {product_name} अकाउंट नहीं बनाया है, तो आप इस ईमेल को बेझिझक अनदेखा कर सकते हैं।\n\nयह लिंक 24 घंटे तक मान्य है।\n\n– {product_name} टीम"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {हमने आपके {product_name} अकाउंट से जुड़ा एक फैसला वापस ले लिया है} other {आपके {product_name} अकाउंट पर लगी एक पाबंदी खत्म हो गई है}}",
|
||||
"body": "नमस्ते {username},\n\n{outcome, select, reversed {{decision_id, select, null {हमने आपके अकाउंट से जुड़े एक फैसले की दोबारा जाँच की और उसे वापस ले लिया।} other {हमने फैसले {decision_id} की दोबारा जाँच की और उसे वापस ले लिया।}} हुई परेशानी के लिए हमें खेद है।} other {{decision_id, select, null {हमने आपके अकाउंट से पाबंदी हटा दी है।} other {हमने फैसले {decision_id} से लगी पाबंदी हटा दी है।}}}}{restores_access, select, yes { आप फिर से साइन इन करके {product_name} इस्तेमाल कर सकते हैं।} other {}}{statement, select, null {} other {\n\nहमारी टीम का नोट: {statement}}}{content_removed, select, yes {\n\nहमने जो कंटेंट हटाया था, उसे वापस नहीं लाया जा सकता।} other {}}\n\n{product_name} टीम"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {आपके {product_name} अकाउंट के बारे में एक अपडेट} other {{kind_label, select, warning {आपके {product_name} अकाउंट के लिए एक चेतावनी} suspension {आपका {product_name} अकाउंट सस्पेंड कर दिया गया है} ban {आपका {product_name} अकाउंट बैन कर दिया गया है} closure {आपका {product_name} अकाउंट बंद कर दिया गया है} feature_limit {आपके {product_name} अकाउंट पर कुछ फ़ीचर सीमित कर दिए गए हैं} content_removal {हमने {product_name} पर आपके भेजे मैसेज हटा दिए हैं} profile_reset {हमने आपकी {product_name} प्रोफ़ाइल के कुछ हिस्से रीसेट कर दिए हैं} community_removal {हमने {product_name} पर आपकी कम्युनिटी हटा दी है} bot {{product_name} पर आपके बॉट के बारे में एक अपडेट} other {आपके {product_name} अकाउंट के बारे में एक अपडेट}}}}",
|
||||
"body": "नमस्ते {username},\n\n{summary}\n\n{effects}\n\nवजह\nनियम: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nहमारी टीम का नोट: {statement}}}\n\nहमने फैसला कैसे लिया\n{how_we_decided}{appeals_email, select, null {} other {\n\nअपील कैसे करें\nअगर आपको लगता है कि हमसे गलती हुई है, तो {appeal_deadline} तक {appeals_email} पर ईमेल करें। हो सके तो अपने अकाउंट वाले ईमेल एड्रेस से लिखें, और फैसला ID लिखें। अपील मुफ़्त है, और हर अपील को एक इंसान रिव्यू करता है।{redress, select, null {} other {\n\n{redress}}}}}\n\nफैसला ID: {decision_id}\n\n{product_name} टीम"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "आपके रिडीम किए गए गिफ्ट से मिलने वाले पर्क हटा दिए गए हैं",
|
||||
"body": "नमस्ते {username},\n\nआपने जो गिफ्ट कोड रिडीम किया था, उसका पेमेंट मूल रूप से किसी और ने किया था। उस पेमेंट को बाद में वापस ले लिया गया (चार्जबैक)।\n\nइस वजह से, हमने वे पर्क हटा दिए हैं जो गिफ्ट रिडीम करते समय आपके अकाउंट में जोड़े गए थे।\n\n{support_email, select, null {अगर आपको लगता है कि यह गलती है, तो कृपया इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें और गिफ्ट कोड और उसे रिडीम करने के समय के बारे में आपके पास जो भी जानकारी है, वह शेयर करें।} other {अगर आपको लगता है कि यह गलती है, तो कृपया हमारी सपोर्ट टीम से संपर्क करें और गिफ्ट कोड और उसे रिडीम करने के समय के बारे में आपके पास जो भी जानकारी है, वह शेयर करें।}}\n\n– {product_name} टीम"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "आपकी {product_name} रिपोर्ट का रिव्यू कर लिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपकी रिपोर्ट (ID: {reportId}) का हमारी सेफ्टी टीम ने रिव्यू कर लिया है।{hasComment, select, yes {\n\nसेफ्टी टीम का जवाब:\n{publicComment}} other {}}\n\n{product_name} को सभी के लिए सुरक्षित रखने में मदद करने के लिए धन्यवाद। हम हर रिपोर्ट को गंभीरता से लेते हैं और कम्युनिटी में आपके योगदान की सराहना करते हैं।\n\n{safety_email, select, null {अगर आपको इस नतीजे के बारे में कोई सवाल या चिंता है, तो कृपया इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {अगर आपको इस नतीजे के बारे में कोई सवाल या चिंता है, तो कृपया {safety_email} पर संपर्क करें।}}\n\n– {product_name} सेफ्टी टीम"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "आपका {product_name} अकाउंट हमेशा के लिए डिलीट कर दिया जाएगा",
|
||||
"body": "नमस्ते {username},\n\nआपके {product_name} अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल किया गया है।\n\nडिलीशन की निर्धारित तारीख: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {वजह: {reason}\n\n}\n}यह एक गंभीर कार्रवाई है। आपके अकाउंट का डेटा निर्धारित तारीख पर हमेशा के लिए डिलीट कर दिया जाएगा।\n\nअगर आपको लगता है कि यह फैसला गलत था, तो आप अपील सबमिट कर सकते हैं। {appeals_email, select, null {इस इंस्टेंस के एडमिनिस्ट्रेटर से संपर्क करें।} other {इस ईमेल पते से {appeals_email} पर ईमेल करें।}}\n\n– {product_name} सेफ्टी टीम"
|
||||
"body": "नमस्ते {username},\n\nहमने आपकी रिपोर्ट (ID: {reportId}) रिव्यू कर ली है।\n\n{outcome, select, removed {आपने जिस कंटेंट की रिपोर्ट की थी, उसे हमने हटा दिया है क्योंकि उसने हमारे नियम तोड़े थे।} restricted {हमने पाया कि आपने जिस अकाउंट की रिपोर्ट की थी, उसने हमारे नियम तोड़े, और हमने उस पर पाबंदी लगा दी है। हम यह नहीं बताते कि किसी दूसरे अकाउंट पर हमने क्या कार्रवाई की, क्योंकि यह उनका निजी डेटा है।} action_taken {हमने पाया कि आपने जिसकी रिपोर्ट की थी, उसने हमारे नियम तोड़े, और हमने कार्रवाई की है। हम यह नहीं बताते कि किसी दूसरे अकाउंट पर हमने क्या कार्रवाई की, क्योंकि यह उनका निजी डेटा है।} no_violation {हमने पाया कि आपने जिस कंटेंट या अकाउंट की रिपोर्ट की थी, वह हमारे नियम नहीं तोड़ता, इसलिए हमने उसे वैसे ही रहने दिया है।} other {हमने आपकी रिपोर्ट बंद कर दी है।}}{hasComment, select, yes {\n\nहमारी टीम का नोट: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nअगर आप इससे सहमत नहीं हैं, तो 60 दिनों के अंदर {appeals_email} पर ईमेल करें और रिपोर्ट ID लिखें। अगर आप EU में हैं, तो आप यह मामला अदालत के बाहर विवाद निपटाने वाली किसी प्रमाणित संस्था के पास भी भेज सकते हैं, या अपने देश के डिजिटल सेवा समन्वयक से शिकायत कर सकते हैं।}}\n\n{product_name} टीम"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "आपके {product_name} अकाउंट का डिलीशन शेड्यूल कर दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nआपने अपना {product_name} अकाउंट डिलीट करने का अनुरोध किया है। आपके अकाउंट को हमेशा के लिए डिलीट करने के लिए शेड्यूल कर दिया गया है:\n\n{deletionDate, date, full} को {deletionDate, time, short} पर\n\nअगर आपने इसका अनुरोध नहीं किया था, तो डिलीशन रद्द करने के लिए अपने अकाउंट में साइन इन करें। हम आपके अकाउंट को सुरक्षित करने के लिए अपना पासवर्ड बदलने की भी सलाह देते हैं।\n\n– {product_name} टीम"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "आपके {product_name} अकाउंट का सस्पेंशन हटा दिया गया है",
|
||||
"body": "नमस्ते {username},\n\nअच्छी खबर: आपके {product_name} अकाउंट का सस्पेंशन हटा दिया गया है।\n\n{reason, select,\n null {}\n other {वजह: {reason}\n\n}\n}अब आप फिर से लॉगिन कर सकते हैं और सामान्य रूप से {product_name} का उपयोग जारी रख सकते हैं।\n\n– {product_name} सेफ्टी टीम"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tvoj račun na {product_name}u bit će izbrisan zbog neaktivnosti",
|
||||
"body": "Pozdrav {username},\n\nTvoj račun na {product_name}u dugo je bio neaktivan, pa je zakazan za trajno brisanje:\n\n{deletionDate, date, full} u {deletionDate, time, short}{reason, select, null {} other {\n\nRazlog: {reason}}}\n\n{safety_email, select, null {Ako želiš zadržati račun, obrati se administratorima ove instance prije tog datuma.} other {Ako želiš zadržati račun, kontaktiraj {safety_email} s ove adrese e-pošte prije tog datuma.}}\n\n– Tim {product_name}a"
|
||||
"body": "Pozdrav {username},\n\nTvoj račun na platformi {product_name} dugo je bio neaktivan, pa smo zakazali njegovo brisanje za {deletion_date}.\n\nAko želiš zadržati račun, prijavi se prije tog datuma. {support_email, select, null {Ako imaš pitanja, obrati se administratorima ove instance.} other {Ako imaš pitanja, kontaktiraj {support_email}.}}\n\nTim platforme {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Brisanje tvog računa na {product_name}u je zakazano",
|
||||
"body": "Pozdrav {username},\n\nNa tvoj zahtjev, tvoj račun na {product_name}u zakazan je za trajno brisanje:\n\n{deletionDate, date, full} u {deletionDate, time, short}{reason, select, null {} other {\n\nRazlog: {reason}}}\n\nDo tada je tvoj račun zaključan. {safety_email, select, null {Ako ovaj zahtjev ne dolazi od tebe ili želiš zadržati račun, obrati se administratorima ove instance prije tog datuma.} other {Ako ovaj zahtjev ne dolazi od tebe ili želiš zadržati račun, kontaktiraj {safety_email} s ove adrese e-pošte prije tog datuma.}}\n\n– Tim {product_name}a"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tvoj račun na {product_name}u bit će trajno izbrisan",
|
||||
"body": "Pozdrav {username},\n\nTvoj račun na {product_name}u zakazan je za trajno brisanje zbog kršenja naših Uvjeta pružanja usluge ili Smjernica zajednice.\n\nZakazano brisanje: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Razlog: {reason}\n\n}\n}Ovo je ozbiljna mjera. Podaci tvog računa bit će trajno izbrisani na zakazani datum.\n\n{legalLinks, select,\n both {Pregledaj:\n- Uvjeti pružanja usluge: {termsUrl}\n- Smjernice zajednice: {guidelinesUrl}\n\n}\n terms {Pregledaj:\n- Uvjeti pružanja usluge: {termsUrl}\n\n}\n guidelines {Pregledaj:\n- Smjernice zajednice: {guidelinesUrl}\n\n}\n other {}\n}Postupak žalbe:\nAko smatraš da je ova odluka bila netočna ili neopravdana, imaš 60 dana za podnošenje žalbe. {appeals_email, select, null {Obrati se administratorima ove instance.} other {Pošalji e-poštu na {appeals_email} s ove adrese e-pošte.}}\n\nU svojoj žalbi:\n- Jasno objasni zašto smatraš da je odluka bila netočna ili neopravdana\n- Navedi sve relevantne dokaze ili kontekst\n\nČlan Sigurnosnog tima {product_name}a pregledat će tvoju žalbu i može privremeno zaustaviti zakazano brisanje dok se ne donese konačna odluka.\n\n– Sigurnosni tim {product_name}a"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tvoj račun na {product_name}u privremeno je suspendiran",
|
||||
"body": "Pozdrav {username},\n\nTvoj račun na {product_name}u privremeno je suspendiran zbog kršenja naših Uvjeta pružanja usluge ili Smjernica zajednice.\n\nTrajanje: {durationHours, plural,\n =1 {1 sat}\n one {# sat}\n few {# sata}\n other {# sati}\n}\nSuspendiran do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Razlog: {reason}\n\n}\n}Tijekom tog vremena nećeš moći pristupiti svom računu.\n\n{legalLinks, select,\n both {Pregledaj:\n- Uvjeti pružanja usluge: {termsUrl}\n- Smjernice zajednice: {guidelinesUrl}\n\n}\n terms {Pregledaj:\n- Uvjeti pružanja usluge: {termsUrl}\n\n}\n guidelines {Pregledaj:\n- Smjernice zajednice: {guidelinesUrl}\n\n}\n other {}\n}Ako smatraš da je ova odluka bila netočna ili neopravdana, možeš podnijeti žalbu. {appeals_email, select, null {Obrati se administratorima ove instance i jasno objasni zašto smatraš da je odluka bila netočna.} other {Pošalji e-poštu na {appeals_email} s ove adrese e-pošte i jasno objasni zašto smatraš da je odluka bila netočna.}} Pregledat ćemo tvoju žalbu i javiti ti našu odluku.\n\n– Sigurnosni tim {product_name}a"
|
||||
"body": "Pozdrav {username},\n\nNa tvoj zahtjev zakazali smo brisanje tvog računa na platformi {product_name} za {deletion_date}.\n\nAko želiš zadržati račun, prijavi se prije {deletion_date}. {safety_email, select, null {Ako zahtjev nije došao od tebe, obrati se administratorima ove instance.} other {Ako zahtjev nije došao od tebe, kontaktiraj {safety_email}.}}\n\nTim platforme {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Hvala ti na donaciji za {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Donijeli smo odluku o tvojoj prijavi na platformi {product_name}",
|
||||
"body": "Pozdrav,\n\nNaš sigurnosni tim pregledao je tvoju prijavu prema Zakonu o digitalnim uslugama (ID: {reportId}) i donio odluku.{hasComment, select, yes {\n\nOdgovor sigurnosnog tima:\n{publicComment}} other {}}\n\nNe dijelimo pojedinosti o mjerama poduzetima protiv računa druge osobe jer su to njezini osobni podaci.\n\nAko se ne slažeš s ovom odlukom, možeš besplatno podnijeti žalbu u roku od 60 dana. {appeals_email, select, null {Obrati se administratorima ove instance, navedi ID svoje prijave i objasni zašto smatraš da je odluka pogrešna.} other {Pošalji e-poštu na {appeals_email} s ove adrese e-pošte, navedi ID svoje prijave i objasni zašto smatraš da je odluka pogrešna.}} Ako se nalaziš u EU-u, spor možeš uputiti i certificiranom tijelu za izvansudsko rješavanje sporova. Ništa od navedenog ne utječe na tvoje pravo da se obratiš sudu.\n\n– Sigurnosni tim {product_name}a"
|
||||
"body": "Pozdrav,\n\nPregledali smo tvoju prijavu (ID: {reportId}).\n\n{outcome, select, removed {Uklonili smo prijavljeni sadržaj jer je kršio naša pravila.} restricted {Utvrdili smo da je prijavljeni račun prekršio naša pravila i ograničili smo ga. Ne dijelimo koje smo mjere poduzeli protiv drugog računa jer su to osobni podaci njegova vlasnika.} action_taken {Utvrdili smo da je prijavljeni sadržaj ili račun prekršio naša pravila i poduzeli smo mjere. Ne dijelimo koje smo mjere poduzeli protiv drugog računa jer su to osobni podaci njegova vlasnika.} no_violation {Utvrdili smo da prijavljeni sadržaj ili račun ne krši naša pravila pa ništa nismo mijenjali.} other {Zatvorili smo tvoju prijavu.}}{hasComment, select, yes {\n\nNapomena našeg tima: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nAko se ne slažeš, pošalji e-poruku na {appeals_email} u roku od 60 dana i navedi ID prijave. Ako si u EU-u, slučaj možeš uputiti i certificiranom tijelu za izvansudsko rješavanje sporova ili podnijeti pritužbu koordinatoru za digitalne usluge u svojoj zemlji.}}\n\nTim platforme {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Potvrdi svoju e-poštu za prijavu prema Zakonu o digitalnim uslugama",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Potvrdi svoju e-poštu za {product_name}",
|
||||
"body": "Pozdrav {username},\n\nPotvrdi adresu e-pošte za svoj račun na {product_name}u klikom na poveznicu ispod:\n\n{verifyUrl}\n\nAko račun na {product_name}u nije tvoj, možeš zanemariti ovu e-poštu.\n\nOva poveznica vrijedi 24 sata.\n\n– Tim {product_name}a"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Poništili smo odluku o tvom računu na platformi {product_name}} other {Ograničenje tvog računa na platformi {product_name} je prestalo}}",
|
||||
"body": "Pozdrav {username},\n\n{outcome, select, reversed {{decision_id, select, null {Ponovno smo razmotrili odluku o tvom računu i poništili je.} other {Ponovno smo razmotrili odluku {decision_id} i poništili je.}} Ispričavamo se zbog neugodnosti.} other {{decision_id, select, null {Ukinuli smo ograničenje tvog računa.} other {Ukinuli smo ograničenje iz odluke {decision_id}.}}}}{restores_access, select, yes { Ponovno se možeš prijaviti i koristiti {product_name}.} other {}}{statement, select, null {} other {\n\nNapomena našeg tima: {statement}}}{content_removed, select, yes {\n\nUklonjeni sadržaj ne može se vratiti.} other {}}\n\nTim platforme {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Obavijest o tvom računu na platformi {product_name}} other {{kind_label, select, warning {Upozorenje u vezi s tvojim računom na platformi {product_name}} suspension {Tvoj račun na platformi {product_name} je suspendiran} ban {Tvoj račun na platformi {product_name} je trajno blokiran} closure {Tvoj račun na platformi {product_name} je zatvoren} feature_limit {Neke funkcije tvog računa na platformi {product_name} su ograničene} content_removal {Uklonili smo tvoje poruke na platformi {product_name}} profile_reset {Vratili smo dijelove tvog profila na platformi {product_name} na zadano} community_removal {Uklonili smo tvoju zajednicu na platformi {product_name}} bot {Obavijest o tvom botu na platformi {product_name}} other {Obavijest o tvom računu na platformi {product_name}}}}}",
|
||||
"body": "Pozdrav {username},\n\n{summary}\n\n{effects}\n\nZašto\nPravilo: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNapomena našeg tima: {statement}}}\n\nKako smo odlučili\n{how_we_decided}{appeals_email, select, null {} other {\n\nKako uložiti žalbu\nAko misliš da smo pogriješili, pošalji e-poruku na {appeals_email} do {appeal_deadline}. Ako možeš, piši s adrese e-pošte povezane s tvojim računom i navedi ID odluke. Žalbe su besplatne i svaku pregledava čovjek.{redress, select, null {} other {\n\n{redress}}}}}\n\nID odluke: {decision_id}\n\nTim platforme {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Pogodnosti tvog iskorištenog poklona su uklonjene",
|
||||
"body": "Pozdrav {username},\n\nPoklon kod iskorišten na tvom računu izvorno je platio netko drugi. To je plaćanje u međuvremenu poništeno (povrat sredstava putem banke).\n\nZbog toga smo uklonili pogodnosti koje su pri iskorištavanju poklona dodane tvom računu.\n\n{support_email, select, null {Ako misliš da je ovo pogreška, obrati se administratorima ove instance i navedi sve podatke koje imaš o poklon kodu i o vremenu njegova iskorištavanja.} other {Ako misliš da je ovo pogreška, kontaktiraj naš tim za podršku i navedi sve podatke koje imaš o poklon kodu i o vremenu njegova iskorištavanja.}}\n\n– Tim {product_name}a"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Tvoja prijava na {product_name}u je pregledana",
|
||||
"body": "Pozdrav {username},\n\nTvoju prijavu (ID: {reportId}) pregledao je naš sigurnosni tim.{hasComment, select, yes {\n\nOdgovor sigurnosnog tima:\n{publicComment}} other {}}\n\nHvala ti što pomažeš da {product_name} bude siguran za sve. Sve prijave shvaćamo ozbiljno i cijenimo tvoj doprinos zajednici.\n\n{safety_email, select, null {Ako imaš bilo kakvih pitanja ili nedoumica u vezi s ovim ishodom, obrati se administratorima ove instance.} other {Ako imaš bilo kakvih pitanja ili nedoumica u vezi s ovim ishodom, kontaktiraj {safety_email}.}}\n\n– Sigurnosni tim {product_name}a"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tvoj račun na {product_name}u bit će trajno izbrisan",
|
||||
"body": "Pozdrav {username},\n\nTvoj račun na {product_name}u zakazan je za trajno brisanje.\n\nZakazano brisanje: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Razlog: {reason}\n\n}\n}Ovo je ozbiljna mjera. Podaci tvog računa bit će trajno izbrisani na zakazani datum.\n\nAko smatraš da je ova odluka bila netočna, možeš podnijeti žalbu. {appeals_email, select, null {Obrati se administratorima ove instance.} other {Pošalji e-poštu na {appeals_email} s ove adrese e-pošte.}}\n\n– Sigurnosni tim {product_name}a"
|
||||
"body": "Pozdrav {username},\n\nPregledali smo tvoju prijavu (ID: {reportId}).\n\n{outcome, select, removed {Uklonili smo prijavljeni sadržaj jer je kršio naša pravila.} restricted {Utvrdili smo da je prijavljeni račun prekršio naša pravila i ograničili smo ga. Ne dijelimo koje smo mjere poduzeli protiv drugog računa jer su to osobni podaci njegova vlasnika.} action_taken {Utvrdili smo da je prijavljeni sadržaj ili račun prekršio naša pravila i poduzeli smo mjere. Ne dijelimo koje smo mjere poduzeli protiv drugog računa jer su to osobni podaci njegova vlasnika.} no_violation {Utvrdili smo da prijavljeni sadržaj ili račun ne krši naša pravila pa ništa nismo mijenjali.} other {Zatvorili smo tvoju prijavu.}}{hasComment, select, yes {\n\nNapomena našeg tima: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nAko se ne slažeš, pošalji e-poruku na {appeals_email} u roku od 60 dana i navedi ID prijave. Ako si u EU-u, slučaj možeš uputiti i certificiranom tijelu za izvansudsko rješavanje sporova ili podnijeti pritužbu koordinatoru za digitalne usluge u svojoj zemlji.}}\n\nTim platforme {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Brisanje tvog računa na {product_name}u je zakazano",
|
||||
"body": "Pozdrav {username},\n\nPrimili smo tvoj zahtjev za brisanje računa na {product_name}u. Tvoj račun zakazan je za trajno brisanje:\n\n{deletionDate, date, full} u {deletionDate, time, short}\n\nAko ovaj zahtjev ne dolazi od tebe, prijavi se na svoj račun i otkaži brisanje. Također preporučujemo da promijeniš lozinku radi zaštite računa.\n\n– Tim {product_name}a"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Suspenzija tvog računa na {product_name}u je ukinuta",
|
||||
"body": "Pozdrav {username},\n\nDobre vijesti: suspenzija tvog računa na {product_name}u je ukinuta.\n\n{reason, select,\n null {}\n other {Razlog: {reason}\n\n}\n}Sada se možeš ponovno prijaviti i nastaviti koristiti {product_name} kao i obično.\n\n– Sigurnosni tim {product_name}a"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}-fiókodat inaktivitás miatt törölni fogjuk",
|
||||
"body": "Szia, {username}!\n\n{product_name}-fiókod hosszú ideje inaktív, ezért végleges törlése a következő időpontra van ütemezve:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nIndok: {reason}}}\n\n{safety_email, select, null {Ha meg szeretnéd tartani a fiókodat, még az időpont előtt fordulj ennek a példánynak az adminisztrátoraihoz.} other {Ha meg szeretnéd tartani a fiókodat, még az időpont előtt írj erről az e-mail-címről erre a címre: {safety_email}.}}\n\n– {product_name} csapata"
|
||||
"body": "Szia, {username}!\n\n{product_name}-fiókod hosszú ideje inaktív, ezért ütemeztük a törlését erre az időpontra: {deletion_date}.\n\nHa meg szeretnéd tartani a fiókodat, jelentkezz be az időpont előtt. {support_email, select, null {Ha kérdésed van, fordulj ennek a példánynak az adminisztrátoraihoz.} other {Ha kérdésed van, írj erre a címre: {support_email}.}}\n\n{product_name} csapata"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Ütemeztük {product_name}-fiókod törlését",
|
||||
"body": "Szia, {username}!\n\nKérésedre {product_name}-fiókod végleges törlése a következő időpontra van ütemezve:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nIndok: {reason}}}\n\nAddig a fiókod zárolva van. {safety_email, select, null {Ha nem te kérted ezt, vagy meg szeretnéd tartani a fiókodat, még az időpont előtt fordulj ennek a példánynak az adminisztrátoraihoz.} other {Ha nem te kérted ezt, vagy meg szeretnéd tartani a fiókodat, még az időpont előtt írj erről az e-mail-címről erre a címre: {safety_email}.}}\n\n– {product_name} csapata"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "{product_name}-fiókodat véglegesen törölni fogjuk",
|
||||
"body": "Szia, {username}!\n\n{product_name}-fiókod végleges törlését ütemeztük a szolgáltatási feltételeink vagy a közösségi irányelveink megsértése miatt.\n\nA törlés tervezett időpontja: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Indok: {reason}\n\n}\n}Ez súlyos moderációs intézkedés. Fiókod adatait a megadott időpontban véglegesen töröljük.\n\n{legalLinks, select,\n both {Kérjük, olvasd át az alábbiakat:\n- Szolgáltatási feltételek: {termsUrl}\n- Közösségi irányelvek: {guidelinesUrl}\n\n}\n terms {Kérjük, olvasd át az alábbiakat:\n- Szolgáltatási feltételek: {termsUrl}\n\n}\n guidelines {Kérjük, olvasd át az alábbiakat:\n- Közösségi irányelvek: {guidelinesUrl}\n\n}\n other {}\n}Fellebbezés:\nHa úgy gondolod, hogy ez a döntés hibás vagy indokolatlan volt, 60 napod van fellebbezni. {appeals_email, select, null {Fordulj ennek a példánynak az adminisztrátoraihoz.} other {Erről az e-mail-címről írj a következő címre: {appeals_email}.}}\n\nA fellebbezésben:\n- Világosan fejtsd ki, miért gondolod, hogy a döntés hibás vagy indokolatlan volt.\n- Add meg a kapcsolódó bizonyítékokat és a körülményeket.\n\nA {product_name} biztonsági csapatának egyik tagja elbírálja a fellebbezésedet, és a végleges döntésig felfüggesztheti a tervezett törlést.\n\n– {product_name} biztonsági csapata"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "{product_name}-fiókodat ideiglenesen felfüggesztettük",
|
||||
"body": "Szia, {username}!\n\n{product_name}-fiókodat ideiglenesen felfüggesztettük a szolgáltatási feltételeink vagy a közösségi irányelveink megsértése miatt.\n\nIdőtartam: {durationHours, plural,\n =1 {1 óra}\n other {# óra}\n}\nA felfüggesztés vége: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Indok: {reason}\n\n}\n}Ez idő alatt nem férsz hozzá a fiókodhoz.\n\n{legalLinks, select,\n both {Kérjük, olvasd át az alábbiakat:\n- Szolgáltatási feltételek: {termsUrl}\n- Közösségi irányelvek: {guidelinesUrl}\n\n}\n terms {Kérjük, olvasd át az alábbiakat:\n- Szolgáltatási feltételek: {termsUrl}\n\n}\n guidelines {Kérjük, olvasd át az alábbiakat:\n- Közösségi irányelvek: {guidelinesUrl}\n\n}\n other {}\n}Ha úgy gondolod, hogy ez a döntés hibás vagy indokolatlan volt, fellebbezhetsz. {appeals_email, select, null {Fordulj ennek a példánynak az adminisztrátoraihoz, és világosan fejtsd ki, miért tartod hibásnak a döntést.} other {Erről az e-mail-címről írj a következő címre: {appeals_email}, és világosan fejtsd ki, miért tartod hibásnak a döntést.}} Elbíráljuk a fellebbezésedet, és értesítünk a döntésünkről.\n\n– {product_name} biztonsági csapata"
|
||||
"body": "Szia, {username}!\n\nKérésedre ütemeztük {product_name}-fiókod törlését erre az időpontra: {deletion_date}.\n\nHa meg szeretnéd tartani a fiókodat, jelentkezz be {deletion_date} előtt. {safety_email, select, null {Ha nem te kérted ezt, fordulj ennek a példánynak az adminisztrátoraihoz.} other {Ha nem te kérted ezt, írj erre a címre: {safety_email}.}}\n\n{product_name} csapata"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Köszönjük, hogy adományoddal támogatod a {product_name} működését",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Döntést hoztunk a {product_name} felületén tett bejelentésedről",
|
||||
"body": "Szia!\n\nBiztonsági csapatunk elbírálta a digitális szolgáltatásokról szóló rendelet szerinti bejelentésedet (azonosító: {reportId}), és döntést hozott.{hasComment, select, yes {\n\nA biztonsági csapat válasza:\n{publicComment}} other {}}\n\nMás személy fiókjával szemben tett intézkedések részleteit nem osztjuk meg, mert azok az ő személyes adatai.\n\nHa nem értesz egyet a döntéssel, 60 napon belül díjmentesen fellebbezhetsz. {appeals_email, select, null {Fordulj ennek a példánynak az adminisztrátoraihoz, add meg a bejelentésed azonosítóját, és fejtsd ki, miért tartod hibásnak a döntést.} other {Erről az e-mail-címről írj a következő címre: {appeals_email}, add meg a bejelentésed azonosítóját, és fejtsd ki, miért tartod hibásnak a döntést.}} Ha az EU-ban tartózkodsz, a vitát tanúsított peren kívüli vitarendezési testület elé is viheted. Mindez nem érinti azt a jogodat, hogy bírósághoz fordulj.\n\n– {product_name} biztonsági csapata"
|
||||
"body": "Szia!\n\nMegvizsgáltuk a bejelentésedet (azonosító: {reportId}).\n\n{outcome, select, removed {Eltávolítottuk a bejelentett tartalmat, mert megsértette a szabályainkat.} restricted {Megállapítottuk, hogy a bejelentett fiók megsértette a szabályainkat, ezért korlátoztuk. Azt nem osztjuk meg, milyen intézkedést hoztunk egy másik fiókkal szemben, mert az az illető személyes adata.} action_taken {Megállapítottuk, hogy amit bejelentettél, megsértette a szabályainkat, ezért intézkedtünk. Azt nem osztjuk meg, milyen intézkedést hoztunk egy másik fiókkal szemben, mert az az illető személyes adata.} no_violation {Megállapítottuk, hogy a bejelentett tartalom vagy fiók nem sérti a szabályainkat, ezért nem változtattunk rajta.} other {Lezártuk a bejelentésedet.}}{hasComment, select, yes {\n\nMegjegyzés a csapatunktól: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nHa nem értesz egyet, 60 napon belül írj a(z) {appeals_email} címre, és add meg a bejelentés azonosítóját. Ha az EU-ban élsz, az ügyet egy tanúsított peren kívüli vitarendezési testület elé is viheted, vagy panaszt tehetsz az országod digitális szolgáltatási koordinátoránál.}}\n\n{product_name} csapata"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Erősítsd meg e-mail-címedet a DSA-bejelentéshez",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Erősítsd meg {product_name}-fiókod e-mail-címét",
|
||||
"body": "Szia, {username}!\n\nErősítsd meg a {product_name}-fiókod e-mail-címét az alábbi linkre kattintva:\n\n{verifyUrl}\n\nHa nem hoztál létre {product_name}-fiókot, nyugodtan figyelmen kívül hagyhatod ezt az e-mailt.\n\nEz a link 24 óráig érvényes.\n\n– {product_name} csapata"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Visszavontunk egy {product_name}-fiókodat érintő döntést} other {Véget ért egy {product_name}-fiókodat érintő korlátozás}}",
|
||||
"body": "Szia, {username}!\n\n{outcome, select, reversed {{decision_id, select, null {Újra megvizsgáltunk egy fiókodat érintő döntést, és visszavontuk.} other {Újra megvizsgáltuk a(z) {decision_id} azonosítójú döntést, és visszavontuk.}} Elnézést kérünk a kellemetlenségért.} other {{decision_id, select, null {Feloldottuk a fiókodat érintő korlátozást.} other {Feloldottuk a(z) {decision_id} azonosítójú döntésen alapuló korlátozást.}}}}{restores_access, select, yes { Újra bejelentkezhetsz, és használhatod a(z) {product_name} szolgáltatást.} other {}}{statement, select, null {} other {\n\nMegjegyzés a csapatunktól: {statement}}}{content_removed, select, yes {\n\nAz általunk eltávolított tartalmat nem lehet visszaállítani.} other {}}\n\n{product_name} csapata"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Tájékoztatás {product_name}-fiókodról} other {{kind_label, select, warning {Figyelmeztetés {product_name}-fiókoddal kapcsolatban} suspension {{product_name}-fiókod fel van függesztve} ban {{product_name}-fiókod véglegesen fel van függesztve} closure {{product_name}-fiókod le van zárva} feature_limit {Néhány funkció korlátozva van {product_name}-fiókodon} content_removal {Eltávolítottunk üzeneteket, amelyeket a(z) {product_name} felületén küldtél} profile_reset {Visszaállítottuk {product_name}-profilod egyes részeit} community_removal {Eltávolítottuk a közösségedet a(z) {product_name} felületéről} bot {Tájékoztatás a botodról a(z) {product_name} felületén} other {Tájékoztatás {product_name}-fiókodról}}}}",
|
||||
"body": "Szia, {username}!\n\n{summary}\n\n{effects}\n\nMiért\nSzabály: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nMegjegyzés a csapatunktól: {statement}}}\n\nHogyan döntöttünk\n{how_we_decided}{appeals_email, select, null {} other {\n\nÍgy fellebbezhetsz\nHa úgy gondolod, hogy tévedtünk, írj a(z) {appeals_email} címre. Határidő: {appeal_deadline}. Ha tudsz, a fiókodhoz tartozó e-mail-címről írj, és add meg a döntésazonosítót. A fellebbezés ingyenes, és mindegyiket egy ember bírálja el.{redress, select, null {} other {\n\n{redress}}}}}\n\nDöntésazonosító: {decision_id}\n\n{product_name} csapata"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Megszűntek a beváltott ajándékodhoz tartozó előnyök",
|
||||
"body": "Szia, {username}!\n\nAz általad beváltott ajándékkódot eredetileg valaki más fizette ki. Ezt a fizetést azóta visszaterhelték (chargeback).\n\nEmiatt megszüntettük azokat az előnyöket, amelyeket az ajándék beváltásakor kapott a fiókod.\n\n{support_email, select, null {Ha úgy gondolod, hogy ez tévedés, fordulj ennek a példánynak az adminisztrátoraihoz, és add meg az ajándékkóddal és a beváltás időpontjával kapcsolatos ismert részleteket.} other {Ha úgy gondolod, hogy ez tévedés, vedd fel a kapcsolatot ügyfélszolgálatunkkal, és add meg az ajándékkóddal és a beváltás időpontjával kapcsolatos ismert részleteket.}}\n\n– {product_name} csapata"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Elbíráltuk a {product_name} felületén tett bejelentésedet",
|
||||
"body": "Szia, {username}!\n\nBiztonsági csapatunk elbírálta a bejelentésedet (azonosító: {reportId}).{hasComment, select, yes {\n\nA biztonsági csapat válasza:\n{publicComment}} other {}}\n\nKöszönjük, hogy segítesz mindenki számára biztonságossá tenni a {product_name} közösségét. Minden bejelentést komolyan veszünk, és nagyra értékeljük, hogy segíted a közösséget.\n\n{safety_email, select, null {Ha kérdésed vagy aggályod van az eredménnyel kapcsolatban, fordulj ennek a példánynak az adminisztrátoraihoz.} other {Ha kérdésed vagy aggályod van az eredménnyel kapcsolatban, írj erre a címre: {safety_email}.}}\n\n– {product_name} biztonsági csapata"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "{product_name}-fiókodat véglegesen törölni fogjuk",
|
||||
"body": "Szia, {username}!\n\nÜtemeztük {product_name}-fiókod végleges törlését.\n\nA törlés tervezett időpontja: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Indok: {reason}\n\n}\n}Ez súlyos moderációs intézkedés. Fiókod adatait a megadott időpontban véglegesen töröljük.\n\nHa úgy gondolod, hogy ez a döntés hibás volt, fellebbezhetsz. {appeals_email, select, null {Fordulj ennek a példánynak az adminisztrátoraihoz.} other {Erről az e-mail-címről írj a következő címre: {appeals_email}.}}\n\n– {product_name} biztonsági csapata"
|
||||
"body": "Szia, {username}!\n\nMegvizsgáltuk a bejelentésedet (azonosító: {reportId}).\n\n{outcome, select, removed {Eltávolítottuk a bejelentett tartalmat, mert megsértette a szabályainkat.} restricted {Megállapítottuk, hogy a bejelentett fiók megsértette a szabályainkat, ezért korlátoztuk. Azt nem osztjuk meg, milyen intézkedést hoztunk egy másik fiókkal szemben, mert az az illető személyes adata.} action_taken {Megállapítottuk, hogy amit bejelentettél, megsértette a szabályainkat, ezért intézkedtünk. Azt nem osztjuk meg, milyen intézkedést hoztunk egy másik fiókkal szemben, mert az az illető személyes adata.} no_violation {Megállapítottuk, hogy a bejelentett tartalom vagy fiók nem sérti a szabályainkat, ezért nem változtattunk rajta.} other {Lezártuk a bejelentésedet.}}{hasComment, select, yes {\n\nMegjegyzés a csapatunktól: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nHa nem értesz egyet, 60 napon belül írj a(z) {appeals_email} címre, és add meg a bejelentés azonosítóját. Ha az EU-ban élsz, az ügyet egy tanúsított peren kívüli vitarendezési testület elé is viheted, vagy panaszt tehetsz az országod digitális szolgáltatási koordinátoránál.}}\n\n{product_name} csapata"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Ütemeztük {product_name}-fiókod törlését",
|
||||
"body": "Szia, {username}!\n\nKérted a {product_name}-fiókod törlését. Fiókod végleges törlése a következő időpontra van ütemezve:\n\n{deletionDate, date, full} {deletionDate, time, short}\n\nHa nem te kérted ezt, jelentkezz be a fiókodba a törlés visszavonásához. Javasoljuk továbbá, hogy változtasd meg a jelszavadat a fiókod biztonságának megőrzése érdekében.\n\n– {product_name} csapata"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Feloldottuk {product_name}-fiókod felfüggesztését",
|
||||
"body": "Szia, {username}!\n\nJó hír: feloldottuk {product_name}-fiókod felfüggesztését.\n\n{reason, select,\n null {}\n other {Indok: {reason}\n\n}\n}Most már újra bejelentkezhetsz, és a megszokott módon használhatod a {product_name} alkalmazást.\n\n– {product_name} biztonsági csapata"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Akun {product_name} kamu akan dihapus karena tidak aktif",
|
||||
"body": "Halo {username},\n\nAkun {product_name} kamu sudah lama tidak aktif, sehingga dijadwalkan untuk dihapus permanen pada:\n\n{deletionDate, date, full} pukul {deletionDate, time, short}{reason, select, null {} other {\n\nAlasan: {reason}}}\n\n{safety_email, select, null {Jika kamu ingin mempertahankan akunmu, hubungi administrator instance ini sebelum tanggal tersebut.} other {Jika kamu ingin mempertahankan akunmu, hubungi {safety_email} dari alamat email ini sebelum tanggal tersebut.}}\n\n– Tim {product_name}"
|
||||
"body": "Halo {username},\n\nAkun {product_name} kamu sudah lama tidak aktif, jadi kami menjadwalkan penghapusannya pada {deletion_date}.\n\nUntuk mempertahankan akunmu, masuklah sebelum tanggal tersebut. {support_email, select, null {Jika kamu punya pertanyaan, hubungi administrator instance ini.} other {Jika kamu punya pertanyaan, hubungi {support_email}.}}\n\nTim {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Penghapusan akun {product_name} kamu telah dijadwalkan",
|
||||
"body": "Halo {username},\n\nSesuai permintaanmu, akun {product_name} kamu dijadwalkan untuk dihapus permanen pada:\n\n{deletionDate, date, full} pukul {deletionDate, time, short}{reason, select, null {} other {\n\nAlasan: {reason}}}\n\nAkunmu dikunci hingga saat itu. {safety_email, select, null {Jika kamu tidak meminta ini, atau ingin mempertahankan akunmu, hubungi administrator instance ini sebelum tanggal tersebut.} other {Jika kamu tidak meminta ini, atau ingin mempertahankan akunmu, hubungi {safety_email} dari alamat email ini sebelum tanggal tersebut.}}\n\n– Tim {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Akun {product_name} kamu akan dihapus secara permanen",
|
||||
"body": "Halo {username},\n\nAkun {product_name} kamu telah dijadwalkan untuk dihapus permanen karena melanggar Ketentuan Layanan atau Pedoman Komunitas kami.\n\nPenghapusan terjadwal: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Alasan: {reason}\n\n}\n}Ini adalah tindakan penegakan yang serius. Data akunmu akan dihapus permanen pada tanggal yang dijadwalkan.\n\n{legalLinks, select,\n both {Silakan tinjau:\n- Ketentuan Layanan: {termsUrl}\n- Pedoman Komunitas: {guidelinesUrl}\n\n}\n terms {Silakan tinjau:\n- Ketentuan Layanan: {termsUrl}\n\n}\n guidelines {Silakan tinjau:\n- Pedoman Komunitas: {guidelinesUrl}\n\n}\n other {}\n}Proses banding:\nJika kamu yakin keputusan ini keliru atau tidak beralasan, kamu punya 60 hari untuk mengajukan banding. {appeals_email, select, null {Hubungi administrator instance ini.} other {Kirim email ke {appeals_email} dari alamat email ini.}}\n\nDalam bandingmu:\n- Uraikan dengan jelas kenapa kamu yakin keputusan ini keliru atau tidak beralasan\n- Berikan bukti atau konteks yang relevan\n\nAnggota Tim Keamanan {product_name} akan meninjau bandingmu dan bisa menunda penghapusan sampai keputusan akhir tercapai.\n\n– Tim Keamanan {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Akun {product_name} kamu telah ditangguhkan sementara",
|
||||
"body": "Halo {username},\n\nAkun {product_name} kamu telah ditangguhkan sementara karena melanggar Ketentuan Layanan atau Pedoman Komunitas kami.\n\nDurasi: {durationHours, plural,\n =1 {1 jam}\n other {# jam}\n}\nDitangguhkan hingga: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Alasan: {reason}\n\n}\n}Selama waktu ini, kamu tidak akan bisa mengakses akunmu.\n\n{legalLinks, select,\n both {Silakan tinjau:\n- Ketentuan Layanan: {termsUrl}\n- Pedoman Komunitas: {guidelinesUrl}\n\n}\n terms {Silakan tinjau:\n- Ketentuan Layanan: {termsUrl}\n\n}\n guidelines {Silakan tinjau:\n- Pedoman Komunitas: {guidelinesUrl}\n\n}\n other {}\n}Jika kamu yakin keputusan ini keliru atau tidak beralasan, kamu bisa mengajukan banding. {appeals_email, select, null {Hubungi administrator instance ini dan uraikan dengan jelas kenapa kamu yakin keputusan ini keliru.} other {Kirim email ke {appeals_email} dari alamat email ini dan uraikan dengan jelas kenapa kamu yakin keputusan ini keliru.}} Kami akan meninjau bandingmu dan menyampaikan keputusan kami.\n\n– Tim Keamanan {product_name}"
|
||||
"body": "Halo {username},\n\nSesuai permintaanmu, kami menjadwalkan penghapusan akun {product_name} kamu pada {deletion_date}.\n\nUntuk mempertahankan akunmu, masuklah sebelum {deletion_date}. {safety_email, select, null {Jika kamu tidak memintanya, hubungi administrator instance ini.} other {Jika kamu tidak memintanya, hubungi {safety_email}.}}\n\nTim {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Terima kasih atas donasimu untuk {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Kami telah mengambil keputusan atas laporanmu di {product_name}",
|
||||
"body": "Halo,\n\nTim Keamanan kami telah meninjau laporan Digital Services Act kamu (ID: {reportId}) dan mengambil keputusan.{hasComment, select, yes {\n\nTanggapan dari Tim Keamanan:\n{publicComment}} other {}}\n\nKami tidak membagikan detail tindakan yang diambil terhadap akun orang lain, karena itu adalah data pribadi mereka.\n\nJika kamu tidak setuju dengan keputusan ini, kamu bisa mengajukan banding secara gratis dalam waktu 60 hari. {appeals_email, select, null {Hubungi administrator instance ini, sertakan ID laporanmu, dan jelaskan kenapa menurutmu keputusan ini keliru.} other {Kirim email ke {appeals_email} dari alamat email ini, sertakan ID laporanmu, dan jelaskan kenapa menurutmu keputusan ini keliru.}} Jika kamu berada di Uni Eropa, kamu juga bisa membawa sengketa ini ke badan penyelesaian sengketa di luar pengadilan yang bersertifikat. Semua ini tidak memengaruhi hakmu untuk menempuh jalur pengadilan.\n\n– Tim Keamanan {product_name}"
|
||||
"body": "Halo,\n\nKami telah meninjau laporanmu (ID: {reportId}).\n\n{outcome, select, removed {Kami menghapus konten yang kamu laporkan karena melanggar aturan kami.} restricted {Kami menemukan bahwa akun yang kamu laporkan melanggar aturan kami, dan kami telah membatasinya. Kami tidak membagikan tindakan apa yang kami ambil terhadap akun lain, karena itu adalah data pribadi pemiliknya.} action_taken {Kami menemukan bahwa yang kamu laporkan melanggar aturan kami, dan kami telah mengambil tindakan. Kami tidak membagikan tindakan apa yang kami ambil terhadap akun lain, karena itu adalah data pribadi pemiliknya.} no_violation {Kami menemukan bahwa konten atau akun yang kamu laporkan tidak melanggar aturan kami, jadi kami membiarkannya apa adanya.} other {Kami menutup laporanmu.}}{hasComment, select, yes {\n\nCatatan dari tim kami: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJika kamu tidak setuju, kirim email ke {appeals_email} dalam 60 hari dan sertakan ID laporan. Jika kamu berada di UE, kamu juga bisa membawa hal ini ke badan penyelesaian sengketa di luar pengadilan yang tersertifikasi atau mengajukan pengaduan ke Koordinator Layanan Digital di negaramu.}}\n\nTim {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifikasi emailmu untuk laporan DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Verifikasi alamat email {product_name} kamu",
|
||||
"body": "Halo {username},\n\nSilakan verifikasi alamat email untuk akun {product_name} kamu dengan mengeklik tautan di bawah ini:\n\n{verifyUrl}\n\nJika kamu tidak membuat akun {product_name}, kamu bisa mengabaikan email ini dengan aman.\n\nTautan ini berlaku selama 24 jam.\n\n– Tim {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Kami membatalkan keputusan tentang akun {product_name} kamu} other {Pembatasan pada akun {product_name} kamu telah berakhir}}",
|
||||
"body": "Halo {username},\n\n{outcome, select, reversed {{decision_id, select, null {Kami meninjau ulang keputusan tentang akunmu dan membatalkannya.} other {Kami meninjau ulang keputusan {decision_id} dan membatalkannya.}} Kami mohon maaf atas ketidaknyamanannya.} other {{decision_id, select, null {Kami telah mencabut pembatasan pada akunmu.} other {Kami telah mencabut pembatasan dari keputusan {decision_id}.}}}}{restores_access, select, yes { Kamu bisa masuk dan menggunakan {product_name} lagi.} other {}}{statement, select, null {} other {\n\nCatatan dari tim kami: {statement}}}{content_removed, select, yes {\n\nKonten yang kami hapus tidak dapat dipulihkan.} other {}}\n\nTim {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Kabar terbaru tentang akun {product_name} kamu} other {{kind_label, select, warning {Peringatan tentang akun {product_name} kamu} suspension {Akun {product_name} kamu ditangguhkan} ban {Akun {product_name} kamu diblokir} closure {Akun {product_name} kamu ditutup} feature_limit {Beberapa fitur di akun {product_name} kamu dibatasi} content_removal {Kami menghapus pesan yang kamu kirim di {product_name}} profile_reset {Kami mengatur ulang sebagian profil {product_name} kamu} community_removal {Kami menghapus komunitasmu di {product_name}} bot {Kabar terbaru tentang botmu di {product_name}} other {Kabar terbaru tentang akun {product_name} kamu}}}}",
|
||||
"body": "Halo {username},\n\n{summary}\n\n{effects}\n\nAlasan\nAturan: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nCatatan dari tim kami: {statement}}}\n\nCara kami memutuskan\n{how_we_decided}{appeals_email, select, null {} other {\n\nCara mengajukan banding\nJika menurutmu kami keliru, kirim email ke {appeals_email} paling lambat {appeal_deadline}. Jika bisa, kirim dari alamat email akunmu, dan sertakan ID keputusan. Banding tidak dipungut biaya, dan setiap banding ditinjau oleh manusia.{redress, select, null {} other {\n\n{redress}}}}}\n\nID keputusan: {decision_id}\n\nTim {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Manfaat dari hadiah yang kamu tukarkan telah dihapus",
|
||||
"body": "Halo {username},\n\nKode hadiah yang kamu tukarkan awalnya dibayar oleh orang lain. Pembayaran itu kemudian dibatalkan (chargeback).\n\nKarena itu, kami menghapus manfaat yang ditambahkan ke akunmu saat kamu menukarkan hadiah tersebut.\n\n{support_email, select, null {Jika kamu merasa ini keliru, silakan hubungi administrator instance ini dan sertakan detail apa pun yang kamu punya tentang kode hadiah itu dan kapan kamu menukarkannya.} other {Jika kamu merasa ini keliru, silakan hubungi tim dukungan kami dan sertakan detail apa pun yang kamu punya tentang kode hadiah itu dan kapan kamu menukarkannya.}}\n\n– Tim {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Laporanmu di {product_name} telah ditinjau",
|
||||
"body": "Halo {username},\n\nLaporanmu (ID: {reportId}) telah ditinjau oleh Tim Keamanan kami.{hasComment, select, yes {\n\nTanggapan dari Tim Keamanan:\n{publicComment}} other {}}\n\nTerima kasih telah membantu menjaga {product_name} tetap aman untuk semua orang. Kami menanggapi semua laporan dengan serius dan menghargai kontribusimu bagi komunitas.\n\n{safety_email, select, null {Jika kamu punya pertanyaan atau kekhawatiran tentang hasil ini, silakan hubungi administrator instance ini.} other {Jika kamu punya pertanyaan atau kekhawatiran tentang hasil ini, silakan hubungi {safety_email}.}}\n\n– Tim Keamanan {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Akun {product_name} kamu akan dihapus secara permanen",
|
||||
"body": "Halo {username},\n\nAkun {product_name} kamu telah dijadwalkan untuk dihapus permanen.\n\nPenghapusan terjadwal: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Alasan: {reason}\n\n}\n}Ini adalah tindakan penegakan yang serius. Data akunmu akan dihapus permanen pada tanggal yang dijadwalkan.\n\nJika kamu yakin keputusan ini keliru, kamu bisa mengajukan banding. {appeals_email, select, null {Hubungi administrator instance ini.} other {Kirim email ke {appeals_email} dari alamat email ini.}}\n\n– Tim Keamanan {product_name}"
|
||||
"body": "Halo {username},\n\nKami telah meninjau laporanmu (ID: {reportId}).\n\n{outcome, select, removed {Kami menghapus konten yang kamu laporkan karena melanggar aturan kami.} restricted {Kami menemukan bahwa akun yang kamu laporkan melanggar aturan kami, dan kami telah membatasinya. Kami tidak membagikan tindakan apa yang kami ambil terhadap akun lain, karena itu adalah data pribadi pemiliknya.} action_taken {Kami menemukan bahwa yang kamu laporkan melanggar aturan kami, dan kami telah mengambil tindakan. Kami tidak membagikan tindakan apa yang kami ambil terhadap akun lain, karena itu adalah data pribadi pemiliknya.} no_violation {Kami menemukan bahwa konten atau akun yang kamu laporkan tidak melanggar aturan kami, jadi kami membiarkannya apa adanya.} other {Kami menutup laporanmu.}}{hasComment, select, yes {\n\nCatatan dari tim kami: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJika kamu tidak setuju, kirim email ke {appeals_email} dalam 60 hari dan sertakan ID laporan. Jika kamu berada di UE, kamu juga bisa membawa hal ini ke badan penyelesaian sengketa di luar pengadilan yang tersertifikasi atau mengajukan pengaduan ke Koordinator Layanan Digital di negaramu.}}\n\nTim {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Penghapusan akun {product_name} kamu telah dijadwalkan",
|
||||
"body": "Halo {username},\n\nKamu meminta agar akun {product_name} kamu dihapus. Akunmu dijadwalkan untuk dihapus permanen pada:\n\n{deletionDate, date, full} pukul {deletionDate, time, short}\n\nJika kamu tidak meminta ini, masuk ke akunmu untuk membatalkan penghapusan. Kami juga menyarankan kamu mengubah kata sandi agar akunmu tetap aman.\n\n– Tim {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Penangguhan akun {product_name} kamu telah dicabut",
|
||||
"body": "Halo {username},\n\nKabar baik: penangguhan akun {product_name} kamu telah dicabut.\n\n{reason, select,\n null {}\n other {Alasan: {reason}\n\n}\n}Kamu sekarang bisa masuk lagi dan terus menggunakan {product_name} seperti biasa.\n\n– Tim Keamanan {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Il tuo account {product_name} verrà eliminato per inattività",
|
||||
"body": "Ciao {username},\n\nIl tuo account {product_name} è inattivo da molto tempo, quindi verrà eliminato definitivamente in questa data:\n\n{deletionDate, date, full} alle {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\n{safety_email, select, null {Se vuoi mantenere il tuo account, contatta gli amministratori di questa istanza prima di tale data.} other {Se vuoi mantenere il tuo account, contatta {safety_email} da questo indirizzo email prima di tale data.}}\n\n– Il team di {product_name}"
|
||||
"body": "Ciao {username},\n\nIl tuo account {product_name} è inattivo da molto tempo, quindi ne abbiamo programmato l'eliminazione per il {deletion_date}.\n\nPer mantenere il tuo account, accedi prima di tale data. {support_email, select, null {Se hai domande, contatta gli amministratori di questa istanza.} other {Se hai domande, contatta {support_email}.}}\n\nIl team di {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "L'eliminazione del tuo account {product_name} è programmata",
|
||||
"body": "Ciao {username},\n\nCome da tua richiesta, il tuo account {product_name} verrà eliminato definitivamente in questa data:\n\n{deletionDate, date, full} alle {deletionDate, time, short}{reason, select, null {} other {\n\nMotivo: {reason}}}\n\nFino ad allora il tuo account è bloccato. {safety_email, select, null {Se non hai richiesto l'eliminazione o se vuoi mantenere il tuo account, contatta gli amministratori di questa istanza prima di tale data.} other {Se non hai richiesto l'eliminazione o se vuoi mantenere il tuo account, contatta {safety_email} da questo indirizzo email prima di tale data.}}\n\n– Il team di {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Il tuo account {product_name} verrà eliminato definitivamente",
|
||||
"body": "Ciao {username},\n\nÈ stata programmata l’eliminazione definitiva del tuo account {product_name} a causa di violazioni dei nostri Termini di servizio o delle Linee guida della community.\n\nEliminazione programmata: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Si tratta di un provvedimento grave. I dati del tuo account verranno eliminati definitivamente alla data programmata.\n\n{legalLinks, select,\n both {Consulta:\n- Termini di servizio: {termsUrl}\n- Linee guida della community: {guidelinesUrl}\n\n}\n terms {Consulta:\n- Termini di servizio: {termsUrl}\n\n}\n guidelines {Consulta:\n- Linee guida della community: {guidelinesUrl}\n\n}\n other {}\n}Procedura di ricorso:\nSe ritieni che questa decisione sia scorretta o ingiustificata, hai 60 giorni per presentare un ricorso. {appeals_email, select, null {Contatta gli amministratori di questa istanza.} other {Invia un'email a {appeals_email} da questo indirizzo email.}}\n\nNel tuo ricorso:\n- Spiega chiaramente perché ritieni che la decisione sia scorretta o ingiustificata\n- Fornisci qualsiasi prova o contesto pertinente\n\nUn membro del team per la sicurezza {product_name} esaminerà il tuo ricorso e potrebbe sospendere l’eliminazione programmata fino a quando non sarà stata raggiunta una decisione finale.\n\n– Il team per la sicurezza di {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Il tuo account {product_name} è stato temporaneamente sospeso",
|
||||
"body": "Ciao {username},\n\nIl tuo account {product_name} è stato temporaneamente sospeso per aver violato i nostri Termini di servizio o le Linee guida della community.\n\nDurata: {durationHours, plural,\n =1 {1 ora}\n other {# ore}\n}\nSospeso fino a: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Durante questo periodo, non potrai accedere al tuo account.\n\n{legalLinks, select,\n both {Consulta:\n- Termini di servizio: {termsUrl}\n- Linee guida della community: {guidelinesUrl}\n\n}\n terms {Consulta:\n- Termini di servizio: {termsUrl}\n\n}\n guidelines {Consulta:\n- Linee guida della community: {guidelinesUrl}\n\n}\n other {}\n}Se ritieni che questa decisione sia scorretta o ingiustificata, puoi presentare un ricorso. {appeals_email, select, null {Contatta gli amministratori di questa istanza e spiega chiaramente perché ritieni che la decisione sia scorretta.} other {Invia un'email a {appeals_email} da questo indirizzo email e spiega chiaramente perché ritieni che la decisione sia scorretta.}} Esamineremo il tuo ricorso e risponderemo con la nostra decisione.\n\n– Il team per la sicurezza di {product_name}"
|
||||
"body": "Ciao {username},\n\nCome richiesto, abbiamo programmato l'eliminazione del tuo account {product_name} per il {deletion_date}.\n\nPer mantenere il tuo account, accedi prima del {deletion_date}. {safety_email, select, null {Se non l'hai richiesto tu, contatta gli amministratori di questa istanza.} other {Se non l'hai richiesto tu, contatta {safety_email}.}}\n\nIl team di {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Grazie per la tua donazione a {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Abbiamo preso una decisione sulla tua segnalazione su {product_name}",
|
||||
"body": "Ciao,\n\nIl nostro team per la sicurezza ha esaminato la tua segnalazione ai sensi del Digital Services Act (ID: {reportId}) e ha preso una decisione.{hasComment, select, yes {\n\nRisposta dal team per la sicurezza:\n{publicComment}} other {}}\n\nNon condividiamo i dettagli delle misure adottate nei confronti dell'account di un'altra persona, perché si tratta dei suoi dati personali.\n\nSe non sei d'accordo con questa decisione, puoi presentare un ricorso gratuitamente entro 60 giorni. {appeals_email, select, null {Contatta gli amministratori di questa istanza, indica l'ID della tua segnalazione e spiega perché ritieni che la decisione sia scorretta.} other {Invia un'email a {appeals_email} da questo indirizzo email, indica l'ID della tua segnalazione e spiega perché ritieni che la decisione sia scorretta.}} Se ti trovi nell'UE, puoi anche sottoporre la controversia a un organismo certificato di risoluzione extragiudiziale delle controversie. Tutto questo non pregiudica il tuo diritto di rivolgerti a un tribunale.\n\n– Il team per la sicurezza di {product_name}"
|
||||
"body": "Ciao,\n\nAbbiamo esaminato la tua segnalazione (ID: {reportId}).\n\n{outcome, select, removed {Abbiamo rimosso il contenuto che hai segnalato perché violava le nostre regole.} restricted {Abbiamo verificato che l'account che hai segnalato violava le nostre regole e gli abbiamo applicato una restrizione. Non comunichiamo quali provvedimenti abbiamo preso nei confronti di un altro account, perché si tratta di dati personali del suo titolare.} action_taken {Abbiamo verificato che ciò che hai segnalato violava le nostre regole e abbiamo preso provvedimenti. Non comunichiamo quali provvedimenti abbiamo preso nei confronti di un altro account, perché si tratta di dati personali del suo titolare.} no_violation {Abbiamo verificato che il contenuto o l'account che hai segnalato non viola le nostre regole, quindi non abbiamo cambiato nulla.} other {Abbiamo chiuso la tua segnalazione.}}{hasComment, select, yes {\n\nNota del nostro team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSe non sei d'accordo, scrivi a {appeals_email} entro 60 giorni indicando l'ID della segnalazione. Se ti trovi nell'UE, puoi anche rivolgerti a un organismo certificato di risoluzione extragiudiziale delle controversie o presentare un reclamo al coordinatore dei servizi digitali del tuo paese.}}\n\nIl team di {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifica la tua email per una segnalazione DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Verifica il tuo indirizzo email di {product_name}",
|
||||
"body": "Ciao {username},\n\nVerifica l'indirizzo email del tuo account {product_name} cliccando sul link qui sotto:\n\n{verifyUrl}\n\nSe non hai creato un account {product_name}, puoi ignorare tranquillamente questa email.\n\nQuesto link è valido per 24 ore.\n\n– Il team di {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Abbiamo annullato una decisione sul tuo account {product_name}} other {Una restrizione sul tuo account {product_name} è terminata}}",
|
||||
"body": "Ciao {username},\n\n{outcome, select, reversed {{decision_id, select, null {Abbiamo riesaminato una decisione sul tuo account e l'abbiamo annullata.} other {Abbiamo riesaminato la decisione {decision_id} e l'abbiamo annullata.}} Ci scusiamo per il disagio.} other {{decision_id, select, null {Abbiamo revocato la restrizione sul tuo account.} other {Abbiamo revocato la restrizione della decisione {decision_id}.}}}}{restores_access, select, yes { Puoi di nuovo accedere e usare {product_name}.} other {}}{statement, select, null {} other {\n\nNota del nostro team: {statement}}}{content_removed, select, yes {\n\nI contenuti che abbiamo rimosso non possono essere ripristinati.} other {}}\n\nIl team di {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Un aggiornamento sul tuo account {product_name}} other {{kind_label, select, warning {Un avvertimento sul tuo account {product_name}} suspension {Il tuo account {product_name} è sospeso} ban {Il tuo account {product_name} è bannato} closure {Il tuo account {product_name} è chiuso} feature_limit {Alcune funzionalità del tuo account {product_name} sono limitate} content_removal {Abbiamo rimosso dei messaggi che hai inviato su {product_name}} profile_reset {Abbiamo reimpostato parte del tuo profilo {product_name}} community_removal {Abbiamo rimosso la tua community su {product_name}} bot {Un aggiornamento sul tuo bot su {product_name}} other {Un aggiornamento sul tuo account {product_name}}}}}",
|
||||
"body": "Ciao {username},\n\n{summary}\n\n{effects}\n\nPerché\nRegola: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nNota del nostro team: {statement}}}\n\nCome abbiamo deciso\n{how_we_decided}{appeals_email, select, null {} other {\n\nCome presentare ricorso\nSe pensi che abbiamo sbagliato, scrivi a {appeals_email} entro il {appeal_deadline}. Se puoi, scrivi dall'indirizzo email del tuo account e indica l'ID decisione. Il ricorso è gratuito e ogni richiesta viene esaminata da una persona.{redress, select, null {} other {\n\n{redress}}}}}\n\nID decisione: {decision_id}\n\nIl team di {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "I vantaggi del tuo regalo riscattato sono stati rimossi",
|
||||
"body": "Ciao {username},\n\nUn codice regalo che hai riscattato era stato originariamente pagato da qualcun altro. Tale pagamento è stato successivamente annullato (un chargeback).\n\nPer questo motivo, abbiamo rimosso i vantaggi che erano stati aggiunti al tuo account quando hai riscattato il regalo.\n\n{support_email, select, null {Se ritieni che si tratti di un errore, contatta gli amministratori di questa istanza e includi tutti i dettagli che hai sul codice regalo e su quando lo hai riscattato.} other {Se ritieni che si tratti di un errore, contatta il nostro team di assistenza e includi tutti i dettagli che hai sul codice regalo e su quando lo hai riscattato.}}\n\n– Il team di {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "La tua segnalazione {product_name} è stata esaminata",
|
||||
"body": "Ciao {username},\n\nLa tua segnalazione (ID: {reportId}) è stata esaminata dal nostro team per la sicurezza.{hasComment, select, yes {\n\nRisposta dal team per la sicurezza:\n{publicComment}} other {}}\n\nGrazie per aver contribuito a mantenere {product_name} sicuro per tutti. Prendiamo sul serio tutte le segnalazioni e apprezziamo il tuo contributo alla community.\n\n{safety_email, select, null {Se hai domande o preoccupazioni riguardo a questo esito, contatta gli amministratori di questa istanza.} other {Se hai domande o preoccupazioni riguardo a questo esito, contatta {safety_email}.}}\n\n– Il team per la sicurezza di {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Il tuo account {product_name} verrà eliminato definitivamente",
|
||||
"body": "Ciao {username},\n\nÈ stata programmata l’eliminazione definitiva del tuo account {product_name}.\n\nEliminazione programmata: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Si tratta di un provvedimento grave. I dati del tuo account verranno eliminati definitivamente alla data programmata.\n\nSe ritieni che questa decisione sia scorretta, puoi presentare un ricorso. {appeals_email, select, null {Contatta gli amministratori di questa istanza.} other {Invia un'email a {appeals_email} da questo indirizzo email.}}\n\n– Il team per la sicurezza di {product_name}"
|
||||
"body": "Ciao {username},\n\nAbbiamo esaminato la tua segnalazione (ID: {reportId}).\n\n{outcome, select, removed {Abbiamo rimosso il contenuto che hai segnalato perché violava le nostre regole.} restricted {Abbiamo verificato che l'account che hai segnalato violava le nostre regole e gli abbiamo applicato una restrizione. Non comunichiamo quali provvedimenti abbiamo preso nei confronti di un altro account, perché si tratta di dati personali del suo titolare.} action_taken {Abbiamo verificato che ciò che hai segnalato violava le nostre regole e abbiamo preso provvedimenti. Non comunichiamo quali provvedimenti abbiamo preso nei confronti di un altro account, perché si tratta di dati personali del suo titolare.} no_violation {Abbiamo verificato che il contenuto o l'account che hai segnalato non viola le nostre regole, quindi non abbiamo cambiato nulla.} other {Abbiamo chiuso la tua segnalazione.}}{hasComment, select, yes {\n\nNota del nostro team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nSe non sei d'accordo, scrivi a {appeals_email} entro 60 giorni indicando l'ID della segnalazione. Se ti trovi nell'UE, puoi anche rivolgerti a un organismo certificato di risoluzione extragiudiziale delle controversie o presentare un reclamo al coordinatore dei servizi digitali del tuo paese.}}\n\nIl team di {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "L'eliminazione del tuo account {product_name} è programmata",
|
||||
"body": "Ciao {username},\n\nHai richiesto di eliminare il tuo account {product_name}. Il tuo account verrà eliminato definitivamente in questa data:\n\n{deletionDate, date, full} alle {deletionDate, time, short}\n\nSe non hai richiesto l'eliminazione, accedi al tuo account per annullarla. Ti consigliamo anche di cambiare la password per mettere in sicurezza il tuo account.\n\n– Il team di {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "La sospensione del tuo account {product_name} è stata revocata",
|
||||
"body": "Ciao {username},\n\nBuone notizie: la sospensione del tuo account {product_name} è stata revocata.\n\n{reason, select,\n null {}\n other {Motivo: {reason}\n\n}\n}Ora puoi accedere di nuovo e continuare a usare {product_name} normalmente.\n\n– Il team per la sicurezza di {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}アカウントは非アクティブのため削除されます",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは長期間利用されていないため、以下の日時に完全に削除される予定です。\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n理由: {reason}}}\n\n{safety_email, select, null {アカウントを維持したい場合は、この日時までにこのインスタンスの管理者へご連絡ください。} other {アカウントを維持したい場合は、この日時までにこのメールアドレスから{safety_email}へご連絡ください。}}\n\n– {product_name}チーム"
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは長期間利用されていないため、{deletion_date}に削除するよう設定しました。\n\nアカウントを残したい場合は、その日より前にログインしてください。{support_email, select, null {ご質問がございましたら、このインスタンスの管理者にご連絡ください。} other {ご質問がございましたら、{support_email}までご連絡ください。}}\n\n{product_name}チーム"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name}アカウントの削除が予定されています",
|
||||
"body": "こんにちは、{username}さん\n\nリクエストに基づき、{product_name}アカウントは以下の日時に完全に削除される予定です。\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n理由: {reason}}}\n\nそれまでアカウントはロックされます。{safety_email, select, null {ご自身でリクエストしていない場合、またはアカウントを維持したい場合は、この日時までにこのインスタンスの管理者へご連絡ください。} other {ご自身でリクエストしていない場合、またはアカウントを維持したい場合は、この日時までにこのメールアドレスから{safety_email}へご連絡ください。}}\n\n– {product_name}チーム"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "{product_name}アカウントは完全に削除されます",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは、利用規約またはコミュニティガイドラインへの違反のため、完全な削除が予定されています。\n\n削除予定日時: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}\n\n}\n}これは重大な措置です。アカウントデータは、予定日に完全に削除されます。\n\n{legalLinks, select,\n both {以下をご確認ください。\n- 利用規約: {termsUrl}\n- コミュニティガイドライン: {guidelinesUrl}\n\n}\n terms {以下をご確認ください。\n- 利用規約: {termsUrl}\n\n}\n guidelines {以下をご確認ください。\n- コミュニティガイドライン: {guidelinesUrl}\n\n}\n other {}\n}異議申し立て手続き:\nこの措置が不正確または不当であると思われる場合は、60日以内に異議申し立てを提出できます。{appeals_email, select, null {このインスタンスの管理者に連絡してください。} other {このメールアドレスから{appeals_email}までメールを送信してください。}}\n\n異議申し立てには、以下を記載してください。\n- 措置が不正確または不当であると考える理由の明確な説明\n- 関連する証拠や背景情報\n\n{product_name}安全チームのメンバーが異議申し立てを審査し、最終決定が下されるまで削除を一時停止する場合があります。\n\n– {product_name}安全チーム"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "{product_name}アカウントが一時的に停止されました",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは、利用規約またはコミュニティガイドラインへの違反のため、一時的に停止されました。\n\n期間: {durationHours, plural,\n =1 {1時間}\n other {#時間}\n}\n停止解除日時: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}\n\n}\n}この期間中、アカウントにアクセスすることはできません。\n\n{legalLinks, select,\n both {以下をご確認ください。\n- 利用規約: {termsUrl}\n- コミュニティガイドライン: {guidelinesUrl}\n\n}\n terms {以下をご確認ください。\n- 利用規約: {termsUrl}\n\n}\n guidelines {以下をご確認ください。\n- コミュニティガイドライン: {guidelinesUrl}\n\n}\n other {}\n}この措置が不正確または不当であると思われる場合は、異議申し立てを提出できます。{appeals_email, select, null {このインスタンスの管理者に連絡し、決定が不正確であると考える理由を明確に説明してください。} other {このメールアドレスから{appeals_email}までメールを送信し、決定が不正確であると考える理由を明確に説明してください。}}異議申し立てを審査し、決定をご連絡いたします。\n\n– {product_name}安全チーム"
|
||||
"body": "こんにちは、{username}さん\n\nご依頼のとおり、{product_name}アカウントを{deletion_date}に削除するよう設定しました。\n\nアカウントを残したい場合は、{deletion_date}より前にログインしてください。{safety_email, select, null {ご自身で依頼していない場合は、このインスタンスの管理者にご連絡ください。} other {ご自身で依頼していない場合は、{safety_email}までご連絡ください。}}\n\n{product_name}チーム"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name}へのご寄付ありがとうございます",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "{product_name}への報告について決定しました",
|
||||
"body": "こんにちは。\n\nデジタルサービス法に基づくご報告(ID: {reportId})を安全チームが審査し、決定を行いました。{hasComment, select, yes {\n\n安全チームからの回答:\n{publicComment}} other {}}\n\n他の方のアカウントに対して行った措置の詳細は、その方の個人データにあたるため、お伝えしていません。\n\nこの決定に同意できない場合は、60日以内に無料で異議申し立てを行えます。{appeals_email, select, null {このインスタンスの管理者に連絡し、報告IDを記載のうえ、決定が誤っていると考える理由を説明してください。} other {このメールアドレスから{appeals_email}までメールを送信し、報告IDを記載のうえ、決定が誤っていると考える理由を説明してください。}}EU域内にお住まいの場合は、認定を受けた裁判外紛争解決機関に紛争を付託することもできます。これらはいずれも、裁判所に訴える権利に影響しません。\n\n– {product_name}安全チーム"
|
||||
"body": "こんにちは。\n\nご報告(ID: {reportId})を確認しました。\n\n{outcome, select, removed {報告されたコンテンツはルールに違反していたため、削除しました。} restricted {報告されたアカウントがルールに違反していることを確認し、制限しました。他のアカウントに対してどのような措置をとったかは、その方の個人データにあたるため、お伝えしていません。} action_taken {報告された内容がルールに違反していることを確認し、措置をとりました。他のアカウントに対してどのような措置をとったかは、その方の個人データにあたるため、お伝えしていません。} no_violation {報告されたコンテンツまたはアカウントはルールに違反していないことを確認したため、そのままにしています。} other {ご報告の対応を終了しました。}}{hasComment, select, yes {\n\nチームからのコメント: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nこの結果に同意できない場合は、60日以内に報告IDを記載して{appeals_email}までメールでご連絡ください。EU域内の方は、認定を受けた裁判外紛争解決機関にこの件を付託すること、またはお住まいの国のデジタルサービス調整官(Digital Services Coordinator)に苦情を申し立てることもできます。}}\n\n{product_name}チーム"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "DSA報告のためのメールアドレスを確認",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "{product_name}のメールアドレスを確認",
|
||||
"body": "こんにちは、{username}さん\n\n以下のリンクをクリックして、{product_name}アカウントのメールアドレスを確認してください。\n\n{verifyUrl}\n\n{product_name}アカウントを作成していない場合は、このメールを無視して問題ありません。\n\nこのリンクは24時間有効です。\n\n– {product_name}チーム"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {{product_name}アカウントに関する決定を取り消しました} other {{product_name}アカウントの制限が終了しました}}",
|
||||
"body": "こんにちは、{username}さん\n\n{outcome, select, reversed {{decision_id, select, null {アカウントに関する決定を再検討し、取り消しました。} other {決定{decision_id}を再検討し、取り消しました。}}ご迷惑をおかけして申し訳ありません。} other {{decision_id, select, null {アカウントの制限を解除しました。} other {決定{decision_id}による制限を解除しました。}}}}{restores_access, select, yes {再びログインして{product_name}を利用できます。} other {}}{statement, select, null {} other {\n\nチームからのコメント: {statement}}}{content_removed, select, yes {\n\n削除したコンテンツは復元できません。} other {}}\n\n{product_name}チーム"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {{product_name}アカウントに関するお知らせ} other {{kind_label, select, warning {{product_name}アカウントに関する警告} suspension {{product_name}アカウントは一時停止されています} ban {{product_name}アカウントは永久停止されています} closure {{product_name}アカウントは閉鎖されています} feature_limit {{product_name}アカウントの一部の機能が制限されています} content_removal {{product_name}で送信したメッセージを削除しました} profile_reset {{product_name}プロフィールの一部をリセットしました} community_removal {{product_name}のあなたのコミュニティを削除しました} bot {{product_name}のボットに関するお知らせ} other {{product_name}アカウントに関するお知らせ}}}}",
|
||||
"body": "こんにちは、{username}さん\n\n{summary}\n\n{effects}\n\n理由\nルール: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nチームからのコメント: {statement}}}\n\n判断の経緯\n{how_we_decided}{appeals_email, select, null {} other {\n\n異議申し立ての方法\nこの判断が誤りだと思われる場合は、{appeal_deadline}までに{appeals_email}へメールでご連絡ください。可能であればアカウントに登録したメールアドレスから送信し、決定IDを記載してください。異議申し立ては無料で、一件ずつ担当者が審査します。{redress, select, null {} other {\n\n{redress}}}}}\n\n決定ID: {decision_id}\n\n{product_name}チーム"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "引き換えたギフトの特典が削除されました",
|
||||
"body": "こんにちは、{username}さん\n\n引き換えたギフトコードは、元は別の方が支払ったものです。その支払いはその後取り消されました(チャージバック)。\n\nそのため、ギフトを引き換えた際にアカウントに追加された特典を削除しました。\n\n{support_email, select, null {これが間違いであると思われる場合は、ギフトコードと引き換え時期に関する詳細を添えて、このインスタンスの管理者にお問い合わせください。} other {これが間違いであると思われる場合は、ギフトコードと引き換え時期に関する詳細を添えて、サポートチームにお問い合わせください。}}\n\n– {product_name}チーム"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "{product_name}への報告の審査が完了しました",
|
||||
"body": "こんにちは、{username}さん\n\nご報告いただいた内容(ID: {reportId})を安全チームが審査しました。{hasComment, select, yes {\n\n安全チームからの回答:\n{publicComment}} other {}}\n\n{product_name}をすべての人にとって安全に保つためのご協力ありがとうございます。すべての報告を真剣に受け止め、コミュニティへのご貢献に感謝します。\n\n{safety_email, select, null {この結果についてご質問やご不明な点がございましたら、このインスタンスの管理者にお問い合わせください。} other {この結果についてご質問やご不明な点がございましたら、{safety_email}までお問い合わせください。}}\n\n– {product_name}安全チーム"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "{product_name}アカウントは完全に削除されます",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントの完全な削除が予定されています。\n\n削除予定日時: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}\n\n}\n}これは重大な措置です。アカウントデータは、予定日に完全に削除されます。\n\nこの措置が不正確であると思われる場合は、異議申し立てを提出できます。{appeals_email, select, null {このインスタンスの管理者に連絡してください。} other {このメールアドレスから{appeals_email}までメールを送信してください。}}\n\n– {product_name}安全チーム"
|
||||
"body": "こんにちは、{username}さん\n\nご報告(ID: {reportId})を確認しました。\n\n{outcome, select, removed {報告されたコンテンツはルールに違反していたため、削除しました。} restricted {報告されたアカウントがルールに違反していることを確認し、制限しました。他のアカウントに対してどのような措置をとったかは、その方の個人データにあたるため、お伝えしていません。} action_taken {報告された内容がルールに違反していることを確認し、措置をとりました。他のアカウントに対してどのような措置をとったかは、その方の個人データにあたるため、お伝えしていません。} no_violation {報告されたコンテンツまたはアカウントはルールに違反していないことを確認したため、そのままにしています。} other {ご報告の対応を終了しました。}}{hasComment, select, yes {\n\nチームからのコメント: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nこの結果に同意できない場合は、60日以内に報告IDを記載して{appeals_email}までメールでご連絡ください。EU域内の方は、認定を受けた裁判外紛争解決機関にこの件を付託すること、またはお住まいの国のデジタルサービス調整官(Digital Services Coordinator)に苦情を申し立てることもできます。}}\n\n{product_name}チーム"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "{product_name}アカウントの削除が予定されています",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントの削除をリクエストされました。アカウントは以下の日時に完全に削除される予定です。\n\n{deletionDate, date, full} {deletionDate, time, short}\n\nご自身でリクエストしていない場合は、アカウントにログインして削除をキャンセルしてください。また、アカウントを保護するためにパスワードの変更をお勧めします。\n\n– {product_name}チーム"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "{product_name}アカウントの停止が解除されました",
|
||||
"body": "こんにちは、{username}さん\n\n良いお知らせです。{product_name}アカウントの停止が解除されました。\n\n{reason, select,\n null {}\n other {理由: {reason}\n\n}\n}これで再度ログインして、通常通り{product_name}を利用できます。\n\n– {product_name}安全チーム"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "장기간 미접속으로 {product_name} 계정이 삭제될 예정이에요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정이 오랫동안 사용되지 않아 다음 날짜에 영구적으로 삭제될 예정이에요:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n사유: {reason}}}\n\n{safety_email, select, null {계정을 유지하고 싶다면 해당 날짜 전에 이 인스턴스의 관리자에게 문의해 주세요.} other {계정을 유지하고 싶다면 해당 날짜 전에 이 이메일 주소에서 {safety_email}으로 문의해 주세요.}}\n\n– {product_name} 팀"
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정이 오랫동안 사용되지 않아 {deletion_date}에 삭제하도록 예약했어요.\n\n계정을 유지하려면 그 날짜 전에 로그인해 주세요. {support_email, select, null {궁금한 점이 있다면 이 인스턴스의 관리자에게 문의해 주세요.} other {궁금한 점이 있다면 {support_email} 주소로 문의해 주세요.}}\n\n{product_name} 팀 드림"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "{product_name} 계정 삭제가 예정되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n요청하신 대로 {product_name} 계정이 다음 날짜에 영구적으로 삭제될 예정이에요:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\n사유: {reason}}}\n\n그때까지 계정은 잠겨 있어요. {safety_email, select, null {요청하지 않으셨거나 계정을 유지하고 싶다면 해당 날짜 전에 이 인스턴스의 관리자에게 문의해 주세요.} other {요청하지 않으셨거나 계정을 유지하고 싶다면 해당 날짜 전에 이 이메일 주소에서 {safety_email}으로 문의해 주세요.}}\n\n– {product_name} 팀"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "{product_name} 계정이 영구적으로 삭제될 예정이에요",
|
||||
"body": "안녕하세요, {username}님.\n\n서비스 약관 또는 커뮤니티 가이드라인 위반으로 인해 {product_name} 계정이 영구적으로 삭제될 예정이에요.\n\n삭제 예정일: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {사유: {reason}\n\n}\n}이는 중대한 조치이며, 계정 데이터는 예정된 날짜에 영구적으로 삭제될 예정이에요.\n\n{legalLinks, select,\n both {다음 내용을 확인해 주세요:\n- 서비스 약관: {termsUrl}\n- 커뮤니티 가이드라인: {guidelinesUrl}\n\n}\n terms {다음 내용을 확인해 주세요:\n- 서비스 약관: {termsUrl}\n\n}\n guidelines {다음 내용을 확인해 주세요:\n- 커뮤니티 가이드라인: {guidelinesUrl}\n\n}\n other {}\n}이의 제기 절차:\n이 조치가 잘못되었거나 부당하다고 생각되면 60일 이내에 이의를 제기할 수 있어요. {appeals_email, select, null {이 인스턴스의 관리자에게 연락해 이의를 제기해 주세요.} other {이 이메일 주소에서 {appeals_email}으로 이메일을 보내 이의를 제기해 주세요.}}\n\n이의 제기 시에는:\n- 조치가 잘못되었거나 부당하다고 생각하는 이유를 명확하게 설명해 주세요.\n- 관련 증거 또는 맥락을 제공해 주세요.\n\n{product_name} 안전팀 담당자가 이의 제기를 검토하고 최종 결정이 내려질 때까지 삭제를 일시적으로 보류할 수 있어요.\n\n– {product_name} 안전팀"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "{product_name} 계정이 일시적으로 정지되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n서비스 약관 또는 커뮤니티 가이드라인 위반으로 인해 {product_name} 계정이 일시적으로 정지되었어요.\n\n기간: {durationHours, plural,\n =1 {1시간}\n other {#시간}\n}\n정지 해제 일시: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {사유: {reason}\n\n}\n}이 기간 동안에는 계정에 액세스할 수 없어요.\n\n{legalLinks, select,\n both {다음 내용을 확인해 주세요:\n- 서비스 약관: {termsUrl}\n- 커뮤니티 가이드라인: {guidelinesUrl}\n\n}\n terms {다음 내용을 확인해 주세요:\n- 서비스 약관: {termsUrl}\n\n}\n guidelines {다음 내용을 확인해 주세요:\n- 커뮤니티 가이드라인: {guidelinesUrl}\n\n}\n other {}\n}이 조치가 잘못되었거나 부당하다고 생각되면 이의를 제기할 수 있어요. {appeals_email, select, null {이 인스턴스의 관리자에게 연락해 결정이 잘못되었다고 생각하는 이유를 명확하게 설명해 주세요.} other {이 이메일 주소에서 {appeals_email}으로 이메일을 보내 결정이 잘못되었다고 생각하는 이유를 명확하게 설명해 주세요.}} 이의 제기를 검토한 후 결과를 알려 드릴 예정이에요.\n\n– {product_name} 안전팀"
|
||||
"body": "안녕하세요, {username}님.\n\n요청하신 대로 {product_name} 계정을 {deletion_date}에 삭제하도록 예약했어요.\n\n계정을 유지하려면 {deletion_date} 전에 로그인해 주세요. {safety_email, select, null {직접 요청하지 않았다면 이 인스턴스의 관리자에게 문의해 주세요.} other {직접 요청하지 않았다면 {safety_email} 주소로 문의해 주세요.}}\n\n{product_name} 팀 드림"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name} 후원에 감사드려요",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "{product_name} 신고에 대한 결정이 내려졌어요",
|
||||
"body": "안녕하세요,\n\n안전팀에서 디지털 서비스법 신고(ID: {reportId})를 검토하고 결정을 내렸어요.{hasComment, select, yes {\n\n안전팀의 답변:\n{publicComment}} other {}}\n\n다른 사람의 계정에 취한 조치의 세부 내용은 그 사람의 개인 정보이므로 공유하지 않아요.\n\n이 결정에 동의하지 않는다면 60일 이내에 무료로 이의를 제기할 수 있어요. {appeals_email, select, null {이 인스턴스의 관리자에게 연락해 신고 ID를 적고 결정이 잘못되었다고 생각하는 이유를 설명해 주세요.} other {이 이메일 주소에서 {appeals_email}으로 이메일을 보내 신고 ID를 적고 결정이 잘못되었다고 생각하는 이유를 설명해 주세요.}} EU에 거주하고 있다면 인증된 법원 외 분쟁 해결 기관에 분쟁을 회부할 수도 있어요. 이 내용은 법원에 소송을 제기할 권리에 영향을 주지 않아요.\n\n– {product_name} 안전팀"
|
||||
"body": "안녕하세요.\n\n접수하신 신고(ID: {reportId})를 검토했어요.\n\n{outcome, select, removed {신고하신 콘텐츠가 규칙을 위반해 삭제했어요.} restricted {신고하신 계정이 규칙을 위반한 것을 확인하고 제한했어요. 다른 계정에 어떤 조치를 했는지는 그 사람의 개인 정보이므로 공유하지 않아요.} action_taken {신고하신 내용이 규칙을 위반한 것을 확인하고 조치했어요. 다른 계정에 어떤 조치를 했는지는 그 사람의 개인 정보이므로 공유하지 않아요.} no_violation {신고하신 콘텐츠나 계정이 규칙을 위반하지 않은 것으로 확인되어 그대로 두었어요.} other {신고 처리를 종료했어요.}}{hasComment, select, yes {\n\n팀에서 남긴 메모: {publicComment}} other {}}{appeals_email, select, null {} other {\n\n이 결과에 동의하지 않는다면 60일 이내에 신고 ID를 적어 {appeals_email} 주소로 이메일을 보내 주세요. EU에 있다면 인증된 재판 외 분쟁 해결 기관에 이 건을 회부하거나, 거주 국가의 디지털 서비스 조정관에게 민원을 제기할 수도 있어요.}}\n\n{product_name} 팀 드림"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "DSA 신고를 위한 이메일 인증",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "{product_name} 이메일 주소 인증",
|
||||
"body": "안녕하세요, {username}님.\n\n아래 링크를 클릭하여 {product_name} 계정의 이메일 주소를 인증해 주세요:\n\n{verifyUrl}\n\n{product_name} 계정을 생성하지 않으셨다면 이 이메일을 무시해도 괜찮아요.\n\n이 링크는 24시간 동안 유효해요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {{product_name} 계정에 대한 결정을 취소했어요} other {{product_name} 계정의 제한이 종료되었어요}}",
|
||||
"body": "안녕하세요, {username}님.\n\n{outcome, select, reversed {{decision_id, select, null {계정에 대한 결정을 다시 검토하고 취소했어요.} other {{decision_id} 결정을 다시 검토하고 취소했어요.}} 불편을 드려 죄송해요.} other {{decision_id, select, null {계정의 제한을 해제했어요.} other {{decision_id} 결정에 따른 제한을 해제했어요.}}}}{restores_access, select, yes { 이제 다시 로그인해 {product_name} 서비스를 이용할 수 있어요.} other {}}{statement, select, null {} other {\n\n팀에서 남긴 메모: {statement}}}{content_removed, select, yes {\n\n삭제된 콘텐츠는 복원할 수 없어요.} other {}}\n\n{product_name} 팀 드림"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {{product_name} 계정 관련 안내} other {{kind_label, select, warning {{product_name} 계정에 대한 경고} suspension {{product_name} 계정이 일시 정지되었어요} ban {{product_name} 계정이 영구 정지되었어요} closure {{product_name} 계정이 폐쇄되었어요} feature_limit {{product_name} 계정의 일부 기능이 제한되었어요} content_removal {{product_name}에서 보낸 메시지를 삭제했어요} profile_reset {{product_name} 프로필의 일부를 초기화했어요} community_removal {{product_name}에서 회원님의 커뮤니티를 삭제했어요} bot {{product_name} 봇 관련 안내} other {{product_name} 계정 관련 안내}}}}",
|
||||
"body": "안녕하세요, {username}님.\n\n{summary}\n\n{effects}\n\n사유\n규칙: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\n팀에서 남긴 메모: {statement}}}\n\n결정 과정\n{how_we_decided}{appeals_email, select, null {} other {\n\n이의 제기 방법\n결정이 잘못되었다고 생각하면 {appeal_deadline}까지 {appeals_email} 주소로 이메일을 보내 주세요. 가능하면 계정에 등록된 이메일 주소로 보내고, 결정 ID를 적어 주세요. 이의 제기는 무료이며, 모든 이의 제기는 담당자가 직접 검토해요.{redress, select, null {} other {\n\n{redress}}}}}\n\n결정 ID: {decision_id}\n\n{product_name} 팀 드림"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "사용하신 선물 혜택이 제거되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n사용하신 선물 코드는 다른 사용자가 결제한 것이었어요. 해당 결제가 취소(차지백)되었어요.\n\n이로 인해 선물을 사용했을 때 계정에 추가되었던 혜택이 제거되었어요.\n\n{support_email, select, null {실수라고 생각되면 이 인스턴스의 관리자에게 문의하여 선물 코드 및 사용 시기에 대한 자세한 정보를 알려 주세요.} other {실수라고 생각되면 고객지원팀에 문의하여 선물 코드 및 사용 시기에 대한 자세한 정보를 알려 주세요.}}\n\n– {product_name} 팀"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "{product_name} 신고 검토가 완료되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n접수하신 신고(ID: {reportId})를 안전팀에서 검토했어요.{hasComment, select, yes {\n\n안전팀의 답변:\n{publicComment}} other {}}\n\n모두를 위해 {product_name}를 안전하게 유지하는 데 도움을 주셔서 감사해요. 모든 신고를 진지하게 검토하고 있으며, 커뮤니티에 기여해 주셔서 감사해요.\n\n{safety_email, select, null {이 결과에 대해 궁금한 점이나 우려 사항이 있다면 이 인스턴스의 관리자에게 문의해 주세요.} other {이 결과에 대해 궁금한 점이나 우려 사항이 있다면 {safety_email}으로 문의해 주세요.}}\n\n– {product_name} 안전팀"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "{product_name} 계정이 영구적으로 삭제될 예정이에요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정이 영구적으로 삭제될 예정이에요.\n\n삭제 예정일: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {사유: {reason}\n\n}\n}이는 중대한 조치이며, 계정 데이터는 예정된 날짜에 영구적으로 삭제될 예정이에요.\n\n이 조치가 잘못되었다고 생각되면 이의를 제기할 수 있어요. {appeals_email, select, null {이 인스턴스의 관리자에게 연락해 이의를 제기해 주세요.} other {이 이메일 주소에서 {appeals_email}으로 이메일을 보내 이의를 제기해 주세요.}}\n\n– {product_name} 안전팀"
|
||||
"body": "안녕하세요, {username}님.\n\n접수하신 신고(ID: {reportId})를 검토했어요.\n\n{outcome, select, removed {신고하신 콘텐츠가 규칙을 위반해 삭제했어요.} restricted {신고하신 계정이 규칙을 위반한 것을 확인하고 제한했어요. 다른 계정에 어떤 조치를 했는지는 그 사람의 개인 정보이므로 공유하지 않아요.} action_taken {신고하신 내용이 규칙을 위반한 것을 확인하고 조치했어요. 다른 계정에 어떤 조치를 했는지는 그 사람의 개인 정보이므로 공유하지 않아요.} no_violation {신고하신 콘텐츠나 계정이 규칙을 위반하지 않은 것으로 확인되어 그대로 두었어요.} other {신고 처리를 종료했어요.}}{hasComment, select, yes {\n\n팀에서 남긴 메모: {publicComment}} other {}}{appeals_email, select, null {} other {\n\n이 결과에 동의하지 않는다면 60일 이내에 신고 ID를 적어 {appeals_email} 주소로 이메일을 보내 주세요. EU에 있다면 인증된 재판 외 분쟁 해결 기관에 이 건을 회부하거나, 거주 국가의 디지털 서비스 조정관에게 민원을 제기할 수도 있어요.}}\n\n{product_name} 팀 드림"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "{product_name} 계정 삭제가 예정되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정 삭제를 요청하셨어요. 계정은 다음 날짜에 영구적으로 삭제될 예정이에요:\n\n{deletionDate, date, full} {deletionDate, time, short}\n\n요청하지 않으셨다면 계정에 로그인하여 삭제를 취소해 주세요. 계정을 보호하기 위해 비밀번호를 변경하는 것을 권장해요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "{product_name} 계정 정지가 해제되었어요",
|
||||
"body": "안녕하세요, {username}님.\n\n좋은 소식이에요: {product_name} 계정 정지가 해제되었어요.\n\n{reason, select,\n null {}\n other {사유: {reason}\n\n}\n}이제 다시 로그인하여 평소처럼 {product_name}를 계속 사용할 수 있어요.\n\n– {product_name} 안전팀"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Tavo {product_name} paskyra bus ištrinta dėl neaktyvumo",
|
||||
"body": "Sveiki, {username},\n\nTavo {product_name} paskyra ilgą laiką buvo neaktyvi, todėl ją numatyta visam laikui ištrinti:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nPriežastis: {reason}}}\n\n{safety_email, select, null {Jei nori išsaugoti paskyrą, iki šios datos susisiek su šios instancijos administratoriais.} other {Jei nori išsaugoti paskyrą, iki šios datos parašyk adresu {safety_email} iš šio el. pašto adreso.}}\n\n– {product_name} komanda"
|
||||
"body": "Sveiki, {username},\n\nTavo {product_name} paskyra ilgą laiką buvo neaktyvi, todėl numatėme ją ištrinti {deletion_date}.\n\nJei nori išsaugoti paskyrą, prisijunk iki šios datos. {support_email, select, null {Jei turi klausimų, susisiek su šios instancijos administratoriais.} other {Jei turi klausimų, parašyk adresu {support_email}.}}\n\n{product_name} komanda"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Tavo {product_name} paskyros ištrynimas numatytas",
|
||||
"body": "Sveiki, {username},\n\nTavo prašymu {product_name} paskyrą numatyta visam laikui ištrinti:\n\n{deletionDate, date, full} {deletionDate, time, short}{reason, select, null {} other {\n\nPriežastis: {reason}}}\n\nIki tol tavo paskyra užrakinta. {safety_email, select, null {Jei to neprašei arba nori išsaugoti paskyrą, iki šios datos susisiek su šios instancijos administratoriais.} other {Jei to neprašei arba nori išsaugoti paskyrą, iki šios datos parašyk adresu {safety_email} iš šio el. pašto adreso.}}\n\n– {product_name} komanda"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Tavo {product_name} paskyra bus visam laikui ištrinta",
|
||||
"body": "Sveiki, {username},\n\nTavo {product_name} paskyrą numatyta visam laikui ištrinti dėl mūsų paslaugų teikimo sąlygų arba bendruomenės gairių pažeidimų.\n\nNumatytas ištrynimas: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Priežastis: {reason}\n\n}\n}Tai rimta priemonė. Tavo paskyros duomenys numatytą dieną bus visam laikui ištrinti.\n\n{legalLinks, select,\n both {Peržiūrėk:\n- Paslaugų teikimo sąlygos: {termsUrl}\n- Bendruomenės gairės: {guidelinesUrl}\n\n}\n terms {Peržiūrėk:\n- Paslaugų teikimo sąlygos: {termsUrl}\n\n}\n guidelines {Peržiūrėk:\n- Bendruomenės gairės: {guidelinesUrl}\n\n}\n other {}\n}Apeliacijos procesas:\nJei manai, kad šis sprendimas neteisingas arba nepagrįstas, apeliaciją gali pateikti per 60 dienų. {appeals_email, select, null {Susisiek su šios instancijos administratoriais.} other {Iš šio el. pašto adreso siųsk el. laišką adresu {appeals_email}.}}\n\nSavo apeliacijoje:\n- Aiškiai paaiškink, kodėl manai, kad sprendimas neteisingas arba nepagrįstas\n- Pateik visus susijusius įrodymus ar kontekstą\n\n{product_name} saugos komandos narys peržiūrės tavo apeliaciją ir gali sustabdyti numatytą ištrynimą, kol bus priimtas galutinis sprendimas.\n\n– {product_name} saugos komanda"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Tavo {product_name} paskyra laikinai sustabdyta",
|
||||
"body": "Sveiki, {username},\n\nTavo {product_name} paskyra laikinai sustabdyta dėl mūsų paslaugų teikimo sąlygų arba bendruomenės gairių pažeidimo.\n\nTrukmė: {durationHours, plural,\n one {# valanda} few {# valandos}\n many {# valandos} other {# valandų}\n}\nSustabdyta iki: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Priežastis: {reason}\n\n}\n}Šiuo laikotarpiu negalėsi pasiekti savo paskyros.\n\n{legalLinks, select,\n both {Peržiūrėk:\n- Paslaugų teikimo sąlygos: {termsUrl}\n- Bendruomenės gairės: {guidelinesUrl}\n\n}\n terms {Peržiūrėk:\n- Paslaugų teikimo sąlygos: {termsUrl}\n\n}\n guidelines {Peržiūrėk:\n- Bendruomenės gairės: {guidelinesUrl}\n\n}\n other {}\n}Jei manai, kad šis sprendimas neteisingas arba nepagrįstas, gali pateikti apeliaciją. {appeals_email, select, null {Susisiek su šios instancijos administratoriais ir aiškiai paaiškink, kodėl manai, kad sprendimas neteisingas.} other {Iš šio el. pašto adreso siųsk el. laišką adresu {appeals_email} ir aiškiai paaiškink, kodėl manai, kad sprendimas neteisingas.}} Peržiūrėsime tavo apeliaciją ir pranešime savo sprendimą.\n\n– {product_name} saugos komanda"
|
||||
"body": "Sveiki, {username},\n\nKaip prašei, numatėme ištrinti tavo {product_name} paskyrą {deletion_date}.\n\nJei nori išsaugoti paskyrą, prisijunk iki {deletion_date}. {safety_email, select, null {Jei to neprašei, susisiek su šios instancijos administratoriais.} other {Jei to neprašei, parašyk adresu {safety_email}.}}\n\n{product_name} komanda"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Dėkojame už tavo {product_name} paramą",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Priėmėme sprendimą dėl tavo {product_name} pranešimo",
|
||||
"body": "Sveiki,\n\nMūsų saugos komanda peržiūrėjo tavo pranešimą pagal Skaitmeninių paslaugų aktą (ID: {reportId}) ir priėmė sprendimą.{hasComment, select, yes {\n\nSaugos komandos atsakymas:\n{publicComment}} other {}}\n\nNesidalijame informacija apie veiksmus, kurių imtasi dėl kito asmens paskyros, nes tai yra jo asmens duomenys.\n\nJei nesutinki su šiuo sprendimu, per 60 dienų gali nemokamai pateikti apeliaciją. {appeals_email, select, null {Susisiek su šios instancijos administratoriais, nurodyk savo pranešimo ID ir paaiškink, kodėl manai, kad sprendimas neteisingas.} other {Iš šio el. pašto adreso siųsk el. laišką adresu {appeals_email}, nurodyk savo pranešimo ID ir paaiškink, kodėl manai, kad sprendimas neteisingas.}} Jei esi ES, ginčą taip pat gali perduoti sertifikuotai neteisminio ginčų sprendimo įstaigai. Visa tai neturi įtakos tavo teisei kreiptis į teismą.\n\n– {product_name} saugos komanda"
|
||||
"body": "Sveiki,\n\nPeržiūrėjome tavo pranešimą (ID: {reportId}).\n\n{outcome, select, removed {Pašalinome turinį, apie kurį pranešei, nes jis pažeidė mūsų taisykles.} restricted {Nustatėme, kad paskyra, apie kurią pranešei, pažeidė mūsų taisykles, ir ją apribojome. Neatskleidžiame, kokių veiksmų ėmėmės prieš kitą paskyrą, nes tai yra jos savininko asmens duomenys.} action_taken {Nustatėme, kad tai, apie ką pranešei, pažeidė mūsų taisykles, ir ėmėmės veiksmų. Neatskleidžiame, kokių veiksmų ėmėmės prieš kitą paskyrą, nes tai yra jos savininko asmens duomenys.} no_violation {Nustatėme, kad turinys ar paskyra, apie kuriuos pranešei, nepažeidžia mūsų taisyklių, todėl nieko nekeitėme.} other {Uždarėme tavo pranešimą.}}{hasComment, select, yes {\n\nMūsų komandos pastaba: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJei nesutinki, per 60 dienų parašyk adresu {appeals_email} ir nurodyk pranešimo ID. Jei esi ES, taip pat gali kreiptis į sertifikuotą neteisminio ginčų sprendimo įstaigą arba pateikti skundą savo šalies skaitmeninių paslaugų koordinatoriui.}}\n\n{product_name} komanda"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Patvirtink savo el. paštą DSA pranešimui pateikti",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Patvirtink savo {product_name} el. pašto adresą",
|
||||
"body": "Sveiki, {username},\n\nSpustelėk žemiau esančią nuorodą ir patvirtink savo {product_name} paskyros el. pašto adresą:\n\n{verifyUrl}\n\nJei {product_name} paskyros nekūrei, gali saugiai ignoruoti šį el. laišką.\n\nŠi nuoroda galioja 24 valandas.\n\n– {product_name} komanda"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Atšaukėme sprendimą dėl tavo {product_name} paskyros} other {Tavo {product_name} paskyros apribojimas baigėsi}}",
|
||||
"body": "Sveiki, {username},\n\n{outcome, select, reversed {{decision_id, select, null {Dar kartą peržiūrėjome sprendimą dėl tavo paskyros ir jį atšaukėme.} other {Dar kartą peržiūrėjome sprendimą {decision_id} ir jį atšaukėme.}} Atsiprašome už nepatogumus.} other {{decision_id, select, null {Panaikinome tavo paskyros apribojimą.} other {Panaikinome apribojimą, taikytą pagal sprendimą {decision_id}.}}}}{restores_access, select, yes { Vėl gali prisijungti ir naudotis {product_name}.} other {}}{statement, select, null {} other {\n\nMūsų komandos pastaba: {statement}}}{content_removed, select, yes {\n\nMūsų pašalinto turinio atkurti negalima.} other {}}\n\n{product_name} komanda"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Informacija apie tavo {product_name} paskyrą} other {{kind_label, select, warning {Įspėjimas dėl tavo {product_name} paskyros} suspension {Tavo {product_name} paskyra sustabdyta} ban {Tavo {product_name} paskyra visam laikui sustabdyta} closure {Tavo {product_name} paskyra uždaryta} feature_limit {Kai kurios tavo {product_name} paskyros funkcijos apribotos} content_removal {Pašalinome tavo {product_name} platformoje išsiųstas žinutes} profile_reset {Iš naujo nustatėme kai kurias tavo {product_name} profilio dalis} community_removal {Pašalinome tavo bendruomenę iš {product_name}} bot {Informacija apie tavo botą {product_name} platformoje} other {Informacija apie tavo {product_name} paskyrą}}}}",
|
||||
"body": "Sveiki, {username},\n\n{summary}\n\n{effects}\n\nKodėl\nTaisyklė: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nMūsų komandos pastaba: {statement}}}\n\nKaip priėmėme sprendimą\n{how_we_decided}{appeals_email, select, null {} other {\n\nKaip apskųsti sprendimą\nJei manai, kad suklydome, parašyk adresu {appeals_email} iki {appeal_deadline}. Jei gali, rašyk iš savo paskyros el. pašto adreso ir nurodyk sprendimo ID. Skundai nagrinėjami nemokamai, o kiekvieną jų peržiūri žmogus.{redress, select, null {} other {\n\n{redress}}}}}\n\nSprendimo ID: {decision_id}\n\n{product_name} komanda"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Privilegijos, gautos už išpirktą dovaną, pašalintos",
|
||||
"body": "Sveiki, {username},\n\nDovanos kodą, kurį išpirkai, iš pradžių apmokėjo kitas asmuo. Tas mokėjimas vėliau buvo atšauktas (lėšų grąžinimas).\n\nDėl to pašalinome privilegijas, kurios buvo pridėtos prie tavo paskyros, kai išpirkai dovaną.\n\n{support_email, select, null {Jei manai, kad tai klaida, susisiek su šios instancijos administratoriais ir nurodyk visą turimą informaciją apie dovanos kodą ir apie tai, kada jį išpirkai.} other {Jei manai, kad tai klaida, susisiek su mūsų palaikymo komanda ir nurodyk visą turimą informaciją apie dovanos kodą ir apie tai, kada jį išpirkai.}}\n\n– {product_name} komanda"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Tavo {product_name} pranešimas peržiūrėtas",
|
||||
"body": "Sveiki, {username},\n\nTavo pranešimą (ID: {reportId}) peržiūrėjo mūsų saugos komanda.{hasComment, select, yes {\n\nSaugos komandos atsakymas:\n{publicComment}} other {}}\n\nDėkojame, kad padedi užtikrinti {product_name} saugumą visiems. Visus pranešimus vertiname rimtai ir esame dėkingi už tavo indėlį į bendruomenę.\n\n{safety_email, select, null {Jei turi klausimų ar abejonių dėl šio sprendimo, susisiek su šios instancijos administratoriais.} other {Jei turi klausimų ar abejonių dėl šio sprendimo, rašyk adresu {safety_email}.}}\n\n– {product_name} saugos komanda"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Tavo {product_name} paskyra bus visam laikui ištrinta",
|
||||
"body": "Sveiki, {username},\n\nTavo {product_name} paskyrą numatyta visam laikui ištrinti.\n\nNumatytas ištrynimas: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Priežastis: {reason}\n\n}\n}Tai rimta priemonė. Tavo paskyros duomenys numatytą dieną bus visam laikui ištrinti.\n\nJei manai, kad šis sprendimas neteisingas, gali pateikti apeliaciją. {appeals_email, select, null {Susisiek su šios instancijos administratoriais.} other {Iš šio el. pašto adreso siųsk el. laišką adresu {appeals_email}.}}\n\n– {product_name} saugos komanda"
|
||||
"body": "Sveiki, {username},\n\nPeržiūrėjome tavo pranešimą (ID: {reportId}).\n\n{outcome, select, removed {Pašalinome turinį, apie kurį pranešei, nes jis pažeidė mūsų taisykles.} restricted {Nustatėme, kad paskyra, apie kurią pranešei, pažeidė mūsų taisykles, ir ją apribojome. Neatskleidžiame, kokių veiksmų ėmėmės prieš kitą paskyrą, nes tai yra jos savininko asmens duomenys.} action_taken {Nustatėme, kad tai, apie ką pranešei, pažeidė mūsų taisykles, ir ėmėmės veiksmų. Neatskleidžiame, kokių veiksmų ėmėmės prieš kitą paskyrą, nes tai yra jos savininko asmens duomenys.} no_violation {Nustatėme, kad turinys ar paskyra, apie kuriuos pranešei, nepažeidžia mūsų taisyklių, todėl nieko nekeitėme.} other {Uždarėme tavo pranešimą.}}{hasComment, select, yes {\n\nMūsų komandos pastaba: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJei nesutinki, per 60 dienų parašyk adresu {appeals_email} ir nurodyk pranešimo ID. Jei esi ES, taip pat gali kreiptis į sertifikuotą neteisminio ginčų sprendimo įstaigą arba pateikti skundą savo šalies skaitmeninių paslaugų koordinatoriui.}}\n\n{product_name} komanda"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Tavo {product_name} paskyros ištrynimas numatytas",
|
||||
"body": "Sveiki, {username},\n\nPaprašei ištrinti savo {product_name} paskyrą. Tavo paskyrą numatyta visam laikui ištrinti:\n\n{deletionDate, date, full} {deletionDate, time, short}\n\nJei to neprašei, prisijunk prie savo paskyros ir atšauk ištrynimą. Taip pat rekomenduojame pakeisti slaptažodį, kad apsaugotum savo paskyrą.\n\n– {product_name} komanda"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Tavo {product_name} paskyros sustabdymas panaikintas",
|
||||
"body": "Sveiki, {username},\n\nGeros naujienos: tavo {product_name} paskyros sustabdymas panaikintas.\n\n{reason, select,\n null {}\n other {Priežastis: {reason}\n\n}\n}Dabar gali vėl prisijungti ir toliau naudotis {product_name} kaip įprasta.\n\n– {product_name} saugos komanda"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Je {product_name}-account wordt verwijderd wegens inactiviteit",
|
||||
"body": "Hallo {username},\n\nJe {product_name}-account is al lange tijd inactief en staat daarom gepland voor permanente verwijdering op:\n\n{deletionDate, date, full} om {deletionDate, time, short}{reason, select, null {} other {\n\nReden: {reason}}}\n\n{safety_email, select, null {Als je je account wilt behouden, neem dan vóór die datum contact op met de beheerders van deze instantie.} other {Als je je account wilt behouden, neem dan vóór die datum vanaf dit e-mailadres contact op met {safety_email}.}}\n\n– Het team van {product_name}"
|
||||
"body": "Hallo {username},\n\nJe {product_name}-account is al lange tijd inactief, dus we hebben het ingepland voor verwijdering op {deletion_date}.\n\nLog vóór die datum in als je je account wilt houden. {support_email, select, null {Als je vragen hebt, neem dan contact op met de beheerders van deze instantie.} other {Als je vragen hebt, neem dan contact op met {support_email}.}}\n\nHet team van {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "De verwijdering van je {product_name}-account staat gepland",
|
||||
"body": "Hallo {username},\n\nZoals je hebt verzocht, staat je {product_name}-account gepland voor permanente verwijdering op:\n\n{deletionDate, date, full} om {deletionDate, time, short}{reason, select, null {} other {\n\nReden: {reason}}}\n\nJe account is tot die tijd vergrendeld. {safety_email, select, null {Als je dit niet hebt aangevraagd of je account wilt behouden, neem dan vóór die datum contact op met de beheerders van deze instantie.} other {Als je dit niet hebt aangevraagd of je account wilt behouden, neem dan vóór die datum vanaf dit e-mailadres contact op met {safety_email}.}}\n\n– Het team van {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Je {product_name}-account wordt permanent verwijderd",
|
||||
"body": "Hallo {username},\n\nJe {product_name}-account staat gepland voor permanente verwijdering vanwege schendingen van onze Gebruiksvoorwaarden of Communityrichtlijnen.\n\nGeplande verwijdering: {deletionDate, date, full} om {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Reden: {reason}\n\n}\n}Dit is een ingrijpende handhavingsmaatregel. Je accountgegevens worden permanent verwijderd op de geplande datum.\n\n{legalLinks, select,\n both {Bekijk ook:\n- Gebruiksvoorwaarden: {termsUrl}\n- Communityrichtlijnen: {guidelinesUrl}\n\n}\n terms {Bekijk ook:\n- Gebruiksvoorwaarden: {termsUrl}\n\n}\n guidelines {Bekijk ook:\n- Communityrichtlijnen: {guidelinesUrl}\n\n}\n other {}\n}Bezwaarprocedure:\nAls je van mening bent dat deze handhavingsbeslissing onjuist of ongerechtvaardigd was, heb je 60 dagen de tijd om bezwaar te maken. {appeals_email, select, null {Neem contact op met de beheerders van deze instantie.} other {Stuur een e-mail naar {appeals_email} vanaf dit e-mailadres.}}\n\nVermeld in je bezwaar:\n- Een duidelijke uitleg waarom je van mening bent dat de handhavingsbeslissing onjuist of ongerechtvaardigd was\n- Alle relevante bewijzen of context\n\nEen medewerker van het veiligheidsteam van {product_name} zal je bezwaar beoordelen en kan de geplande verwijdering uitstellen totdat een definitieve beslissing is genomen.\n\n– Het veiligheidsteam van {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Je {product_name}-account is tijdelijk geschorst",
|
||||
"body": "Hallo {username},\n\nJe {product_name}-account is tijdelijk geschorst wegens schending van onze Gebruiksvoorwaarden of Communityrichtlijnen.\n\nDuur: {durationHours, plural,\n =1 {1 uur}\n other {# uur}\n}\nGeschorst tot: {bannedUntil, date, full} om {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Reden: {reason}\n\n}\n}Gedurende deze periode heb je geen toegang tot je account.\n\n{legalLinks, select,\n both {Bekijk ook:\n- Gebruiksvoorwaarden: {termsUrl}\n- Communityrichtlijnen: {guidelinesUrl}\n\n}\n terms {Bekijk ook:\n- Gebruiksvoorwaarden: {termsUrl}\n\n}\n guidelines {Bekijk ook:\n- Communityrichtlijnen: {guidelinesUrl}\n\n}\n other {}\n}Als je van mening bent dat deze handhavingsbeslissing onjuist of ongerechtvaardigd was, kun je bezwaar maken. {appeals_email, select, null {Neem contact op met de beheerders van deze instantie en leg duidelijk uit waarom je van mening bent dat de beslissing onjuist was.} other {Stuur een e-mail naar {appeals_email} vanaf dit e-mailadres en leg duidelijk uit waarom je van mening bent dat de beslissing onjuist was.}} We zullen je bezwaar beoordelen en reageren met onze beslissing.\n\n– Het veiligheidsteam van {product_name}"
|
||||
"body": "Hallo {username},\n\nZoals je hebt gevraagd, hebben we je {product_name}-account ingepland voor verwijdering op {deletion_date}.\n\nLog vóór {deletion_date} in als je je account wilt houden. {safety_email, select, null {Als je hier niet om hebt gevraagd, neem dan contact op met de beheerders van deze instantie.} other {Als je hier niet om hebt gevraagd, neem dan contact op met {safety_email}.}}\n\nHet team van {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Bedankt voor je {product_name}-donatie",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "We hebben een beslissing genomen over je melding op {product_name}",
|
||||
"body": "Hallo,\n\nOns veiligheidsteam heeft je melding op grond van de Digital Services Act (ID: {reportId}) beoordeeld en een beslissing genomen.{hasComment, select, yes {\n\nReactie van het veiligheidsteam:\n{publicComment}} other {}}\n\nWe delen geen details over maatregelen tegen het account van iemand anders, omdat dat persoonsgegevens van die persoon zijn.\n\nAls je het niet eens bent met deze beslissing, kun je binnen 60 dagen kosteloos bezwaar maken. {appeals_email, select, null {Neem contact op met de beheerders van deze instantie, vermeld je meldings-ID en leg uit waarom je vindt dat de beslissing onjuist is.} other {Stuur een e-mail naar {appeals_email} vanaf dit e-mailadres, vermeld je meldings-ID en leg uit waarom je vindt dat de beslissing onjuist is.}} Als je in de EU bent, kun je het geschil ook voorleggen aan een gecertificeerd orgaan voor buitengerechtelijke geschillenbeslechting. Dit alles doet niets af aan je recht om naar de rechter te stappen.\n\n– Het veiligheidsteam van {product_name}"
|
||||
"body": "Hallo,\n\nWe hebben je melding (ID: {reportId}) beoordeeld.\n\n{outcome, select, removed {We hebben de inhoud die je hebt gemeld verwijderd, omdat die onze regels overtrad.} restricted {We hebben vastgesteld dat het account dat je hebt gemeld onze regels heeft overtreden, en we hebben het beperkt. We delen niet welke maatregel we tegen een ander account hebben genomen, omdat dat persoonsgegevens van die persoon zijn.} action_taken {We hebben vastgesteld dat wat je hebt gemeld onze regels heeft overtreden, en we hebben maatregelen genomen. We delen niet welke maatregel we tegen een ander account hebben genomen, omdat dat persoonsgegevens van die persoon zijn.} no_violation {We hebben vastgesteld dat de inhoud of het account dat je hebt gemeld onze regels niet overtreedt, dus we hebben niets veranderd.} other {We hebben je melding afgesloten.}}{hasComment, select, yes {\n\nOpmerking van ons team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nAls je het er niet mee eens bent, stuur dan binnen 60 dagen een e-mail naar {appeals_email} en vermeld het meldings-ID. Als je in de EU bent, kun je dit ook voorleggen aan een gecertificeerd orgaan voor buitengerechtelijke geschillenbeslechting of een klacht indienen bij de digitaledienstencoördinator in je land.}}\n\nHet team van {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Verifieer je e-mailadres voor een DSA-melding",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Verifieer je e-mailadres op {product_name}",
|
||||
"body": "Hallo {username},\n\nVerifieer het e-mailadres van je {product_name}-account door op de onderstaande link te klikken:\n\n{verifyUrl}\n\nAls je geen {product_name}-account hebt aangemaakt, kun je deze e-mail veilig negeren.\n\nDeze link is 24 uur geldig.\n\n– Het team van {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {We hebben een beslissing over je {product_name}-account teruggedraaid} other {Een beperking op je {product_name}-account is afgelopen}}",
|
||||
"body": "Hallo {username},\n\n{outcome, select, reversed {{decision_id, select, null {We hebben een beslissing over je account opnieuw bekeken en teruggedraaid.} other {We hebben beslissing {decision_id} opnieuw bekeken en teruggedraaid.}} Sorry voor het ongemak.} other {{decision_id, select, null {We hebben de beperking op je account opgeheven.} other {We hebben de beperking uit beslissing {decision_id} opgeheven.}}}}{restores_access, select, yes { Je kunt weer inloggen en {product_name} gebruiken.} other {}}{statement, select, null {} other {\n\nOpmerking van ons team: {statement}}}{content_removed, select, yes {\n\nInhoud die we hebben verwijderd, kan niet worden hersteld.} other {}}\n\nHet team van {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Een update over je {product_name}-account} other {{kind_label, select, warning {Een waarschuwing over je {product_name}-account} suspension {Je {product_name}-account is geschorst} ban {Je {product_name}-account is verbannen} closure {Je {product_name}-account is gesloten} feature_limit {Sommige functies van je {product_name}-account zijn beperkt} content_removal {We hebben berichten verwijderd die je op {product_name} hebt gestuurd} profile_reset {We hebben delen van je {product_name}-profiel teruggezet} community_removal {We hebben je community op {product_name} verwijderd} bot {Een update over je bot op {product_name}} other {Een update over je {product_name}-account}}}}",
|
||||
"body": "Hallo {username},\n\n{summary}\n\n{effects}\n\nWaarom\nRegel: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nOpmerking van ons team: {statement}}}\n\nZo hebben we besloten\n{how_we_decided}{appeals_email, select, null {} other {\n\nZo maak je bezwaar\nAls je denkt dat we een fout hebben gemaakt, stuur dan uiterlijk {appeal_deadline} een e-mail naar {appeals_email}. Stuur die zo mogelijk vanaf het e-mailadres van je account en vermeld het beslissings-ID. Bezwaar maken is gratis en elk bezwaar wordt door een persoon beoordeeld.{redress, select, null {} other {\n\n{redress}}}}}\n\nBeslissings-ID: {decision_id}\n\nHet team van {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "De voordelen van je ingewisselde cadeau zijn verwijderd",
|
||||
"body": "Hallo {username},\n\nEen cadeaucode die je hebt ingewisseld, is oorspronkelijk door iemand anders betaald. Die betaling is sindsdien teruggedraaid (een terugboeking).\n\nHierdoor hebben we de voordelen verwijderd die aan je account zijn toegevoegd toen je het cadeau inwisselde.\n\n{support_email, select, null {Als je denkt dat dit een fout is, neem dan contact op met de beheerders van deze instantie en vermeld alle details die je hebt over de cadeaucode en wanneer je deze hebt ingewisseld.} other {Als je denkt dat dit een fout is, neem dan contact op met ons supportteam en vermeld alle details die je hebt over de cadeaucode en wanneer je deze hebt ingewisseld.}}\n\n– Het team van {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Je melding op {product_name} is beoordeeld",
|
||||
"body": "Hallo {username},\n\nJe melding (ID: {reportId}) is beoordeeld door ons veiligheidsteam.{hasComment, select, yes {\n\nReactie van het veiligheidsteam:\n{publicComment}} other {}}\n\nBedankt dat je helpt {product_name} veilig te houden voor iedereen. We nemen alle meldingen serieus en waarderen je bijdrage aan de community.\n\n{safety_email, select, null {Als je vragen of opmerkingen hebt over deze uitkomst, neem dan contact op met de beheerders van deze instantie.} other {Als je vragen of opmerkingen hebt over deze uitkomst, neem dan contact op met {safety_email}.}}\n\n– Het veiligheidsteam van {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Je {product_name}-account wordt permanent verwijderd",
|
||||
"body": "Hallo {username},\n\nJe {product_name}-account staat gepland voor permanente verwijdering.\n\nGeplande verwijdering: {deletionDate, date, full} om {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Reden: {reason}\n\n}\n}Dit is een ingrijpende handhavingsmaatregel. Je accountgegevens worden permanent verwijderd op de geplande datum.\n\nAls je van mening bent dat deze handhavingsbeslissing onjuist was, kun je bezwaar maken. {appeals_email, select, null {Neem contact op met de beheerders van deze instantie.} other {Stuur een e-mail naar {appeals_email} vanaf dit e-mailadres.}}\n\n– Het veiligheidsteam van {product_name}"
|
||||
"body": "Hallo {username},\n\nWe hebben je melding (ID: {reportId}) beoordeeld.\n\n{outcome, select, removed {We hebben de inhoud die je hebt gemeld verwijderd, omdat die onze regels overtrad.} restricted {We hebben vastgesteld dat het account dat je hebt gemeld onze regels heeft overtreden, en we hebben het beperkt. We delen niet welke maatregel we tegen een ander account hebben genomen, omdat dat persoonsgegevens van die persoon zijn.} action_taken {We hebben vastgesteld dat wat je hebt gemeld onze regels heeft overtreden, en we hebben maatregelen genomen. We delen niet welke maatregel we tegen een ander account hebben genomen, omdat dat persoonsgegevens van die persoon zijn.} no_violation {We hebben vastgesteld dat de inhoud of het account dat je hebt gemeld onze regels niet overtreedt, dus we hebben niets veranderd.} other {We hebben je melding afgesloten.}}{hasComment, select, yes {\n\nOpmerking van ons team: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nAls je het er niet mee eens bent, stuur dan binnen 60 dagen een e-mail naar {appeals_email} en vermeld het meldings-ID. Als je in de EU bent, kun je dit ook voorleggen aan een gecertificeerd orgaan voor buitengerechtelijke geschillenbeslechting of een klacht indienen bij de digitaledienstencoördinator in je land.}}\n\nHet team van {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "De verwijdering van je {product_name}-account staat gepland",
|
||||
"body": "Hallo {username},\n\nJe hebt verzocht om je {product_name}-account te verwijderen. Je account staat gepland voor permanente verwijdering op:\n\n{deletionDate, date, full} om {deletionDate, time, short}\n\nAls je dit niet hebt aangevraagd, log dan in op je account om de verwijdering te annuleren. We raden ook aan om je wachtwoord te wijzigen om je account te beveiligen.\n\n– Het team van {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "De schorsing van je {product_name}-account is opgeheven",
|
||||
"body": "Hallo {username},\n\nGoed nieuws: de schorsing van je {product_name}-account is opgeheven.\n\n{reason, select,\n null {}\n other {Reden: {reason}\n\n}\n}Je kunt nu weer inloggen en {product_name} normaal blijven gebruiken.\n\n– Het veiligheidsteam van {product_name}"
|
||||
}
|
||||
}
|
||||
+14
-22
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "{product_name}-kontoen din vil bli slettet på grunn av inaktivitet",
|
||||
"body": "Hei {username},\n\n{product_name}-kontoen din har vært inaktiv lenge, så den er planlagt for permanent sletting:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsak: {reason}}}\n\n{safety_email, select, null {Hvis du vil beholde kontoen din, kan du kontakte administratorene av denne instansen før denne datoen.} other {Hvis du vil beholde kontoen din, kan du kontakte {safety_email} fra denne e-postadressen før denne datoen.}}\n\n– {product_name}-teamet"
|
||||
"body": "Hei {username},\n\n{product_name}-kontoen din har vært inaktiv lenge, så vi har planlagt sletting av kontoen den {deletion_date}.\n\nHvis du vil beholde kontoen din, logger du på før den datoen. {support_email, select, null {Hvis du har spørsmål, kontakt administratorene av denne instansen.} other {Hvis du har spørsmål, kontakt {support_email}.}}\n\n{product_name}-teamet"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Slettingen av {product_name}-kontoen din er planlagt",
|
||||
"body": "Hei {username},\n\nSom du ba om, er {product_name}-kontoen din planlagt for permanent sletting:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}{reason, select, null {} other {\n\nÅrsak: {reason}}}\n\nKontoen din er låst frem til da. {safety_email, select, null {Hvis du ikke ba om dette, eller hvis du vil beholde kontoen din, kan du kontakte administratorene av denne instansen før denne datoen.} other {Hvis du ikke ba om dette, eller hvis du vil beholde kontoen din, kan du kontakte {safety_email} fra denne e-postadressen før denne datoen.}}\n\n– {product_name}-teamet"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "{product_name}-kontoen din blir permanent slettet",
|
||||
"body": "Hei {username},\n\nVi har planlagt permanent sletting av {product_name}-kontoen din på grunn av brudd på våre vilkår for bruk eller retningslinjer for fellesskapet.\n\nPlanlagt sletting: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsak: {reason}\n\n}\n}Dette er et alvorlig tiltak. Kontodataene dine blir permanent slettet på den planlagte datoen.\n\n{legalLinks, select,\n both {Les gjennom:\n- Vilkår for bruk: {termsUrl}\n- Retningslinjer for fellesskapet: {guidelinesUrl}\n\n}\n terms {Les gjennom:\n- Vilkår for bruk: {termsUrl}\n\n}\n guidelines {Les gjennom:\n- Retningslinjer for fellesskapet: {guidelinesUrl}\n\n}\n other {}\n}Klageprosessen:\nHvis du mener avgjørelsen var feil eller uberettiget, har du 60 dager på deg til å sende inn en klage. {appeals_email, select, null {Kontakt administratorene av denne instansen.} other {Send en e-post til {appeals_email} fra denne e-postadressen.}}\n\nI klagen din:\n- Forklar tydelig hvorfor du mener avgjørelsen var feil eller uberettiget\n- Legg ved relevante bevis eller opplysninger\n\nEt medlem av sikkerhetsteamet hos {product_name} vil vurdere klagen din og kan utsette slettingen til en endelig avgjørelse er tatt.\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "{product_name}-kontoen din er midlertidig suspendert",
|
||||
"body": "Hei {username},\n\n{product_name}-kontoen din er midlertidig suspendert på grunn av brudd på våre vilkår for bruk eller retningslinjer for fellesskapet.\n\nVarighet: {durationHours, plural,\n =1 {1 time}\n other {# timer}\n}\nSuspendert til: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Årsak: {reason}\n\n}\n}Du har ikke tilgang til kontoen din i denne perioden.\n\n{legalLinks, select,\n both {Les gjennom:\n- Vilkår for bruk: {termsUrl}\n- Retningslinjer for fellesskapet: {guidelinesUrl}\n\n}\n terms {Les gjennom:\n- Vilkår for bruk: {termsUrl}\n\n}\n guidelines {Les gjennom:\n- Retningslinjer for fellesskapet: {guidelinesUrl}\n\n}\n other {}\n}Hvis du mener avgjørelsen var feil eller uberettiget, kan du sende inn en klage. {appeals_email, select, null {Kontakt administratorene av denne instansen, og forklar tydelig hvorfor du mener avgjørelsen var feil.} other {Send en e-post til {appeals_email} fra denne e-postadressen, og forklar tydelig hvorfor du mener avgjørelsen var feil.}} Vi vil vurdere klagen din og gi deg beskjed om avgjørelsen vår.\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
"body": "Hei {username},\n\nSom du ba om, har vi planlagt sletting av {product_name}-kontoen din den {deletion_date}.\n\nHvis du vil beholde kontoen din, logger du på før {deletion_date}. {safety_email, select, null {Hvis du ikke ba om dette, kontakt administratorene av denne instansen.} other {Hvis du ikke ba om dette, kontakt {safety_email}.}}\n\n{product_name}-teamet"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Takk for bidraget ditt til {product_name}",
|
||||
@@ -28,8 +20,8 @@
|
||||
"body": "Hei,\n\nKlikk på lenken nedenfor for å få tilgang til giverportalen din:\n\n{manageUrl}\n\nI portalen kan du administrere abonnementer, laste ned fakturaer og se bidragshistorikken din.\n\nDenne lenken utløper {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke ba om denne lenken, kan du trygt ignorere denne e-posten.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Vi har tatt en avgjørelse om {product_name}-rapporten din",
|
||||
"body": "Hei,\n\nSikkerhetsteamet vårt har gjennomgått rapporten din i henhold til Digital Services Act (ID: {reportId}) og tatt en avgjørelse.{hasComment, select, yes {\n\nSvar fra sikkerhetsteamet:\n{publicComment}} other {}}\n\nVi deler ikke detaljer om tiltak mot en annen persons konto, fordi det er personopplysningene til den personen.\n\nHvis du er uenig i avgjørelsen, kan du klage gratis innen 60 dager. {appeals_email, select, null {Kontakt administratorene av denne instansen, oppgi rapport-ID-en din, og forklar hvorfor du mener avgjørelsen er feil.} other {Send en e-post til {appeals_email} fra denne e-postadressen, oppgi rapport-ID-en din, og forklar hvorfor du mener avgjørelsen er feil.}} Hvis du befinner deg i EU, kan du også bringe tvisten inn for et sertifisert organ for utenrettslig tvisteløsning. Ingenting av dette påvirker retten din til å gå til domstolene.\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
"subject": "Vi har tatt en avgjørelse om rapporten din på {product_name}",
|
||||
"body": "Hei,\n\nVi har gått gjennom rapporten din (ID: {reportId}).\n\n{outcome, select, removed {Vi har fjernet innholdet du rapporterte, fordi det brøt reglene våre.} restricted {Vi fant ut at kontoen du rapporterte, brøt reglene våre, og vi har begrenset den. Vi oppgir ikke hvilke tiltak vi har iverksatt mot en annen konto, fordi det er personopplysningene til den personen.} action_taken {Vi fant ut at det du rapporterte, brøt reglene våre, og vi har grepet inn. Vi oppgir ikke hvilke tiltak vi har iverksatt mot en annen konto, fordi det er personopplysningene til den personen.} no_violation {Vi fant ut at innholdet eller kontoen du rapporterte, ikke bryter reglene våre, så vi har ikke gjort noe.} other {Vi har lukket rapporten din.}}{hasComment, select, yes {\n\nMerknad fra teamet vårt: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nHvis du er uenig, send en e-post til {appeals_email} innen 60 dager og oppgi rapport-ID-en. Hvis du er i EU, kan du også bringe saken inn for et sertifisert organ for utenrettslig tvisteløsning eller klage til koordinatoren for digitale tjenester i landet ditt.}}\n\n{product_name}-teamet"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Bekreft e-postadressen din for en DSA-rapport",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Bekreft e-postadressen din for {product_name}",
|
||||
"body": "Hei {username},\n\nBekreft e-postadressen til {product_name}-kontoen din ved å klikke på lenken nedenfor:\n\n{verifyUrl}\n\nHvis du ikke opprettet en {product_name}-konto, kan du trygt se bort fra denne e-posten.\n\nDenne lenken er gyldig i 24 timer.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Vi har omgjort en avgjørelse om {product_name}-kontoen din} other {En begrensning på {product_name}-kontoen din er opphevet}}",
|
||||
"body": "Hei {username},\n\n{outcome, select, reversed {{decision_id, select, null {Vi har sett på en avgjørelse om kontoen din på nytt og omgjort den.} other {Vi har sett på avgjørelse {decision_id} på nytt og omgjort den.}} Vi beklager bryderiet.} other {{decision_id, select, null {Vi har opphevet begrensningen på kontoen din.} other {Vi har opphevet begrensningen fra avgjørelse {decision_id}.}}}}{restores_access, select, yes { Du kan logge på og bruke {product_name} igjen.} other {}}{statement, select, null {} other {\n\nMerknad fra teamet vårt: {statement}}}{content_removed, select, yes {\n\nInnhold vi har fjernet, kan ikke gjenopprettes.} other {}}\n\n{product_name}-teamet"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Informasjon om {product_name}-kontoen din} other {{kind_label, select, warning {En advarsel om {product_name}-kontoen din} suspension {{product_name}-kontoen din er suspendert} ban {{product_name}-kontoen din er utestengt} closure {{product_name}-kontoen din er stengt} feature_limit {Noen funksjoner er begrenset på {product_name}-kontoen din} content_removal {Vi har fjernet meldinger du sendte på {product_name}} profile_reset {Vi har tilbakestilt deler av {product_name}-profilen din} community_removal {Vi har fjernet fellesskapet ditt på {product_name}} bot {Informasjon om boten din på {product_name}} other {Informasjon om {product_name}-kontoen din}}}}",
|
||||
"body": "Hei {username},\n\n{summary}\n\n{effects}\n\nHvorfor\nRegel: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nMerknad fra teamet vårt: {statement}}}\n\nSlik tok vi avgjørelsen\n{how_we_decided}{appeals_email, select, null {} other {\n\nSlik klager du\nHvis du mener at vi tok feil, send en e-post til {appeals_email} innen {appeal_deadline}. Skriv fra e-postadressen på kontoen din hvis du kan, og oppgi avgjørelses-ID-en. Det er gratis å klage, og en person går gjennom hver klage.{redress, select, null {} other {\n\n{redress}}}}}\n\nAvgjørelses-ID: {decision_id}\n\n{product_name}-teamet"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Fordelene fra gaven du løste inn, er fjernet",
|
||||
"body": "Hei {username},\n\nDu løste inn en gavekode som opprinnelig var betalt av noen andre. Betalingen har senere blitt tilbakeført.\n\nDerfor har vi fjernet fordelene som ble lagt til kontoen din da du løste inn gaven.\n\n{support_email, select, null {Hvis du mener dette er en feil, kan du kontakte administratorene av denne instansen. Ta med opplysningene du har om gavekoden og når du løste den inn.} other {Hvis du mener dette er en feil, kan du kontakte kundestøtten vår. Ta med opplysningene du har om gavekoden og når du løste den inn.}}\n\n– {product_name}-teamet"
|
||||
@@ -84,19 +84,11 @@
|
||||
"body": "Hei,\n\nVi har mottatt rapporten din i henhold til Digital Services Act om {targetKind, select, message {en melding} user {en konto} guild {et fellesskap} other {innhold}} på {product_name}.\n\nRapport-ID: {reportId}\n\nSikkerhetsteamet vårt går gjennom rapporten din, og vi sender deg en e-post på denne adressen når vi har tatt en avgjørelse. Ta vare på denne e-posten.\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "{product_name}-rapporten din er gjennomgått",
|
||||
"body": "Hei {username},\n\nRapporten din (ID: {reportId}) er gjennomgått av sikkerhetsteamet vårt.{hasComment, select, yes {\n\nSvar fra sikkerhetsteamet:\n{publicComment}} other {}}\n\nTakk for at du bidrar til å holde {product_name} trygt for alle. Vi tar alle rapporter på alvor og setter pris på bidraget ditt til fellesskapet.\n\n{safety_email, select, null {Hvis du har spørsmål eller er bekymret for utfallet, kan du kontakte administratorene av denne instansen.} other {Hvis du har spørsmål eller er bekymret for utfallet, kan du kontakte {safety_email}.}}\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "{product_name}-kontoen din blir permanent slettet",
|
||||
"body": "Hei {username},\n\n{product_name}-kontoen din er planlagt for permanent sletting.\n\nPlanlagt sletting: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsak: {reason}\n\n}\n}Dette er et alvorlig tiltak. Kontodataene dine blir permanent slettet på den planlagte datoen.\n\nHvis du mener avgjørelsen var feil, kan du sende inn en klage. {appeals_email, select, null {Kontakt administratorene av denne instansen.} other {Send en e-post til {appeals_email} fra denne e-postadressen.}}\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
"subject": "Rapporten din på {product_name} er gjennomgått",
|
||||
"body": "Hei {username},\n\nVi har gått gjennom rapporten din (ID: {reportId}).\n\n{outcome, select, removed {Vi har fjernet innholdet du rapporterte, fordi det brøt reglene våre.} restricted {Vi fant ut at kontoen du rapporterte, brøt reglene våre, og vi har begrenset den. Vi oppgir ikke hvilke tiltak vi har iverksatt mot en annen konto, fordi det er personopplysningene til den personen.} action_taken {Vi fant ut at det du rapporterte, brøt reglene våre, og vi har grepet inn. Vi oppgir ikke hvilke tiltak vi har iverksatt mot en annen konto, fordi det er personopplysningene til den personen.} no_violation {Vi fant ut at innholdet eller kontoen du rapporterte, ikke bryter reglene våre, så vi har ikke gjort noe.} other {Vi har lukket rapporten din.}}{hasComment, select, yes {\n\nMerknad fra teamet vårt: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nHvis du er uenig, send en e-post til {appeals_email} innen 60 dager og oppgi rapport-ID-en. Hvis du er i EU, kan du også bringe saken inn for et sertifisert organ for utenrettslig tvisteløsning eller klage til koordinatoren for digitale tjenester i landet ditt.}}\n\n{product_name}-teamet"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Slettingen av {product_name}-kontoen din er planlagt",
|
||||
"body": "Hei {username},\n\nDu ba om å slette {product_name}-kontoen din. Kontoen din er planlagt for permanent sletting:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}\n\nHvis du ikke ba om dette, kan du logge inn på kontoen din for å avbryte slettingen. Vi anbefaler også å endre passordet ditt for å sikre kontoen din.\n\n– {product_name}-teamet"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Suspensjonen av {product_name}-kontoen din er opphevet",
|
||||
"body": "Hei {username},\n\nGode nyheter! Suspensjonen av {product_name}-kontoen din er opphevet.\n\n{reason, select,\n null {}\n other {Årsak: {reason}\n\n}\n}Du kan nå logge inn igjen og fortsette å bruke {product_name} som normalt.\n\n– Sikkerhetsteamet hos {product_name}"
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -5,19 +5,11 @@
|
||||
},
|
||||
"account_deletion_scheduled_inactivity": {
|
||||
"subject": "Twoje konto {product_name} zostanie usunięte z powodu braku aktywności",
|
||||
"body": "Witaj {username},\n\nTwoje konto {product_name} od dawna jest nieaktywne, dlatego zostało zaplanowane do trwałego usunięcia. Termin usunięcia:\n\n{deletionDate, date, full} o {deletionDate, time, short}{reason, select, null {} other {\n\nPowód: {reason}}}\n\n{safety_email, select, null {Jeśli chcesz zachować konto, skontaktuj się z administratorami tej instancji przed tym terminem.} other {Jeśli chcesz zachować konto, skontaktuj się z {safety_email} z tego adresu e-mail przed tym terminem.}}\n\n– Zespół {product_name}"
|
||||
"body": "Witaj {username},\n\nTwoje konto {product_name} od dawna jest nieaktywne, dlatego zaplanowaliśmy jego usunięcie na dzień {deletion_date}.\n\nAby zachować konto, zaloguj się przed tym dniem. {support_email, select, null {Jeśli masz pytania, skontaktuj się z administratorami tej instancji.} other {Jeśli masz pytania, napisz na adres {support_email}.}}\n\nZespół {product_name}"
|
||||
},
|
||||
"account_deletion_scheduled_requested": {
|
||||
"subject": "Usunięcie Twojego konta {product_name} zostało zaplanowane",
|
||||
"body": "Witaj {username},\n\nZgodnie z Twoją prośbą zaplanowaliśmy trwałe usunięcie konta {product_name}. Termin usunięcia:\n\n{deletionDate, date, full} o {deletionDate, time, short}{reason, select, null {} other {\n\nPowód: {reason}}}\n\nDo tego czasu Twoje konto jest zablokowane. {safety_email, select, null {Jeśli nie prosiłeś o to lub chcesz zachować konto, skontaktuj się z administratorami tej instancji przed tym terminem.} other {Jeśli nie prosiłeś o to lub chcesz zachować konto, skontaktuj się z {safety_email} z tego adresu e-mail przed tym terminem.}}\n\n– Zespół {product_name}"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Twoje konto {product_name} zostanie trwale usunięte",
|
||||
"body": "Witaj {username},\n\nZaplanowaliśmy trwałe usunięcie Twojego konta {product_name} z powodu naruszenia naszych warunków korzystania z usługi lub zasad społeczności.\n\nZaplanowane usunięcie: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Powód: {reason}\n\n}\n}Jest to poważna sankcja. Dane Twojego konta zostaną usunięte na stałe w zaplanowanym terminie.\n\n{legalLinks, select,\n both {Sprawdź:\n- Warunki korzystania z usługi: {termsUrl}\n- Zasady społeczności: {guidelinesUrl}\n\n}\n terms {Sprawdź:\n- Warunki korzystania z usługi: {termsUrl}\n\n}\n guidelines {Sprawdź:\n- Zasady społeczności: {guidelinesUrl}\n\n}\n other {}\n}Proces odwoławczy:\nJeśli uważasz, że ta decyzja była błędna lub nieuzasadniona, masz 60 dni na złożenie odwołania. {appeals_email, select, null {Skontaktuj się z administratorami tej instancji.} other {Wyślij e-mail na adres {appeals_email} z tego adresu e-mail.}}\n\nW swoim odwołaniu:\n- Wyjaśnij jasno, dlaczego uważasz, że decyzja była błędna lub nieuzasadniona\n- Przedstaw wszelkie istotne dowody lub kontekst\n\nCzłonek zespołu ds. bezpieczeństwa {product_name} rozpatrzy Twoje odwołanie i może wstrzymać usunięcie do czasu podjęcia ostatecznej decyzji.\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Twoje konto {product_name} zostało tymczasowo zawieszone",
|
||||
"body": "Witaj {username},\n\nTwoje konto {product_name} zostało tymczasowo zawieszone za naruszenie naszych warunków korzystania z usługi lub zasad społeczności.\n\nCzas trwania: {durationHours, plural,\n one {1 godzina} few {# godziny}\n many {# godzin} other {# godziny}\n}\nZawieszone do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Powód: {reason}\n\n}\n}W tym czasie nie będziesz mieć dostępu do swojego konta.\n\n{legalLinks, select,\n both {Sprawdź:\n- Warunki korzystania z usługi: {termsUrl}\n- Zasady społeczności: {guidelinesUrl}\n\n}\n terms {Sprawdź:\n- Warunki korzystania z usługi: {termsUrl}\n\n}\n guidelines {Sprawdź:\n- Zasady społeczności: {guidelinesUrl}\n\n}\n other {}\n}Jeśli uważasz, że ta decyzja była błędna lub nieuzasadniona, możesz złożyć odwołanie. {appeals_email, select, null {Skontaktuj się z administratorami tej instancji i jasno wyjaśnij, dlaczego uważasz, że decyzja była błędna.} other {Wyślij e-mail na adres {appeals_email} z tego adresu e-mail i jasno wyjaśnij, dlaczego uważasz, że decyzja była błędna.}} Rozpatrzymy Twoje odwołanie i poinformujemy Cię o naszej decyzji.\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
"body": "Witaj {username},\n\nZgodnie z Twoją prośbą zaplanowaliśmy usunięcie Twojego konta {product_name} na dzień {deletion_date}.\n\nAby zachować konto, zaloguj się przed dniem {deletion_date}. {safety_email, select, null {Jeśli ta prośba nie pochodzi od Ciebie, skontaktuj się z administratorami tej instancji.} other {Jeśli ta prośba nie pochodzi od Ciebie, napisz na adres {safety_email}.}}\n\nZespół {product_name}"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Dziękujemy za darowiznę dla {product_name}",
|
||||
@@ -29,7 +21,7 @@
|
||||
},
|
||||
"dsa_report_resolved": {
|
||||
"subject": "Podjęliśmy decyzję w sprawie Twojego zgłoszenia w {product_name}",
|
||||
"body": "Witaj,\n\nNasz zespół ds. bezpieczeństwa rozpatrzył Twoje zgłoszenie w ramach aktu o usługach cyfrowych (ID: {reportId}) i podjął decyzję.{hasComment, select, yes {\n\nOdpowiedź od zespołu ds. bezpieczeństwa:\n{publicComment}} other {}}\n\nNie udostępniamy szczegółów działań podjętych wobec konta innej osoby, ponieważ są to jej dane osobowe.\n\nJeśli nie zgadzasz się z tą decyzją, możesz bezpłatnie złożyć odwołanie w ciągu 60 dni. {appeals_email, select, null {Skontaktuj się z administratorami tej instancji, podaj ID zgłoszenia i wyjaśnij, dlaczego uważasz, że decyzja jest błędna.} other {Wyślij e-mail na adres {appeals_email} z tego adresu e-mail, podaj ID zgłoszenia i wyjaśnij, dlaczego uważasz, że decyzja jest błędna.}} Jeśli przebywasz w UE, możesz też skierować spór do certyfikowanego organu pozasądowego rozstrzygania sporów. Nic z tego nie wpływa na Twoje prawo do skierowania sprawy do sądu.\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
"body": "Dzień dobry,\n\nRozpatrzyliśmy Twoje zgłoszenie (ID: {reportId}).\n\n{outcome, select, removed {Usunęliśmy zgłoszone treści, ponieważ naruszały nasze zasady.} restricted {Ustaliliśmy, że zgłoszone konto naruszyło nasze zasady, i nałożyliśmy na nie ograniczenia. Nie informujemy, jakie działania podjęliśmy wobec innego konta, ponieważ są to dane osobowe jego właściciela.} action_taken {Ustaliliśmy, że zgłoszona treść lub konto naruszyły nasze zasady, i podjęliśmy działania. Nie informujemy, jakie działania podjęliśmy wobec innego konta, ponieważ są to dane osobowe jego właściciela.} no_violation {Ustaliliśmy, że zgłoszone treści lub konto nie naruszają naszych zasad, więc pozostawiliśmy je bez zmian.} other {Zamknęliśmy Twoje zgłoszenie.}}{hasComment, select, yes {\n\nKomentarz naszego zespołu: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJeśli się nie zgadzasz, napisz na adres {appeals_email} w ciągu 60 dni i podaj identyfikator zgłoszenia. Jeśli jesteś w UE, możesz też skierować sprawę do certyfikowanego organu pozasądowego rozstrzygania sporów lub złożyć skargę do koordynatora ds. usług cyfrowych w swoim kraju.}}\n\nZespół {product_name}"
|
||||
},
|
||||
"dsa_report_verification": {
|
||||
"subject": "Zweryfikuj adres e-mail na potrzeby zgłoszenia DSA",
|
||||
@@ -51,6 +43,14 @@
|
||||
"subject": "Zweryfikuj adres e-mail w {product_name}",
|
||||
"body": "Witaj {username},\n\nZweryfikuj adres e-mail swojego konta {product_name}, klikając poniższy link:\n\n{verifyUrl}\n\nJeśli nie utworzyłeś konta {product_name}, możesz bezpiecznie zignorować tę wiadomość e-mail.\n\nTen link jest ważny przez 24 godziny.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"enforcement_ended": {
|
||||
"subject": "{outcome, select, reversed {Cofnęliśmy decyzję dotyczącą Twojego konta {product_name}} other {Ograniczenie Twojego konta {product_name} przestało obowiązywać}}",
|
||||
"body": "Witaj {username},\n\n{outcome, select, reversed {{decision_id, select, null {Ponownie przeanalizowaliśmy decyzję dotyczącą Twojego konta i ją cofnęliśmy.} other {Ponownie przeanalizowaliśmy decyzję {decision_id} i ją cofnęliśmy.}} Przepraszamy za kłopot.} other {{decision_id, select, null {Zdjęliśmy ograniczenie z Twojego konta.} other {Zdjęliśmy ograniczenie nałożone decyzją {decision_id}.}}}}{restores_access, select, yes { Możesz znowu logować się i korzystać z {product_name}.} other {}}{statement, select, null {} other {\n\nKomentarz naszego zespołu: {statement}}}{content_removed, select, yes {\n\nUsuniętych przez nas treści nie można przywrócić.} other {}}\n\nZespół {product_name}"
|
||||
},
|
||||
"enforcement_notice": {
|
||||
"subject": "{neutral, select, yes {Informacja o Twoim koncie {product_name}} other {{kind_label, select, warning {Ostrzeżenie dotyczące Twojego konta {product_name}} suspension {Twoje konto {product_name} zostało zawieszone} ban {Twoje konto {product_name} zostało zablokowane} closure {Twoje konto {product_name} zostało zamknięte} feature_limit {Niektóre funkcje Twojego konta {product_name} są ograniczone} content_removal {Usunęliśmy Twoje wiadomości w {product_name}} profile_reset {Zresetowaliśmy część Twojego profilu {product_name}} community_removal {Usunęliśmy Twoją społeczność w {product_name}} bot {Informacja o Twoim bocie w {product_name}} other {Informacja o Twoim koncie {product_name}}}}}",
|
||||
"body": "Witaj {username},\n\n{summary}\n\n{effects}\n\nDlaczego\nZasada: {rule_title}{rule_url, select, null {} other { ({rule_url})}}\n{facts}{statement, select, null {} other {\n\nKomentarz naszego zespołu: {statement}}}\n\nJak podjęliśmy decyzję\n{how_we_decided}{appeals_email, select, null {} other {\n\nJak się odwołać\nJeśli uważasz, że popełniliśmy błąd, napisz na adres {appeals_email} do {appeal_deadline}. Jeśli możesz, napisz z adresu e-mail przypisanego do Twojego konta i podaj identyfikator decyzji. Odwołania są bezpłatne, a każde z nich rozpatruje człowiek.{redress, select, null {} other {\n\n{redress}}}}}\n\nIdentyfikator decyzji: {decision_id}\n\nZespół {product_name}"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Usunięto bonusy z wykorzystanego prezentu",
|
||||
"body": "Witaj {username},\n\nKod podarunkowy wykorzystany na Twoim koncie został pierwotnie opłacony przez inną osobę. Płatność ta została następnie cofnięta (chargeback).\n\nZ tego powodu usunęliśmy bonusy, które zostały dodane do Twojego konta po wykorzystaniu prezentu.\n\n{support_email, select, null {Jeśli uważasz, że to pomyłka, skontaktuj się z administratorami tej instancji i podaj wszelkie szczegóły dotyczące kodu podarunkowego oraz daty jego wykorzystania.} other {Jeśli uważasz, że to pomyłka, skontaktuj się z naszym zespołem wsparcia i podaj wszelkie szczegóły dotyczące kodu podarunkowego oraz daty jego wykorzystania.}}\n\n– Zespół {product_name}"
|
||||
@@ -85,18 +85,10 @@
|
||||
},
|
||||
"report_resolved": {
|
||||
"subject": "Twoje zgłoszenie w {product_name} zostało rozpatrzone",
|
||||
"body": "Witaj {username},\n\nTwoje zgłoszenie (ID: {reportId}) zostało rozpatrzone przez nasz zespół ds. bezpieczeństwa.{hasComment, select, yes {\n\nOdpowiedź od zespołu ds. bezpieczeństwa:\n{publicComment}} other {}}\n\nDziękujemy za pomoc w utrzymaniu bezpieczeństwa {product_name} dla wszystkich. Traktujemy wszystkie zgłoszenia poważnie i doceniamy Twój wkład w społeczność.\n\n{safety_email, select, null {Jeśli masz pytania lub wątpliwości dotyczące tej decyzji, skontaktuj się z administratorami tej instancji.} other {Jeśli masz pytania lub wątpliwości dotyczące tej decyzji, skontaktuj się z {safety_email}.}}\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Twoje konto {product_name} zostanie trwale usunięte",
|
||||
"body": "Witaj {username},\n\nZaplanowaliśmy trwałe usunięcie Twojego konta {product_name}.\n\nZaplanowane usunięcie: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Powód: {reason}\n\n}\n}Jest to poważna sankcja. Dane Twojego konta zostaną usunięte na stałe w zaplanowanym terminie.\n\nJeśli uważasz, że ta decyzja była błędna, możesz złożyć odwołanie. {appeals_email, select, null {Skontaktuj się z administratorami tej instancji.} other {Wyślij e-mail na adres {appeals_email} z tego adresu e-mail.}}\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
"body": "Witaj {username},\n\nRozpatrzyliśmy Twoje zgłoszenie (ID: {reportId}).\n\n{outcome, select, removed {Usunęliśmy zgłoszone treści, ponieważ naruszały nasze zasady.} restricted {Ustaliliśmy, że zgłoszone konto naruszyło nasze zasady, i nałożyliśmy na nie ograniczenia. Nie informujemy, jakie działania podjęliśmy wobec innego konta, ponieważ są to dane osobowe jego właściciela.} action_taken {Ustaliliśmy, że zgłoszona treść lub konto naruszyły nasze zasady, i podjęliśmy działania. Nie informujemy, jakie działania podjęliśmy wobec innego konta, ponieważ są to dane osobowe jego właściciela.} no_violation {Ustaliliśmy, że zgłoszone treści lub konto nie naruszają naszych zasad, więc pozostawiliśmy je bez zmian.} other {Zamknęliśmy Twoje zgłoszenie.}}{hasComment, select, yes {\n\nKomentarz naszego zespołu: {publicComment}} other {}}{appeals_email, select, null {} other {\n\nJeśli się nie zgadzasz, napisz na adres {appeals_email} w ciągu 60 dni i podaj identyfikator zgłoszenia. Jeśli jesteś w UE, możesz też skierować sprawę do certyfikowanego organu pozasądowego rozstrzygania sporów lub złożyć skargę do koordynatora ds. usług cyfrowych w swoim kraju.}}\n\nZespół {product_name}"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Usunięcie Twojego konta {product_name} zostało zaplanowane",
|
||||
"body": "Witaj {username},\n\nOtrzymaliśmy Twoją prośbę o usunięcie konta {product_name}. Termin trwałego usunięcia konta:\n\n{deletionDate, date, full} o {deletionDate, time, short}\n\nJeśli nie prosiłeś o to, zaloguj się na swoje konto, aby anulować usunięcie. Zalecamy również zmianę hasła w celu zabezpieczenia konta.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Zawieszenie Twojego konta {product_name} zostało zniesione",
|
||||
"body": "Witaj {username},\n\nDobre wieści: zawieszenie Twojego konta {product_name} zostało zniesione.\n\n{reason, select,\n null {}\n other {Powód: {reason}\n\n}\n}Możesz teraz ponownie się zalogować i kontynuować korzystanie z {product_name} jak zwykle.\n\n– Zespół ds. bezpieczeństwa {product_name}"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 857 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user