Keep recovery codes on screen and make two-factor optional.
Some checks failed
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 4m10s
Some checks failed
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 4m10s
Admins can replace missed codes or turn two-factor off without a forced enrollment on every login. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
32b28a93f3
commit
2e22a7c8e0
|
|
@ -55,8 +55,8 @@ SMTP_STARTTLS=
|
||||||
|
|
||||||
# Produktion: false. true nur für eine bewusst leere Erstinstallation.
|
# Produktion: false. true nur für eine bewusst leere Erstinstallation.
|
||||||
AUTO_ADMIN_FIRST_USER=false
|
AUTO_ADMIN_FIRST_USER=false
|
||||||
# Plattform-Admins müssen TOTP einrichten. 0 schaltet die Pflicht ab.
|
# true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig.
|
||||||
REQUIRE_ADMIN_TOTP=true
|
REQUIRE_ADMIN_TOTP=false
|
||||||
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
|
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
|
||||||
# MEDIA_TOKEN_SECRET=
|
# MEDIA_TOKEN_SECRET=
|
||||||
ADMIN_BOOTSTRAP_EMAILS=
|
ADMIN_BOOTSTRAP_EMAILS=
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,9 @@ limiter = Limiter(key_func=_client_ip)
|
||||||
|
|
||||||
|
|
||||||
def _admin_totp_required() -> bool:
|
def _admin_totp_required() -> bool:
|
||||||
|
"""Nur wenn REQUIRE_ADMIN_TOTP ausdrücklich an ist. Standard: der Nutzer entscheidet selbst."""
|
||||||
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
|
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
|
||||||
if raw in ("1", "true", "yes"):
|
return raw in ("1", "true", "yes")
|
||||||
return True
|
|
||||||
if raw in ("0", "false", "no"):
|
|
||||||
return False
|
|
||||||
return (os.getenv("ENVIRONMENT") or "development").strip().lower() in ("production", "prod")
|
|
||||||
|
|
||||||
|
|
||||||
def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]:
|
def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]:
|
||||||
|
|
@ -394,16 +391,7 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa
|
||||||
""",
|
""",
|
||||||
(prof["totp_pending_secret"], pid),
|
(prof["totp_pending_secret"], pid),
|
||||||
)
|
)
|
||||||
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,))
|
plain_codes = _replace_recovery_codes(cur, pid)
|
||||||
plain_codes = generate_recovery_codes()
|
|
||||||
for code in plain_codes:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO profile_recovery_codes (profile_id, code_hash)
|
|
||||||
VALUES (%s, %s)
|
|
||||||
""",
|
|
||||||
(pid, hash_recovery_code(code)),
|
|
||||||
)
|
|
||||||
prof = dict(prof)
|
prof = dict(prof)
|
||||||
prof["totp_enabled"] = True
|
prof["totp_enabled"] = True
|
||||||
prof["totp_secret"] = None
|
prof["totp_secret"] = None
|
||||||
|
|
@ -414,6 +402,38 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_recovery_codes(cur, profile_id: int) -> list[str]:
|
||||||
|
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (profile_id,))
|
||||||
|
plain_codes = generate_recovery_codes()
|
||||||
|
for code in plain_codes:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profile_recovery_codes (profile_id, code_hash)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(profile_id, hash_recovery_code(code)),
|
||||||
|
)
|
||||||
|
return plain_codes
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/recovery-codes")
|
||||||
|
def regenerate_recovery_codes(req: TotpDisableRequest, session: dict = Depends(require_auth)):
|
||||||
|
"""Neue Codes ausgeben. Die bisherigen werden ungültig. Passwort und aktueller App-Code sind Pflicht."""
|
||||||
|
pid = int(session["profile_id"])
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
|
prof = cur.fetchone()
|
||||||
|
if not prof or not prof.get("totp_enabled"):
|
||||||
|
raise HTTPException(400, "Zwei-Faktor ist nicht aktiv")
|
||||||
|
if not verify_pin(req.current_password, prof["pin_hash"]):
|
||||||
|
raise HTTPException(400, "Aktuelles Passwort ist falsch")
|
||||||
|
if not verify_totp(prof.get("totp_secret"), req.code):
|
||||||
|
raise HTTPException(400, "Code aus der App stimmt nicht")
|
||||||
|
plain_codes = _replace_recovery_codes(cur, pid)
|
||||||
|
return {"recovery_codes": plain_codes}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/totp/disable")
|
@router.post("/totp/disable")
|
||||||
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
|
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
|
||||||
pid = int(session["profile_id"])
|
pid = int(session["profile_id"])
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.239"
|
APP_VERSION = "0.8.240"
|
||||||
BUILD_DATE = "2026-10-12"
|
BUILD_DATE = "2026-10-13"
|
||||||
DB_SCHEMA_VERSION = "20261012096"
|
DB_SCHEMA_VERSION = "20261012096"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
"auth": "1.3.0", # Login-Sperre, TOTP für Plattform-Admins, Medien-Grant statt Sitzungstoken in URLs
|
"auth": "1.3.1", # TOTP freiwillig; Wiederherstellungscodes bleiben bis zur Bestätigung stehen
|
||||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
||||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||||
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
||||||
|
|
@ -53,6 +53,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.240",
|
||||||
|
"date": "2026-10-13",
|
||||||
|
"changes": [
|
||||||
|
"Zwei-Faktor ist freiwillig. REQUIRE_ADMIN_TOTP erzwingt es nur noch, wenn die Variable ausdrücklich true ist.",
|
||||||
|
"Wiederherstellungscodes bleiben sichtbar, bis sie bestätigt sind. Neue Codes lassen sich in den Einstellungen erzeugen.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.239",
|
"version": "0.8.239",
|
||||||
"date": "2026-10-12",
|
"date": "2026-10-12",
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ services:
|
||||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
||||||
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
||||||
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
|
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
|
||||||
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}"
|
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
|
||||||
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
|
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
|
||||||
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
||||||
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ services:
|
||||||
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
|
||||||
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
|
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
|
||||||
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
|
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
|
||||||
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}"
|
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
|
||||||
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
|
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
|
||||||
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
|
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
|
||||||
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
|
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ export default function TotpSetupCard({ onEnabled }) {
|
||||||
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
||||||
setSecret('')
|
setSecret('')
|
||||||
setCode('')
|
setCode('')
|
||||||
if (onEnabled) await onEnabled(data)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Code nicht akzeptiert')
|
setError(err.message || 'Code nicht akzeptiert')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -85,15 +84,33 @@ export default function TotpSetupCard({ onEnabled }) {
|
||||||
{recoveryCodes.length > 0 ? (
|
{recoveryCodes.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: '0.875rem' }}>
|
<p style={{ fontSize: '0.875rem' }}>
|
||||||
Diese Wiederherstellungscodes jetzt sichern. Sie werden nur dieses eine Mal angezeigt.
|
Diese acht Codes jetzt sichern. Die Seite bleibt stehen, bis du unten bestätigst.
|
||||||
|
Ein Neuladen löscht die Anzeige. Neue Codes kannst du danach in den Einstellungen erzeugen.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<textarea
|
||||||
{recoveryCodes.map((item) => (
|
readOnly
|
||||||
<li key={item} style={{ fontFamily: 'ui-monospace, monospace' }}>
|
rows={8}
|
||||||
{item}
|
className="form-input"
|
||||||
</li>
|
value={recoveryCodes.join('\n')}
|
||||||
))}
|
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
|
||||||
</ul>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
onClick={() => navigator.clipboard?.writeText(recoveryCodes.join('\n'))}
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (onEnabled) onEnabled()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ich habe die Codes gespeichert
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ function AccountSettingsPage() {
|
||||||
const [disablePw, setDisablePw] = useState('')
|
const [disablePw, setDisablePw] = useState('')
|
||||||
const [disableCode, setDisableCode] = useState('')
|
const [disableCode, setDisableCode] = useState('')
|
||||||
const [disablingTotp, setDisablingTotp] = useState(false)
|
const [disablingTotp, setDisablingTotp] = useState(false)
|
||||||
|
const [freshRecoveryCodes, setFreshRecoveryCodes] = useState([])
|
||||||
const [newPw1, setNewPw1] = useState('')
|
const [newPw1, setNewPw1] = useState('')
|
||||||
const [newPw2, setNewPw2] = useState('')
|
const [newPw2, setNewPw2] = useState('')
|
||||||
const [savingPw, setSavingPw] = useState(false)
|
const [savingPw, setSavingPw] = useState(false)
|
||||||
|
|
@ -473,24 +474,41 @@ function AccountSettingsPage() {
|
||||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
||||||
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
|
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
|
||||||
|
Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
|
||||||
</p>
|
</p>
|
||||||
{user?.role === 'admin' || user?.role === 'superadmin' ? (
|
{freshRecoveryCodes.length > 0 ? (
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
Für Plattform-Admins bleibt das so.
|
<textarea
|
||||||
</p>
|
readOnly
|
||||||
) : (
|
rows={8}
|
||||||
|
className="form-input"
|
||||||
|
value={freshRecoveryCodes.join('\n')}
|
||||||
|
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
onClick={() => navigator.clipboard?.writeText(freshRecoveryCodes.join('\n'))}
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setFreshRecoveryCodes([])}>
|
||||||
|
Ich habe die Codes gespeichert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<form
|
<form
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDisablingTotp(true)
|
setDisablingTotp(true)
|
||||||
try {
|
try {
|
||||||
await api.disableTotp(disablePw, disableCode.trim())
|
const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
|
||||||
setDisablePw('')
|
setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
||||||
setDisableCode('')
|
setDisableCode('')
|
||||||
showOk('Zwei-Faktor ist aus.')
|
showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
|
||||||
await checkAuth()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErr(err.message || 'Abschalten fehlgeschlagen')
|
showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
|
||||||
} finally {
|
} finally {
|
||||||
setDisablingTotp(false)
|
setDisablingTotp(false)
|
||||||
}
|
}
|
||||||
|
|
@ -501,14 +519,35 @@ function AccountSettingsPage() {
|
||||||
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
|
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||||
<label className="form-label" htmlFor="totp-off-code">Code oder Wiederherstellungscode</label>
|
<label className="form-label" htmlFor="totp-off-code">Aktueller Code aus der App</label>
|
||||||
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
|
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-secondary" disabled={disablingTotp}>
|
<button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
|
||||||
{disablingTotp ? 'Wird gespeichert…' : 'Zwei-Faktor ausschalten'}
|
{disablingTotp ? 'Bitte warten…' : 'Neue Wiederherstellungscodes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={disablingTotp}
|
||||||
|
onClick={async () => {
|
||||||
|
setDisablingTotp(true)
|
||||||
|
try {
|
||||||
|
await api.disableTotp(disablePw, disableCode.trim())
|
||||||
|
setDisablePw('')
|
||||||
|
setDisableCode('')
|
||||||
|
setFreshRecoveryCodes([])
|
||||||
|
showOk('Zwei-Faktor ist aus. Den Eintrag in der Authenticator-App kannst du löschen.')
|
||||||
|
await checkAuth()
|
||||||
|
} catch (err) {
|
||||||
|
showErr(err.message || 'Abschalten fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setDisablingTotp(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zwei-Faktor ausschalten
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TotpSetupCard onEnabled={() => checkAuth()} />
|
<TotpSetupCard onEnabled={() => checkAuth()} />
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@ export async function confirmTotp(code) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function regenerateRecoveryCodes(currentPassword, code) {
|
||||||
|
return request('/api/auth/totp/recovery-codes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ current_password: currentPassword, code }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function disableTotp(currentPassword, code) {
|
export async function disableTotp(currentPassword, code) {
|
||||||
return request('/api/auth/totp/disable', {
|
return request('/api/auth/totp/disable', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -1032,6 +1039,7 @@ export const api = {
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
setupTotp,
|
setupTotp,
|
||||||
confirmTotp,
|
confirmTotp,
|
||||||
|
regenerateRecoveryCodes,
|
||||||
disableTotp,
|
disableTotp,
|
||||||
issueMediaGrant,
|
issueMediaGrant,
|
||||||
register,
|
register,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user