Compare commits

...

2 Commits

Author SHA1 Message Date
Davide Scaini 1b4f0318e7 feat: self-service password reset via email (Phase 4)
- email column on users (migration-safe ALTER TABLE)
- email_reset_tokens table (1h TTL, single-use)
- smtp.py: send via STARTTLS, config from CLI/env vars
- POST /api/auth/request-reset — sends reset link, always 200 (no email leak)
- POST /api/auth/reset-password-token — consumes email token
- GET/POST /api/me/email — users can register/update their email
- reset-password page: email form primary, admin code form as toggle,
  token form shown automatically when ?token= is in URL
- CLI: --smtp-host/port/user/password/from (BINCIO_SMTP_* env vars)
2026-06-03 16:03:08 +02:00
Davide Scaini 5b6146792e fix: decode_session must validate RS256 tokens (not just HS256) 2026-06-03 15:53:10 +02:00
7 changed files with 415 additions and 71 deletions
+20
View File
@@ -98,6 +98,11 @@ def show_secret_cmd(data_dir: str) -> None:
type=click.Path(), help="Path to RS256 PEM private key. Enables OIDC endpoints.") type=click.Path(), help="Path to RS256 PEM private key. Enables OIDC endpoints.")
@click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER", @click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER",
help="OIDC issuer URL (e.g. https://bincio.org). Required when --oidc-private-key-file is set.") help="OIDC issuer URL (e.g. https://bincio.org). Required when --oidc-private-key-file is set.")
@click.option("--smtp-host", default=None, envvar="BINCIO_SMTP_HOST", help="SMTP server hostname.")
@click.option("--smtp-port", default=587, envvar="BINCIO_SMTP_PORT", type=int, show_default=True)
@click.option("--smtp-user", default=None, envvar="BINCIO_SMTP_USER", help="SMTP login username.")
@click.option("--smtp-password", default=None, envvar="BINCIO_SMTP_PASSWORD", help="SMTP password / app password.")
@click.option("--smtp-from", default=None, envvar="BINCIO_SMTP_FROM", help="From address (defaults to --smtp-user).")
def serve_cmd( def serve_cmd(
data_dir: str, data_dir: str,
host: str, host: str,
@@ -105,6 +110,11 @@ def serve_cmd(
jwt_secret: str | None, jwt_secret: str | None,
oidc_private_key_file: str | None, oidc_private_key_file: str | None,
oidc_issuer: str | None, oidc_issuer: str | None,
smtp_host: str | None,
smtp_port: int,
smtp_user: str | None,
smtp_password: str | None,
smtp_from: str | None,
) -> None: ) -> None:
"""Start the bincio-auth API server.""" """Start the bincio-auth API server."""
import uvicorn import uvicorn
@@ -140,11 +150,21 @@ def serve_cmd(
if not deps.oidc_issuer: if not deps.oidc_issuer:
raise click.UsageError("--oidc-issuer is required when --oidc-private-key-file is set") raise click.UsageError("--oidc-issuer is required when --oidc-private-key-file is set")
if smtp_host and smtp_user and smtp_password:
from bincio.auth import smtp as _smtp
_smtp.host = smtp_host
_smtp.port = smtp_port
_smtp.user = smtp_user
_smtp.password = smtp_password
_smtp.sender = smtp_from or smtp_user
console.print("[bold]bincio-auth[/bold]") console.print("[bold]bincio-auth[/bold]")
console.print(f" Data: [cyan]{dd}[/cyan]") console.print(f" Data: [cyan]{dd}[/cyan]")
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
if deps.oidc_issuer: if deps.oidc_issuer:
console.print(f" OIDC: [cyan]{deps.oidc_issuer}[/cyan]") console.print(f" OIDC: [cyan]{deps.oidc_issuer}[/cyan]")
if smtp_host:
console.print(f" SMTP: [cyan]{smtp_user}[/cyan] via [cyan]{smtp_host}:{smtp_port}[/cyan]")
console.print() console.print()
uvicorn.run(srv.app, host=host, port=port, log_level="info") uvicorn.run(srv.app, host=host, port=port, log_level="info")
+73 -8
View File
@@ -2,12 +2,13 @@
Schema Schema
------ ------
users — registered accounts (handle, hashed password, access flags) users — registered accounts (handle, hashed password, access flags)
sessions — active login sessions (opaque token → handle, expiry) sessions — active login sessions (opaque token → handle, expiry)
invites — invite codes (who created, who used, grants_activity flag) invites — invite codes (who created, who used, grants_activity flag)
reset_codes — password reset tokens (admin-issued, 24 h TTL) reset_codes — password reset tokens (admin-issued, 24 h TTL)
settings — instance-wide key/value config email_reset_tokens — self-service password reset tokens (emailed, 1 h TTL)
user_prefs — per-user key/value preferences settings — instance-wide key/value config
user_prefs — per-user key/value preferences
All timestamps are Unix integers (UTC). All timestamps are Unix integers (UTC).
Passwords are hashed with bcrypt. Passwords are hashed with bcrypt.
@@ -97,16 +98,26 @@ CREATE TABLE IF NOT EXISTS oauth2_codes (
used_at INTEGER used_at INTEGER
); );
CREATE TABLE IF NOT EXISTS email_reset_tokens (
token TEXT PRIMARY KEY,
handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle); CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle);
CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by); CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by);
CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle); CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle);
CREATE INDEX IF NOT EXISTS oauth2_codes_client ON oauth2_codes(client_id); CREATE INDEX IF NOT EXISTS oauth2_codes_client ON oauth2_codes(client_id);
CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle); CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle);
CREATE INDEX IF NOT EXISTS email_reset_tokens_handle ON email_reset_tokens(handle);
""" """
_SESSION_DAYS = 30 _SESSION_DAYS = 30
_INVITE_LENGTH = 8 _INVITE_LENGTH = 8
_RESET_CODE_TTL_S = 24 * 3600 # 24 hours _RESET_CODE_TTL_S = 24 * 3600 # 24 hours
_EMAIL_RESET_TTL_S = 3600 # 1 hour
# ── Data classes ────────────────────────────────────────────────────────────── # ── Data classes ──────────────────────────────────────────────────────────────
@@ -145,10 +156,11 @@ def open_db(data_dir: Path) -> sqlite3.Connection:
db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
db.executescript(_SCHEMA) db.executescript(_SCHEMA)
# Migration: add suspended column to pre-existing databases
cols = {r[1] for r in db.execute("PRAGMA table_info(users)")} cols = {r[1] for r in db.execute("PRAGMA table_info(users)")}
if "suspended" not in cols: if "suspended" not in cols:
db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0") db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0")
if "email" not in cols:
db.execute("ALTER TABLE users ADD COLUMN email TEXT DEFAULT NULL")
db.commit() db.commit()
return db return db
@@ -514,6 +526,59 @@ def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool:
return True return True
# ── User email ───────────────────────────────────────────────────────────────
def get_user_by_email(db: sqlite3.Connection, email: str) -> User | None:
row = db.execute(
"SELECT * FROM users WHERE lower(email) = lower(?)", (email,)
).fetchone()
if not row:
return None
return User(
handle=row["handle"],
display_name=row["display_name"],
is_admin=bool(row["is_admin"]),
wiki_access=bool(row["wiki_access"]),
activity_access=bool(row["activity_access"]),
suspended=bool(row["suspended"]),
created_at=row["created_at"],
)
def set_user_email(db: sqlite3.Connection, handle: str, email: str | None) -> None:
db.execute("UPDATE users SET email = ? WHERE handle = ?", (email, handle))
db.commit()
def get_user_email(db: sqlite3.Connection, handle: str) -> str | None:
row = db.execute("SELECT email FROM users WHERE handle = ?", (handle,)).fetchone()
return row["email"] if row else None
# ── Email reset tokens ────────────────────────────────────────────────────────
def create_email_reset_token(db: sqlite3.Connection, handle: str) -> str:
now = int(time.time())
db.execute("DELETE FROM email_reset_tokens WHERE handle = ? AND used_at IS NULL", (handle,))
token = secrets.token_urlsafe(32)
db.execute(
"INSERT INTO email_reset_tokens (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, handle, now, now + _EMAIL_RESET_TTL_S),
)
db.commit()
return token
def get_email_reset_token(db: sqlite3.Connection, token: str) -> dict | None:
row = db.execute("SELECT * FROM email_reset_tokens WHERE token = ?", (token,)).fetchone()
return dict(row) if row else None
def use_email_reset_token(db: sqlite3.Connection, token: str) -> None:
db.execute("UPDATE email_reset_tokens SET used_at = ? WHERE token = ?", (int(time.time()), token))
db.commit()
# ── OAuth2 clients ──────────────────────────────────────────────────────────── # ── OAuth2 clients ────────────────────────────────────────────────────────────
import json as _json import json as _json
+22 -5
View File
@@ -111,11 +111,28 @@ def _check_rate_limit(
# ── Auth dependency functions ───────────────────────────────────────────────── # ── Auth dependency functions ─────────────────────────────────────────────────
def _decode_session(token: str) -> User | None: def _decode_session(token: str) -> User | None:
"""Decode JWT and return the live User, or None if invalid/suspended.""" """Decode JWT (RS256 or HS256) and return the live User, or None if invalid/suspended."""
try: payload = None
payload = decode_token(token, jwt_secret)
except _jwt.PyJWTError: if oidc_private_key_pem:
return None try:
from cryptography.hazmat.primitives.serialization import load_pem_private_key
priv = load_pem_private_key(oidc_private_key_pem.encode(), password=None)
payload = _jwt.decode(
token,
priv.public_key(),
algorithms=["RS256"],
options={"verify_aud": False},
)
except Exception:
pass
if payload is None:
try:
payload = decode_token(token, jwt_secret)
except _jwt.PyJWTError:
return None
handle = payload.get("sub") handle = payload.get("sub")
if not handle: if not handle:
return None return None
+13
View File
@@ -41,5 +41,18 @@ class CreateInviteRequest(BaseModel):
grants_activity: bool = Field(default=False) grants_activity: bool = Field(default=False)
class RequestResetRequest(BaseModel):
email: str
class ResetPasswordTokenRequest(BaseModel):
token: str
password: str = Field(..., min_length=8, description="New password (min 8 chars)")
class SetEmailRequest(BaseModel):
email: str
class GenericResponse(BaseModel): class GenericResponse(BaseModel):
ok: bool = True ok: bool = True
+67
View File
@@ -2,19 +2,28 @@
from __future__ import annotations from __future__ import annotations
import time
from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from bincio.auth import deps from bincio.auth import deps
from bincio.auth import smtp as _smtp
from bincio.auth.db import ( from bincio.auth.db import (
authenticate, authenticate,
change_password, change_password,
count_activity_users, count_activity_users,
count_wiki_users, count_wiki_users,
create_email_reset_token,
create_user, create_user,
get_email_reset_token,
get_invite, get_invite,
get_setting, get_setting,
get_user, get_user,
get_user_by_email,
get_user_email,
set_user_email,
use_email_reset_token,
use_invite, use_invite,
use_reset_code, use_reset_code,
) )
@@ -24,7 +33,10 @@ from bincio.auth.models import (
LoginResponse, LoginResponse,
RegisterRequest, RegisterRequest,
RegisterResponse, RegisterResponse,
RequestResetRequest,
ResetPasswordRequest, ResetPasswordRequest,
ResetPasswordTokenRequest,
SetEmailRequest,
) )
router = APIRouter() router = APIRouter()
@@ -111,6 +123,61 @@ async def reset_password(body: ResetPasswordRequest) -> JSONResponse:
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
# ── Self-service password reset (email) ──────────────────────────────────────
@router.post("/api/auth/request-reset", response_model=GenericResponse)
async def request_reset(body: RequestResetRequest, request: Request) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT,
"Too many attempts. Try again later.")
email = body.email.strip().lower()
db = deps._get_db()
user = get_user_by_email(db, email)
if user and not user.suspended and _smtp.configured():
token = create_email_reset_token(db, user.handle)
issuer = deps.oidc_issuer.rstrip("/") if deps.oidc_issuer else ""
reset_url = f"{issuer}/reset-password/?token={token}"
try:
_smtp.send_reset_email(email, user.handle, reset_url)
except Exception:
pass # never reveal SMTP errors to the caller
# Always return 200 so the response doesn't leak whether an email is registered
return JSONResponse({"ok": True})
@router.post("/api/auth/reset-password-token", response_model=GenericResponse)
async def reset_password_via_token(body: ResetPasswordTokenRequest) -> JSONResponse:
if len(body.password) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
db = deps._get_db()
rec = get_email_reset_token(db, body.token)
if not rec or rec["used_at"] is not None or rec["expires_at"] < int(time.time()):
raise HTTPException(400, "Invalid or expired reset link")
use_email_reset_token(db, body.token)
change_password(db, rec["handle"], body.password)
return JSONResponse({"ok": True})
@router.post("/api/me/email", response_model=GenericResponse)
async def set_email(
body: SetEmailRequest,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
email = body.email.strip().lower() or None
set_user_email(deps._get_db(), user.handle, email)
return JSONResponse({"ok": True})
@router.get("/api/me/email")
async def get_email(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
email = get_user_email(deps._get_db(), user.handle)
return JSONResponse({"email": email})
# ── Registration ────────────────────────────────────────────────────────────── # ── Registration ──────────────────────────────────────────────────────────────
@router.post("/api/register", response_model=RegisterResponse) @router.post("/api/register", response_model=RegisterResponse)
+48
View File
@@ -0,0 +1,48 @@
"""SMTP email sending for bincio-auth.
Config is set by the CLI before uvicorn starts (same pattern as deps.py).
"""
from __future__ import annotations
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
# ── Config (set by CLI) ───────────────────────────────────────────────────────
host: str = ""
port: int = 587
user: str = ""
password: str = ""
sender: str = "" # "From" address; defaults to user
def configured() -> bool:
return bool(host and user and password)
def send_reset_email(to_email: str, handle: str, reset_url: str) -> None:
if not configured():
raise RuntimeError("SMTP not configured")
from_addr = sender or user
msg = MIMEMultipart("alternative")
msg["Subject"] = "Reset your Bincio password"
msg["From"] = f"Bincio <{from_addr}>"
msg["To"] = to_email
body = (
f"Hi @{handle},\n\n"
f"Someone requested a password reset for your Bincio account.\n"
f"Click the link below to set a new password — it expires in 1 hour:\n\n"
f" {reset_url}\n\n"
f"If you didn't request this, you can ignore this email.\n\n"
f"— Bincio"
)
msg.attach(MIMEText(body, "plain"))
with smtplib.SMTP(host, port, timeout=10) as s:
s.starttls()
s.login(user, password)
s.send_message(msg)
+172 -58
View File
@@ -3,43 +3,99 @@ import Base from '../../layouts/Base.astro';
--- ---
<Base title="Reset password — Bincio" public={true}> <Base title="Reset password — Bincio" public={true}>
<div class="max-w-sm mx-auto mt-12"> <div class="max-w-sm mx-auto mt-12">
<h1 class="text-2xl font-bold text-white mb-2 text-center">Reset password</h1> <h1 class="text-2xl font-bold text-white mb-6 text-center">Reset password</h1>
<p class="text-zinc-500 text-sm text-center mb-2">
Enter the reset code you received from the admin.
</p>
<p class="text-zinc-600 text-xs text-center mb-6">
Don't have a code? Contact the instance admin — they can generate one from the admin panel. Codes expire after 24 hours.
</p>
<form id="reset-form" class="space-y-4"> <!-- ── Email request form (shown when no token in URL) ── -->
<div> <div id="request-section">
<label class="block text-sm text-zinc-400 mb-1" for="code">Reset code</label> <p class="text-zinc-500 text-sm text-center mb-6">
<input id="code" name="code" type="text" autocomplete="off" Enter your email address and we'll send you a reset link.
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white font-mono uppercase tracking-widest placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="XXXXXXXX" maxlength="8" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="handle">Handle</label>
<input id="handle" name="handle" type="text" autocomplete="username"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="your handle" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="password">New password</label>
<input id="password" name="password" type="password" autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white focus:outline-none focus:border-[--accent]"
minlength="8" required />
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
</div>
<p id="reset-error" class="text-red-400 text-sm hidden"></p>
<p id="reset-ok" class="text-green-400 text-sm hidden">
Password updated. <a href="/login/" class="underline">Sign in</a>
</p> </p>
<button type="submit" <form id="request-form" class="space-y-4">
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity"> <div>
Set new password <label class="block text-sm text-zinc-400 mb-1" for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="you@example.com" required />
</div>
<p id="request-error" class="text-red-400 text-sm hidden"></p>
<p id="request-ok" class="text-green-400 text-sm hidden">
If that email is registered you'll receive a reset link shortly.
</p>
<button type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
Send reset link
</button>
</form>
<div class="mt-8 pt-6 border-t border-zinc-800">
<p class="text-zinc-600 text-xs text-center mb-4">Have an admin-issued code instead?</p>
<button id="show-code-form"
class="w-full py-2 rounded-lg border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 text-sm transition-colors">
Use admin reset code
</button>
</div>
</div>
<!-- ── Admin code form (hidden by default, shown on toggle or ?code= param) ── -->
<div id="code-section" class="hidden">
<p class="text-zinc-500 text-sm text-center mb-6">
Enter the reset code you received from the admin.
</p>
<form id="code-form" class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1" for="code">Reset code</label>
<input id="code" name="code" type="text" autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white font-mono uppercase tracking-widest placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="XXXXXXXX" maxlength="8" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="handle">Handle</label>
<input id="handle" name="handle" type="text" autocomplete="username"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="your handle" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="code-password">New password</label>
<input id="code-password" name="password" type="password" autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white focus:outline-none focus:border-[--accent]"
minlength="8" required />
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
</div>
<p id="code-error" class="text-red-400 text-sm hidden"></p>
<p id="code-ok" class="text-green-400 text-sm hidden">
Password updated. <a href="/login/" class="underline">Sign in</a>
</p>
<button type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
Set new password
</button>
</form>
<button id="show-email-form" class="mt-4 w-full text-zinc-600 hover:text-zinc-400 text-xs transition-colors">
← Back to email reset
</button> </button>
</form> </div>
<!-- ── Token form (shown when ?token= is in URL) ── -->
<div id="token-section" class="hidden">
<p class="text-zinc-500 text-sm text-center mb-6">Choose a new password for your account.</p>
<form id="token-form" class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1" for="token-password">New password</label>
<input id="token-password" name="password" type="password" autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white focus:outline-none focus:border-[--accent]"
minlength="8" required />
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
</div>
<p id="token-error" class="text-red-400 text-sm hidden"></p>
<p id="token-ok" class="text-green-400 text-sm hidden">
Password updated. <a href="/login/" class="underline">Sign in</a>
</p>
<button type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
Set new password
</button>
</form>
</div>
<p class="text-center text-zinc-500 text-sm mt-6"> <p class="text-center text-zinc-500 text-sm mt-6">
<a href="/login/" class="text-[--accent] hover:underline">Back to sign in</a> <a href="/login/" class="text-[--accent] hover:underline">Back to sign in</a>
@@ -48,38 +104,47 @@ import Base from '../../layouts/Base.astro';
</Base> </Base>
<script> <script>
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const cp = params.get('code'); const token = params.get('token');
const hp = params.get('handle'); const codeParam = params.get('code');
if (cp) (document.getElementById('code') as HTMLInputElement).value = cp.toUpperCase(); const handleParam = params.get('handle');
if (hp) (document.getElementById('handle') as HTMLInputElement).value = hp;
document.getElementById('reset-form')?.addEventListener('submit', async e => { const requestSection = document.getElementById('request-section')!;
const codeSection = document.getElementById('code-section')!;
const tokenSection = document.getElementById('token-section')!;
function show(section: HTMLElement) {
[requestSection, codeSection, tokenSection].forEach(s => s.classList.add('hidden'));
section.classList.remove('hidden');
}
if (token) {
show(tokenSection);
} else if (codeParam) {
show(codeSection);
(document.getElementById('code') as HTMLInputElement).value = codeParam.toUpperCase();
if (handleParam) (document.getElementById('handle') as HTMLInputElement).value = handleParam;
} else {
show(requestSection);
}
document.getElementById('show-code-form')?.addEventListener('click', () => show(codeSection));
document.getElementById('show-email-form')?.addEventListener('click', () => show(requestSection));
// Email request form
document.getElementById('request-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const form = e.target as HTMLFormElement; const form = e.target as HTMLFormElement;
const errEl = document.getElementById('reset-error')!; const errEl = document.getElementById('request-error')!;
const okEl = document.getElementById('reset-ok')!; const okEl = document.getElementById('request-ok')!;
errEl.classList.add('hidden'); errEl.classList.add('hidden');
okEl.classList.add('hidden'); okEl.classList.add('hidden');
const body = {
code: (form.querySelector('#code') as HTMLInputElement).value.trim().toUpperCase(),
handle: (form.querySelector('#handle') as HTMLInputElement).value.trim().toLowerCase(),
password: (form.querySelector('#password') as HTMLInputElement).value,
};
try { try {
const r = await fetch('/api/auth/reset-password', { await fetch('/api/auth/request-reset', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify({ email: (form.querySelector('#email') as HTMLInputElement).value.trim() }),
}); });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errEl.textContent = d.detail ?? 'Reset failed';
errEl.classList.remove('hidden');
return;
}
okEl.classList.remove('hidden'); okEl.classList.remove('hidden');
form.querySelectorAll('input, button').forEach(el => (el as HTMLInputElement).disabled = true); form.querySelectorAll('input, button').forEach(el => (el as HTMLInputElement).disabled = true);
} catch { } catch {
@@ -87,4 +152,53 @@ import Base from '../../layouts/Base.astro';
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
} }
}); });
// Admin code form
document.getElementById('code-form')?.addEventListener('submit', async e => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const errEl = document.getElementById('code-error')!;
const okEl = document.getElementById('code-ok')!;
errEl.classList.add('hidden');
okEl.classList.add('hidden');
const body = {
code: (form.querySelector('#code') as HTMLInputElement).value.trim().toUpperCase(),
handle: (form.querySelector('#handle') as HTMLInputElement).value.trim().toLowerCase(),
password: (form.querySelector('#code-password') as HTMLInputElement).value,
};
try {
const r = await fetch('/api/auth/reset-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); errEl.textContent = d.detail ?? 'Reset failed'; errEl.classList.remove('hidden'); return; }
okEl.classList.remove('hidden');
form.querySelectorAll('input, button').forEach(el => (el as HTMLInputElement).disabled = true);
} catch {
errEl.textContent = 'Could not reach server'; errEl.classList.remove('hidden');
}
});
// Email token form
document.getElementById('token-form')?.addEventListener('submit', async e => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const errEl = document.getElementById('token-error')!;
const okEl = document.getElementById('token-ok')!;
errEl.classList.add('hidden');
okEl.classList.add('hidden');
const body = {
token,
password: (form.querySelector('#token-password') as HTMLInputElement).value,
};
try {
const r = await fetch('/api/auth/reset-password-token', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); errEl.textContent = d.detail ?? 'Reset failed'; errEl.classList.remove('hidden'); return; }
okEl.classList.remove('hidden');
form.querySelectorAll('input, button').forEach(el => (el as HTMLInputElement).disabled = true);
} catch {
errEl.textContent = 'Could not reach server'; errEl.classList.remove('hidden');
}
});
</script> </script>