Compare commits
2 Commits
c1c1e7ae4e
...
1b4f0318e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b4f0318e7 | |||
| 5b6146792e |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user