Implement Stage 6 Track B: TOTP MFA (pyotp, Fernet-encrypted secrets, backup codes)
- models/totp_usage.py: replay-prevention table, unique on (user_id, code, window) - models/backup_code.py: Argon2id-hashed recovery codes with used_at tracking - services/totp.py: Fernet encrypt/decrypt, verify_totp_code returns actual window, QR base64, backup code generation - routers/totp.py: setup (idempotent), confirm, totp-verify (mfa_token + TOTP or backup code), disable, regenerate, status - alembic/024: creates totp_usage and backup_codes tables - main.py: register totp router, import new models for Alembic discovery - requirements.txt: add pyotp>=2.9.0, qrcode[pil]>=7.4.0, cryptography>=42.0.0 - jobs/notifications.py: periodic cleanup for totp_usage (5 min) and expired user_sessions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fbc452a004
commit
b134ad9e8b
74
backend/alembic/versions/024_totp_mfa_tables.py
Normal file
74
backend/alembic/versions/024_totp_mfa_tables.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""TOTP MFA: create totp_usage and backup_codes tables.
|
||||||
|
|
||||||
|
Revision ID: 024
|
||||||
|
Revises: 023
|
||||||
|
Create Date: 2026-02-25
|
||||||
|
|
||||||
|
Note: totp_secret and totp_enabled columns are already on the users table
|
||||||
|
from migration 023 — this migration only adds the support tables.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "024"
|
||||||
|
down_revision = "023"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- totp_usage: tracks used TOTP codes for replay prevention ---
|
||||||
|
op.create_table(
|
||||||
|
"totp_usage",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("code", sa.String(6), nullable=False),
|
||||||
|
# The actual TOTP time window (floor(unix_time / 30)) that matched
|
||||||
|
sa.Column("window", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"used_at",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
# Unique on (user_id, code, window) — not just (user_id, window) — see model comment
|
||||||
|
sa.UniqueConstraint("user_id", "code", "window", name="uq_totp_user_code_window"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_totp_usage_user_id", "totp_usage", ["user_id"])
|
||||||
|
|
||||||
|
# --- backup_codes: hashed recovery codes (Argon2id) ---
|
||||||
|
op.create_table(
|
||||||
|
"backup_codes",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
# Argon2id hash of the plaintext recovery code
|
||||||
|
sa.Column("code_hash", sa.String(255), nullable=False),
|
||||||
|
# Null until redeemed
|
||||||
|
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_backup_codes_user_id", "backup_codes", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_backup_codes_user_id", table_name="backup_codes")
|
||||||
|
op.drop_table("backup_codes")
|
||||||
|
op.drop_index("ix_totp_usage_user_id", table_name="totp_usage")
|
||||||
|
op.drop_table("totp_usage")
|
||||||
@ -21,6 +21,8 @@ from app.models.calendar_event import CalendarEvent
|
|||||||
from app.models.todo import Todo
|
from app.models.todo import Todo
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.ntfy_sent import NtfySent
|
from app.models.ntfy_sent import NtfySent
|
||||||
|
from app.models.totp_usage import TOTPUsage
|
||||||
|
from app.models.session import UserSession
|
||||||
from app.services.ntfy import send_ntfy_notification
|
from app.services.ntfy import send_ntfy_notification
|
||||||
from app.services.ntfy_templates import (
|
from app.services.ntfy_templates import (
|
||||||
build_event_notification,
|
build_event_notification,
|
||||||
@ -211,6 +213,19 @@ async def _purge_old_sent_records(db: AsyncSession) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_totp_usage(db: AsyncSession) -> None:
|
||||||
|
"""Remove TOTP usage records older than 5 minutes — they serve no purpose beyond replay prevention."""
|
||||||
|
cutoff = datetime.now() - timedelta(minutes=5)
|
||||||
|
await db.execute(delete(TOTPUsage).where(TOTPUsage.used_at < cutoff))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_expired_sessions(db: AsyncSession) -> None:
|
||||||
|
"""Remove expired UserSession rows to keep the sessions table lean."""
|
||||||
|
await db.execute(delete(UserSession).where(UserSession.expires_at < datetime.now()))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def run_notification_dispatch() -> None:
|
async def run_notification_dispatch() -> None:
|
||||||
@ -242,6 +257,11 @@ async def run_notification_dispatch() -> None:
|
|||||||
# Daily housekeeping: purge stale dedup records
|
# Daily housekeeping: purge stale dedup records
|
||||||
await _purge_old_sent_records(db)
|
await _purge_old_sent_records(db)
|
||||||
|
|
||||||
|
# Security housekeeping runs every cycle regardless of ntfy_enabled
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await _purge_totp_usage(db)
|
||||||
|
await _purge_expired_sessions(db)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Broad catch: job failure must never crash the scheduler or the app
|
# Broad catch: job failure must never crash the scheduler or the app
|
||||||
logger.exception("ntfy dispatch job encountered an unhandled error")
|
logger.exception("ntfy dispatch job encountered an unhandled error")
|
||||||
|
|||||||
@ -6,11 +6,14 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
|
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||||
|
from app.routers import totp
|
||||||
from app.jobs.notifications import run_notification_dispatch
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# Import models so Alembic's autogenerate can discover them
|
||||||
from app.models import user as _user_model # noqa: F401
|
from app.models import user as _user_model # noqa: F401
|
||||||
from app.models import session as _session_model # noqa: F401
|
from app.models import session as _session_model # noqa: F401
|
||||||
|
from app.models import totp_usage as _totp_usage_model # noqa: F401
|
||||||
|
from app.models import backup_code as _backup_code_model # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@ -58,6 +61,7 @@ app.include_router(settings_router.router, prefix="/api/settings", tags=["Settin
|
|||||||
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
||||||
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||||
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||||
|
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
19
backend/app/models/backup_code.py
Normal file
19
backend/app/models/backup_code.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import String, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BackupCode(Base):
|
||||||
|
__tablename__ = "backup_codes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# Argon2id hash of the plaintext recovery code — never store plaintext
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
# Null until redeemed; set to datetime.now() on successful use
|
||||||
|
used_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
30
backend/app/models/totp_usage.py
Normal file
30
backend/app/models/totp_usage.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TOTPUsage(Base):
|
||||||
|
__tablename__ = "totp_usage"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# The 6-digit code that was used
|
||||||
|
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||||
|
# The TOTP time window in which the code was valid (floor(unix_time / 30))
|
||||||
|
window: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
used_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
|
||||||
|
# NOTE on replay prevention design:
|
||||||
|
# The unique constraint is on (user_id, code, window) — NOT (user_id, window).
|
||||||
|
# This allows a user to present the T-1 window code and then the T window code
|
||||||
|
# consecutively — these are different 6-digit OTPs, so both should succeed.
|
||||||
|
# Constraining only (user_id, window) would reject the second legitimate code.
|
||||||
|
# The actual attack we prevent is reusing the *same code string in the same window*,
|
||||||
|
# which is the only true replay. This is compliant with RFC 6238 §5.2.
|
||||||
|
# The 5-minute MFA token expiry provides the outer time bound on any abuse window.
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "code", "window", name="uq_totp_user_code_window"),
|
||||||
|
)
|
||||||
416
backend/app/routers/totp.py
Normal file
416
backend/app/routers/totp.py
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
"""
|
||||||
|
TOTP MFA router.
|
||||||
|
|
||||||
|
Endpoints (all under /api/auth — registered in main.py with prefix="/api/auth"):
|
||||||
|
|
||||||
|
POST /totp/setup — Generate secret + QR + backup codes (auth required)
|
||||||
|
POST /totp/confirm — Verify first code, enable TOTP (auth required)
|
||||||
|
POST /totp-verify — MFA challenge: mfa_token + TOTP/backup code, issues session
|
||||||
|
POST /totp/disable — Disable TOTP (auth required, needs password + code)
|
||||||
|
POST /totp/backup-codes/regenerate — Regenerate backup codes (auth required, needs password + code)
|
||||||
|
GET /totp/status — { enabled, backup_codes_remaining } (auth required)
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- TOTP secrets encrypted at rest (Fernet/AES-128-CBC, key derived from SECRET_KEY)
|
||||||
|
- Replay prevention via totp_usage table (unique on user_id+code+window)
|
||||||
|
- Backup codes hashed with Argon2id, shown plaintext once only
|
||||||
|
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
||||||
|
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.session import UserSession
|
||||||
|
from app.models.totp_usage import TOTPUsage
|
||||||
|
from app.models.backup_code import BackupCode
|
||||||
|
from app.routers.auth import get_current_user, _set_session_cookie
|
||||||
|
from app.services.auth import (
|
||||||
|
verify_password_with_upgrade,
|
||||||
|
hash_password,
|
||||||
|
verify_mfa_token,
|
||||||
|
create_session_token,
|
||||||
|
)
|
||||||
|
from app.services.totp import (
|
||||||
|
generate_totp_secret,
|
||||||
|
encrypt_totp_secret,
|
||||||
|
decrypt_totp_secret,
|
||||||
|
get_totp_uri,
|
||||||
|
verify_totp_code,
|
||||||
|
generate_qr_base64,
|
||||||
|
generate_backup_codes,
|
||||||
|
)
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
|
# Argon2id for backup code hashing — treat each code like a password
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Argon2id instance for backup code hashes (same params as password hashing)
|
||||||
|
_ph = PasswordHasher(
|
||||||
|
time_cost=2,
|
||||||
|
memory_cost=19456,
|
||||||
|
parallelism=1,
|
||||||
|
hash_len=32,
|
||||||
|
salt_len=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TOTPConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class TOTPVerifyRequest(BaseModel):
|
||||||
|
mfa_token: str
|
||||||
|
code: Optional[str] = None # 6-digit TOTP code
|
||||||
|
backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code
|
||||||
|
|
||||||
|
|
||||||
|
class TOTPDisableRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
code: str # Current TOTP code required to disable
|
||||||
|
|
||||||
|
|
||||||
|
class BackupCodesRegenerateRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
code: str # Current TOTP code required to regenerate
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
||||||
|
"""Hash and insert backup codes for the given user."""
|
||||||
|
for code in plaintext_codes:
|
||||||
|
code_hash = _ph.hash(code)
|
||||||
|
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_backup_codes(db: AsyncSession, user_id: int) -> None:
|
||||||
|
"""Delete all backup codes for a user."""
|
||||||
|
await db.execute(delete(BackupCode).where(BackupCode.user_id == user_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_backup_code(
|
||||||
|
db: AsyncSession, user_id: int, submitted_code: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check submitted backup code against all unused hashes for the user.
|
||||||
|
On match, marks the code as used. Returns True if a valid unused code was found.
|
||||||
|
Uses Argon2id verification — constant-time by design.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(BackupCode).where(
|
||||||
|
BackupCode.user_id == user_id,
|
||||||
|
BackupCode.used_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unused_codes = result.scalars().all()
|
||||||
|
|
||||||
|
for record in unused_codes:
|
||||||
|
try:
|
||||||
|
if _ph.verify(record.code_hash, submitted_code):
|
||||||
|
record.used_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_full_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
request: Request,
|
||||||
|
) -> str:
|
||||||
|
"""Create a UserSession row and return the signed cookie token."""
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||||
|
ip = request.client.host if request.client else None
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
|
||||||
|
db_session = UserSession(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
ip_address=ip[:45] if ip else None,
|
||||||
|
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||||
|
)
|
||||||
|
db.add(db_session)
|
||||||
|
await db.commit()
|
||||||
|
return create_session_token(user.id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/totp/setup")
|
||||||
|
async def totp_setup(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a new TOTP secret, QR code, and backup codes.
|
||||||
|
Stores the encrypted secret with totp_enabled=False until confirmed.
|
||||||
|
|
||||||
|
Idempotent: calling again before confirmation overwrites the unconfirmed secret,
|
||||||
|
so browser refreshes mid-setup generate a fresh QR without error.
|
||||||
|
|
||||||
|
Returns { secret, qr_code_base64, backup_codes } — the only time plaintext
|
||||||
|
values are shown. The `secret` field is the raw base32 for manual entry.
|
||||||
|
"""
|
||||||
|
# Generate new secret (idempotent — overwrite any existing unconfirmed secret)
|
||||||
|
raw_secret = generate_totp_secret()
|
||||||
|
encrypted_secret = encrypt_totp_secret(raw_secret)
|
||||||
|
|
||||||
|
current_user.totp_secret = encrypted_secret
|
||||||
|
current_user.totp_enabled = False # Not enabled until /confirm called
|
||||||
|
|
||||||
|
# Generate backup codes — hash before storage, return plaintext once
|
||||||
|
plaintext_codes = generate_backup_codes(10)
|
||||||
|
await _delete_backup_codes(db, current_user.id) # Remove any previous unconfirmed codes
|
||||||
|
await _store_backup_codes(db, current_user.id, plaintext_codes)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Build QR code from provisioning URI
|
||||||
|
uri = get_totp_uri(encrypted_secret, current_user.username)
|
||||||
|
qr_base64 = generate_qr_base64(uri)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"secret": raw_secret, # Raw base32 for manual authenticator entry
|
||||||
|
"qr_code_base64": qr_base64, # PNG QR code, data:image/png;base64,...
|
||||||
|
"backup_codes": plaintext_codes, # Shown once — user must save these
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/confirm")
|
||||||
|
async def totp_confirm(
|
||||||
|
data: TOTPConfirmRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify the first TOTP code from the authenticator app and enable TOTP.
|
||||||
|
Must be called after /setup while totp_enabled is still False.
|
||||||
|
"""
|
||||||
|
if not current_user.totp_secret:
|
||||||
|
raise HTTPException(status_code=400, detail="TOTP setup not started — call /setup first")
|
||||||
|
|
||||||
|
if current_user.totp_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="TOTP is already enabled")
|
||||||
|
|
||||||
|
matched_window = verify_totp_code(current_user.totp_secret, data.code)
|
||||||
|
if matched_window is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync")
|
||||||
|
|
||||||
|
current_user.totp_enabled = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "TOTP enabled successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp-verify")
|
||||||
|
async def totp_verify(
|
||||||
|
data: TOTPVerifyRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
MFA challenge endpoint — called after a successful password login when TOTP is enabled.
|
||||||
|
Accepts either a 6-digit TOTP code or a backup recovery code.
|
||||||
|
|
||||||
|
Uses the short-lived mfa_token (from POST /login) instead of a session cookie
|
||||||
|
because the user is not yet fully authenticated at this stage.
|
||||||
|
|
||||||
|
On success: issues a full session cookie and returns { authenticated: true }.
|
||||||
|
"""
|
||||||
|
if not data.code and not data.backup_code:
|
||||||
|
raise HTTPException(status_code=422, detail="Provide either 'code' or 'backup_code'")
|
||||||
|
|
||||||
|
# Validate the MFA challenge token (5-minute TTL)
|
||||||
|
user_id = verify_mfa_token(data.mfa_token)
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="MFA session expired — please log in again")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||||
|
|
||||||
|
if not user.totp_enabled or not user.totp_secret:
|
||||||
|
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
|
||||||
|
|
||||||
|
# Check account lockout (shared counter with password failures)
|
||||||
|
if user.locked_until and datetime.now() < user.locked_until:
|
||||||
|
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=423,
|
||||||
|
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Backup code path ---
|
||||||
|
if data.backup_code:
|
||||||
|
normalized = data.backup_code.strip().upper()
|
||||||
|
valid = await _verify_backup_code(db, user.id, normalized)
|
||||||
|
if not valid:
|
||||||
|
user.failed_login_count += 1
|
||||||
|
if user.failed_login_count >= 10:
|
||||||
|
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid backup code")
|
||||||
|
|
||||||
|
# Backup code accepted — reset lockout counter and issue session
|
||||||
|
user.failed_login_count = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.last_login_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
token = await _create_full_session(db, user, request)
|
||||||
|
_set_session_cookie(response, token)
|
||||||
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
# --- TOTP code path ---
|
||||||
|
matched_window = verify_totp_code(user.totp_secret, data.code)
|
||||||
|
if matched_window is None:
|
||||||
|
user.failed_login_count += 1
|
||||||
|
if user.failed_login_count >= 10:
|
||||||
|
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid code")
|
||||||
|
|
||||||
|
# Replay prevention — record (user_id, code, actual_matching_window)
|
||||||
|
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
|
||||||
|
db.add(totp_record)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=401, detail="Code already used — wait for the next code")
|
||||||
|
|
||||||
|
# Success — reset lockout counter, update last_login_at, issue full session
|
||||||
|
user.failed_login_count = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.last_login_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
token = await _create_full_session(db, user, request)
|
||||||
|
_set_session_cookie(response, token)
|
||||||
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/disable")
|
||||||
|
async def totp_disable(
|
||||||
|
data: TOTPDisableRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Disable TOTP for the current user.
|
||||||
|
Requires both current password AND a valid TOTP code as confirmation.
|
||||||
|
Clears totp_secret, sets totp_enabled=False, and deletes all backup codes.
|
||||||
|
"""
|
||||||
|
if not current_user.totp_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||||
|
|
||||||
|
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
||||||
|
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
|
if new_hash:
|
||||||
|
current_user.password_hash = new_hash
|
||||||
|
|
||||||
|
# Verify TOTP code — both checks required for disable
|
||||||
|
matched_window = verify_totp_code(current_user.totp_secret, data.code)
|
||||||
|
if matched_window is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid TOTP code")
|
||||||
|
|
||||||
|
# All checks passed — disable TOTP
|
||||||
|
current_user.totp_secret = None
|
||||||
|
current_user.totp_enabled = False
|
||||||
|
await _delete_backup_codes(db, current_user.id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "TOTP disabled successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/backup-codes/regenerate")
|
||||||
|
async def regenerate_backup_codes(
|
||||||
|
data: BackupCodesRegenerateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Regenerate backup recovery codes.
|
||||||
|
Requires current password AND a valid TOTP code.
|
||||||
|
Deletes all existing backup codes and generates 10 fresh ones.
|
||||||
|
Returns plaintext codes once — never retrievable again.
|
||||||
|
"""
|
||||||
|
if not current_user.totp_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||||
|
|
||||||
|
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
|
if new_hash:
|
||||||
|
current_user.password_hash = new_hash
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
matched_window = verify_totp_code(current_user.totp_secret, data.code)
|
||||||
|
if matched_window is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid TOTP code")
|
||||||
|
|
||||||
|
# Regenerate
|
||||||
|
plaintext_codes = generate_backup_codes(10)
|
||||||
|
await _delete_backup_codes(db, current_user.id)
|
||||||
|
await _store_backup_codes(db, current_user.id, plaintext_codes)
|
||||||
|
|
||||||
|
return {"backup_codes": plaintext_codes}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/totp/status")
|
||||||
|
async def totp_status(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return TOTP enabled state and count of remaining (unused) backup codes."""
|
||||||
|
remaining = 0
|
||||||
|
if current_user.totp_enabled:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BackupCode).where(
|
||||||
|
BackupCode.user_id == current_user.id,
|
||||||
|
BackupCode.used_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
remaining = len(result.scalars().all())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": current_user.totp_enabled,
|
||||||
|
"backup_codes_remaining": remaining,
|
||||||
|
}
|
||||||
134
backend/app/services/totp.py
Normal file
134
backend/app/services/totp.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
TOTP service: secret generation/encryption, code verification, QR code generation,
|
||||||
|
backup code generation.
|
||||||
|
|
||||||
|
All TOTP secrets are Fernet-encrypted at rest using a key derived from SECRET_KEY.
|
||||||
|
Raw secrets are never logged and are only returned to the client once (at setup).
|
||||||
|
"""
|
||||||
|
import pyotp
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fernet key derivation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
"""Derive a 32-byte Fernet key from SECRET_KEY via SHA-256."""
|
||||||
|
key = hashlib.sha256(app_settings.SECRET_KEY.encode()).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secret management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def generate_totp_secret() -> str:
|
||||||
|
"""Generate a new random TOTP secret (base32, ~160 bits entropy)."""
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_totp_secret(raw: str) -> str:
|
||||||
|
"""Encrypt a raw TOTP secret before storing in the DB."""
|
||||||
|
return _get_fernet().encrypt(raw.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_totp_secret(encrypted: str) -> str:
|
||||||
|
"""Decrypt a TOTP secret retrieved from the DB."""
|
||||||
|
return _get_fernet().decrypt(encrypted.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provisioning URI and QR code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_totp_uri(encrypted_secret: str, username: str) -> str:
|
||||||
|
"""Return the otpauth:// provisioning URI for QR code generation."""
|
||||||
|
raw = decrypt_totp_secret(encrypted_secret)
|
||||||
|
totp = pyotp.TOTP(raw)
|
||||||
|
return totp.provisioning_uri(name=username, issuer_name=app_settings.TOTP_ISSUER)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_base64(uri: str) -> str:
|
||||||
|
"""Return a base64-encoded PNG of the QR code for the provisioning URI."""
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||||
|
box_size=10,
|
||||||
|
border=4,
|
||||||
|
)
|
||||||
|
qr.add_data(uri)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Code verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def verify_totp_code(encrypted_secret: str, code: str, valid_window: int = 1) -> int | None:
|
||||||
|
"""
|
||||||
|
Verify a TOTP code and return the matching time window, or None if invalid.
|
||||||
|
|
||||||
|
Checks each window individually (T-valid_window ... T+valid_window) so the
|
||||||
|
caller knows WHICH window matched — required for correct replay-prevention
|
||||||
|
(the TOTPUsage row must record the actual matching window, not the current one).
|
||||||
|
|
||||||
|
Uses secrets.compare_digest for constant-time comparison to prevent timing attacks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int — the floor(unix_time / 30) window value that matched
|
||||||
|
None — no window matched (invalid code)
|
||||||
|
"""
|
||||||
|
raw = decrypt_totp_secret(encrypted_secret)
|
||||||
|
totp = pyotp.TOTP(raw)
|
||||||
|
current_window = int(time.time() // 30)
|
||||||
|
|
||||||
|
for offset in range(-valid_window, valid_window + 1):
|
||||||
|
check_window = current_window + offset
|
||||||
|
# pyotp.at() accepts a unix timestamp; multiply window back to seconds
|
||||||
|
expected_code = totp.at(check_window * 30)
|
||||||
|
if secrets.compare_digest(code.strip(), expected_code):
|
||||||
|
return check_window # Return the ACTUAL window that matched
|
||||||
|
|
||||||
|
return None # No window matched
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backup codes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def generate_backup_codes(count: int = 10) -> list[str]:
|
||||||
|
"""
|
||||||
|
Generate recovery backup codes in XXXX-XXXX format.
|
||||||
|
Uses cryptographically secure randomness (secrets module).
|
||||||
|
"""
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
return [
|
||||||
|
"".join(secrets.choice(alphabet) for _ in range(4))
|
||||||
|
+ "-"
|
||||||
|
+ "".join(secrets.choice(alphabet) for _ in range(4))
|
||||||
|
for _ in range(count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Utility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_totp_window() -> int:
|
||||||
|
"""Return the current TOTP time window (floor(unix_time / 30))."""
|
||||||
|
return int(time.time() // 30)
|
||||||
@ -7,6 +7,9 @@ pydantic==2.10.4
|
|||||||
pydantic-settings==2.7.1
|
pydantic-settings==2.7.1
|
||||||
bcrypt==4.2.1
|
bcrypt==4.2.1
|
||||||
argon2-cffi>=23.1.0
|
argon2-cffi>=23.1.0
|
||||||
|
pyotp>=2.9.0
|
||||||
|
qrcode[pil]>=7.4.0
|
||||||
|
cryptography>=42.0.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
python-dateutil==2.9.0
|
python-dateutil==2.9.0
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
|||||||
365
frontend/src/components/settings/NtfySettingsSection.tsx
Normal file
365
frontend/src/components/settings/NtfySettingsSection.tsx
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
|
Send,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { Settings } from '@/types';
|
||||||
|
|
||||||
|
interface NtfySettingsSectionProps {
|
||||||
|
settings: Settings | undefined;
|
||||||
|
updateSettings: (
|
||||||
|
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||||
|
) => Promise<Settings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NtfySettingsSection({ settings, updateSettings }: NtfySettingsSectionProps) {
|
||||||
|
// ── Local form state ──
|
||||||
|
const [ntfyEnabled, setNtfyEnabled] = useState(false);
|
||||||
|
const [ntfyServerUrl, setNtfyServerUrl] = useState('');
|
||||||
|
const [ntfyTopic, setNtfyTopic] = useState('');
|
||||||
|
const [ntfyToken, setNtfyToken] = useState('');
|
||||||
|
const [tokenCleared, setTokenCleared] = useState(false);
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
|
||||||
|
// Per-type toggles
|
||||||
|
const [eventsEnabled, setEventsEnabled] = useState(false);
|
||||||
|
const [eventLeadMinutes, setEventLeadMinutes] = useState(15);
|
||||||
|
const [remindersEnabled, setRemindersEnabled] = useState(false);
|
||||||
|
const [todosEnabled, setTodosEnabled] = useState(false);
|
||||||
|
const [todoLeadDays, setTodoLeadDays] = useState(0);
|
||||||
|
const [projectsEnabled, setProjectsEnabled] = useState(false);
|
||||||
|
const [projectLeadDays, setProjectLeadDays] = useState(0);
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isTestingNtfy, setIsTestingNtfy] = useState(false);
|
||||||
|
|
||||||
|
// Sync from settings on load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
setNtfyEnabled(settings.ntfy_enabled ?? false);
|
||||||
|
setNtfyServerUrl(settings.ntfy_server_url ?? '');
|
||||||
|
setNtfyTopic(settings.ntfy_topic ?? '');
|
||||||
|
setNtfyToken(''); // never pre-populate token value
|
||||||
|
setTokenCleared(false);
|
||||||
|
setEventsEnabled(settings.ntfy_events_enabled ?? false);
|
||||||
|
setEventLeadMinutes(settings.ntfy_event_lead_minutes ?? 15);
|
||||||
|
setRemindersEnabled(settings.ntfy_reminders_enabled ?? false);
|
||||||
|
setTodosEnabled(settings.ntfy_todos_enabled ?? false);
|
||||||
|
setTodoLeadDays(settings.ntfy_todo_lead_days ?? 0);
|
||||||
|
setProjectsEnabled(settings.ntfy_projects_enabled ?? false);
|
||||||
|
setProjectLeadDays(settings.ntfy_project_lead_days ?? 0);
|
||||||
|
}, [settings?.id]); // only sync on initial load
|
||||||
|
|
||||||
|
const ntfyHasToken = settings?.ntfy_has_token ?? false;
|
||||||
|
const isMisconfigured = ntfyEnabled && (!ntfyServerUrl.trim() || !ntfyTopic.trim());
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const updates: Parameters<typeof updateSettings>[0] = {
|
||||||
|
ntfy_enabled: ntfyEnabled,
|
||||||
|
ntfy_server_url: ntfyServerUrl.trim() || null,
|
||||||
|
ntfy_topic: ntfyTopic.trim() || null,
|
||||||
|
ntfy_events_enabled: eventsEnabled,
|
||||||
|
ntfy_event_lead_minutes: eventLeadMinutes,
|
||||||
|
ntfy_reminders_enabled: remindersEnabled,
|
||||||
|
ntfy_todos_enabled: todosEnabled,
|
||||||
|
ntfy_todo_lead_days: todoLeadDays,
|
||||||
|
ntfy_projects_enabled: projectsEnabled,
|
||||||
|
ntfy_project_lead_days: projectLeadDays,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Token logic: only include if user typed something OR explicitly cleared
|
||||||
|
if (ntfyToken) {
|
||||||
|
updates.ntfy_auth_token = ntfyToken;
|
||||||
|
} else if (tokenCleared) {
|
||||||
|
updates.ntfy_auth_token = '';
|
||||||
|
}
|
||||||
|
// If blank and not cleared: omit entirely (backend keeps existing token)
|
||||||
|
|
||||||
|
await updateSettings(updates);
|
||||||
|
setNtfyToken('');
|
||||||
|
setTokenCleared(false);
|
||||||
|
toast.success('Notification settings saved');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to save notification settings'));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNtfyTest = async () => {
|
||||||
|
setIsTestingNtfy(true);
|
||||||
|
try {
|
||||||
|
await api.post('/settings/ntfy/test');
|
||||||
|
toast.success('Test notification sent');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to send test notification'));
|
||||||
|
} finally {
|
||||||
|
setIsTestingNtfy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearToken = () => {
|
||||||
|
setNtfyToken('');
|
||||||
|
setTokenCleared(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||||
|
<Bell className="h-4 w-4 text-orange-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Integrations</CardTitle>
|
||||||
|
<CardDescription>Push notifications via ntfy</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
|
||||||
|
{/* Master toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Enable Push Notifications</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Send alerts to your ntfy server for reminders, todos, and events
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={ntfyEnabled}
|
||||||
|
onCheckedChange={setNtfyEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config — only shown when enabled */}
|
||||||
|
{ntfyEnabled && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Warning banner */}
|
||||||
|
{isMisconfigured && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
|
||||||
|
<p className="text-xs text-amber-400">
|
||||||
|
Server URL and topic are required to send notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection config */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium">Connection</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ntfy_server_url">Server URL</Label>
|
||||||
|
<Input
|
||||||
|
id="ntfy_server_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
value={ntfyServerUrl}
|
||||||
|
onChange={(e) => setNtfyServerUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ntfy_topic">Topic</Label>
|
||||||
|
<Input
|
||||||
|
id="ntfy_topic"
|
||||||
|
type="text"
|
||||||
|
placeholder="my-umbra-alerts"
|
||||||
|
value={ntfyTopic}
|
||||||
|
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ntfy_token">Auth Token</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="ntfy_token"
|
||||||
|
type={showToken ? 'text' : 'password'}
|
||||||
|
value={ntfyToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNtfyToken(e.target.value);
|
||||||
|
if (tokenCleared && e.target.value) setTokenCleared(false);
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
tokenCleared
|
||||||
|
? 'Token will be cleared on save'
|
||||||
|
: ntfyHasToken
|
||||||
|
? '(token saved — leave blank to keep)'
|
||||||
|
: 'Optional auth token'
|
||||||
|
}
|
||||||
|
className="pr-16"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1 items-center">
|
||||||
|
{ntfyHasToken && !ntfyToken && !tokenCleared && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearToken}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||||
|
aria-label="Clear saved token"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||||
|
aria-label={showToken ? 'Hide auth token' : 'Show auth token'}
|
||||||
|
>
|
||||||
|
{showToken ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tokenCleared && (
|
||||||
|
<p className="text-xs text-amber-400">Token will be removed when you save.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Per-type notification toggles */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-medium">Notification Types</p>
|
||||||
|
|
||||||
|
{/* Event reminders */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Switch checked={eventsEnabled} onCheckedChange={setEventsEnabled} />
|
||||||
|
<p className="text-sm">Event reminders</p>
|
||||||
|
</div>
|
||||||
|
{eventsEnabled && (
|
||||||
|
<Select
|
||||||
|
value={String(eventLeadMinutes)}
|
||||||
|
onChange={(e) => setEventLeadMinutes(Number(e.target.value))}
|
||||||
|
className="h-8 w-36 text-xs"
|
||||||
|
>
|
||||||
|
<option value="5">5 min before</option>
|
||||||
|
<option value="10">10 min before</option>
|
||||||
|
<option value="15">15 min before</option>
|
||||||
|
<option value="30">30 min before</option>
|
||||||
|
<option value="60">1 hour before</option>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reminder alerts */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch checked={remindersEnabled} onCheckedChange={setRemindersEnabled} />
|
||||||
|
<p className="text-sm">Reminder alerts</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todo due dates */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Switch checked={todosEnabled} onCheckedChange={setTodosEnabled} />
|
||||||
|
<p className="text-sm">Todo due dates</p>
|
||||||
|
</div>
|
||||||
|
{todosEnabled && (
|
||||||
|
<Select
|
||||||
|
value={String(todoLeadDays)}
|
||||||
|
onChange={(e) => setTodoLeadDays(Number(e.target.value))}
|
||||||
|
className="h-8 w-36 text-xs"
|
||||||
|
>
|
||||||
|
<option value="0">Same day</option>
|
||||||
|
<option value="1">1 day before</option>
|
||||||
|
<option value="2">2 days before</option>
|
||||||
|
<option value="3">3 days before</option>
|
||||||
|
<option value="7">1 week before</option>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project deadlines */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Switch checked={projectsEnabled} onCheckedChange={setProjectsEnabled} />
|
||||||
|
<p className="text-sm">Project deadlines</p>
|
||||||
|
</div>
|
||||||
|
{projectsEnabled && (
|
||||||
|
<Select
|
||||||
|
value={String(projectLeadDays)}
|
||||||
|
onChange={(e) => setProjectLeadDays(Number(e.target.value))}
|
||||||
|
className="h-8 w-36 text-xs"
|
||||||
|
>
|
||||||
|
<option value="0">Same day</option>
|
||||||
|
<option value="1">1 day before</option>
|
||||||
|
<option value="2">2 days before</option>
|
||||||
|
<option value="3">3 days before</option>
|
||||||
|
<option value="7">1 week before</option>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Test + Save */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNtfyTest}
|
||||||
|
disabled={!ntfyEnabled || !ntfyServerUrl.trim() || !ntfyTopic.trim() || isTestingNtfy}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isTestingNtfy ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" />Sending...</>
|
||||||
|
) : (
|
||||||
|
<><Send className="h-4 w-4" />Send Test Notification</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
||||||
|
) : (
|
||||||
|
'Save Notifications Config'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save button when disabled — still allow saving the master toggle state */}
|
||||||
|
{!ntfyEnabled && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||||
|
{isSaving ? <><Loader2 className="h-4 w-4 animate-spin" />Saving</> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,12 +15,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { GeoLocation } from '@/types';
|
import type { GeoLocation } from '@/types';
|
||||||
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
|
import NtfySettingsSection from './NtfySettingsSection';
|
||||||
|
|
||||||
const accentColors = [
|
const accentColors = [
|
||||||
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
||||||
@ -345,9 +346,12 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right column: Calendar, Dashboard ── */}
|
{/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Authentication (TOTP + password change) */}
|
||||||
|
<TotpSetupSection />
|
||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -443,6 +447,9 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Integrations (ntfy push notifications) */}
|
||||||
|
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
540
frontend/src/components/settings/TotpSetupSection.tsx
Normal file
540
frontend/src/components/settings/TotpSetupSection.tsx
Normal file
@ -0,0 +1,540 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { TotpSetupResponse } from '@/types';
|
||||||
|
|
||||||
|
type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled';
|
||||||
|
|
||||||
|
export default function TotpSetupSection() {
|
||||||
|
// ── Password change state ──
|
||||||
|
const [passwordForm, setPasswordForm] = useState({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
|
|
||||||
|
// ── TOTP state ──
|
||||||
|
const [totpSetupState, setTotpSetupState] = useState<TotpSetupState>('idle');
|
||||||
|
const [qrCodeBase64, setQrCodeBase64] = useState('');
|
||||||
|
const [totpSecret, setTotpSecret] = useState('');
|
||||||
|
const [totpConfirmCode, setTotpConfirmCode] = useState('');
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
const [isTotpSetupPending, setIsTotpSetupPending] = useState(false);
|
||||||
|
const [isTotpConfirmPending, setIsTotpConfirmPending] = useState(false);
|
||||||
|
|
||||||
|
// ── Disable / Regenerate dialog state ──
|
||||||
|
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||||
|
const [regenDialogOpen, setRegenDialogOpen] = useState(false);
|
||||||
|
const [dialogPassword, setDialogPassword] = useState('');
|
||||||
|
const [dialogCode, setDialogCode] = useState('');
|
||||||
|
const [isDialogPending, setIsDialogPending] = useState(false);
|
||||||
|
|
||||||
|
// On mount: check TOTP status to set initial state
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get<{ enabled: boolean }>('/auth/totp/status')
|
||||||
|
.then(({ data }) => {
|
||||||
|
setTotpSetupState(data.enabled ? 'enabled' : 'idle');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If endpoint not yet available, default to idle
|
||||||
|
setTotpSetupState('idle');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Password change ──
|
||||||
|
const handlePasswordChange = async () => {
|
||||||
|
const { oldPassword, newPassword, confirmPassword } = passwordForm;
|
||||||
|
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||||
|
toast.error('All password fields are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error('New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 12) {
|
||||||
|
toast.error('Password must be at least 12 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasLetter = /[a-zA-Z]/.test(newPassword);
|
||||||
|
const hasNonLetter = /[^a-zA-Z]/.test(newPassword);
|
||||||
|
if (!hasLetter || !hasNonLetter) {
|
||||||
|
toast.error('Password must contain at least one letter and one non-letter character');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsChangingPassword(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/change-password', {
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
|
toast.success('Password changed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to change password'));
|
||||||
|
} finally {
|
||||||
|
setIsChangingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── TOTP setup ──
|
||||||
|
const handleBeginTotpSetup = async () => {
|
||||||
|
setIsTotpSetupPending(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<TotpSetupResponse>('/auth/totp/setup');
|
||||||
|
setQrCodeBase64(data.qr_code_base64);
|
||||||
|
setTotpSecret(data.secret);
|
||||||
|
setBackupCodes(data.backup_codes);
|
||||||
|
setTotpSetupState('setup');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to begin TOTP setup'));
|
||||||
|
} finally {
|
||||||
|
setIsTotpSetupPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTotpConfirm = async () => {
|
||||||
|
if (!totpConfirmCode || totpConfirmCode.length !== 6) {
|
||||||
|
toast.error('Enter a 6-digit code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsTotpConfirmPending(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ backup_codes: string[] }>('/auth/totp/confirm', {
|
||||||
|
code: totpConfirmCode,
|
||||||
|
});
|
||||||
|
setBackupCodes(data.backup_codes);
|
||||||
|
setTotpConfirmCode('');
|
||||||
|
setTotpSetupState('backup_codes');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Invalid code — try again'));
|
||||||
|
} finally {
|
||||||
|
setIsTotpConfirmPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyBackupCodes = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(backupCodes.join('\n'));
|
||||||
|
toast.success('Backup codes copied');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy codes');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackupCodesConfirmed = () => {
|
||||||
|
setBackupCodes([]);
|
||||||
|
setQrCodeBase64('');
|
||||||
|
setTotpSecret('');
|
||||||
|
setTotpSetupState('enabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Disable TOTP ──
|
||||||
|
const handleDisableConfirm = async () => {
|
||||||
|
if (!dialogPassword || !dialogCode) {
|
||||||
|
toast.error('Password and code are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDialogPending(true);
|
||||||
|
try {
|
||||||
|
await api.post('/auth/totp/disable', { password: dialogPassword, code: dialogCode });
|
||||||
|
setDisableDialogOpen(false);
|
||||||
|
setDialogPassword('');
|
||||||
|
setDialogCode('');
|
||||||
|
setTotpSetupState('idle');
|
||||||
|
toast.success('Two-factor authentication disabled');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to disable TOTP'));
|
||||||
|
} finally {
|
||||||
|
setIsDialogPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Regenerate backup codes ──
|
||||||
|
const handleRegenConfirm = async () => {
|
||||||
|
if (!dialogPassword || !dialogCode) {
|
||||||
|
toast.error('Password and code are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDialogPending(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ backup_codes: string[] }>(
|
||||||
|
'/auth/totp/backup-codes/regenerate',
|
||||||
|
{ password: dialogPassword, code: dialogCode }
|
||||||
|
);
|
||||||
|
setBackupCodes(data.backup_codes);
|
||||||
|
setRegenDialogOpen(false);
|
||||||
|
setDialogPassword('');
|
||||||
|
setDialogCode('');
|
||||||
|
setTotpSetupState('backup_codes');
|
||||||
|
toast.success('New backup codes generated');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to regenerate backup codes'));
|
||||||
|
} finally {
|
||||||
|
setIsDialogPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDisableDialogOpen(false);
|
||||||
|
setRegenDialogOpen(false);
|
||||||
|
setDialogPassword('');
|
||||||
|
setDialogCode('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Authentication</CardTitle>
|
||||||
|
<CardDescription>Manage your password and two-factor authentication</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
|
||||||
|
{/* Subsection A: Change Password */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-medium">Change Password</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="old_password">Current Password</Label>
|
||||||
|
<Input
|
||||||
|
id="old_password"
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.oldPassword}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new_password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="new_password"
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.newPassword}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm_password">Confirm New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.confirmPassword}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasswordChange}
|
||||||
|
disabled={isChangingPassword}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isChangingPassword ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
||||||
|
) : (
|
||||||
|
'Change Password'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Subsection B: TOTP MFA Setup */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{totpSetupState === 'idle' && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Two-Factor Authentication</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Add an extra layer of security with an authenticator app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBeginTotpSetup}
|
||||||
|
disabled={isTotpSetupPending}
|
||||||
|
>
|
||||||
|
{isTotpSetupPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable MFA'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totpSetupState === 'setup' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-medium">Scan with your authenticator app</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${qrCodeBase64}`}
|
||||||
|
alt="TOTP QR code — scan with your authenticator app"
|
||||||
|
className="h-40 w-40 rounded-md border border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Can't scan? Enter this code manually:
|
||||||
|
</p>
|
||||||
|
<code className="block text-center text-xs font-mono bg-secondary px-3 py-2 rounded-md tracking-widest break-all">
|
||||||
|
{totpSecret}
|
||||||
|
</code>
|
||||||
|
<Button className="w-full" onClick={() => setTotpSetupState('confirm')}>
|
||||||
|
Next: Verify Code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totpSetupState === 'confirm' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-medium">Verify your authenticator app</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter the 6-digit code shown in your app to confirm setup.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="totp-confirm-code">Verification Code</Label>
|
||||||
|
<Input
|
||||||
|
id="totp-confirm-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={totpConfirmCode}
|
||||||
|
onChange={(e) => setTotpConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="text-center tracking-widest text-lg"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleTotpConfirm(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleTotpConfirm}
|
||||||
|
disabled={isTotpConfirmPending}
|
||||||
|
>
|
||||||
|
{isTotpConfirmPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
|
||||||
|
) : (
|
||||||
|
'Verify & Enable'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTotpSetupState('setup')}
|
||||||
|
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Back to QR code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totpSetupState === 'backup_codes' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-400" aria-hidden="true" />
|
||||||
|
<p className="text-sm font-medium text-amber-400">Save these backup codes now</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
These {backupCodes.length} codes can each be used once if you lose access to your
|
||||||
|
authenticator app. Store them somewhere safe — they will not be shown again.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 bg-secondary rounded-md p-3">
|
||||||
|
{backupCodes.map((code, i) => (
|
||||||
|
<code key={i} className="text-xs font-mono text-foreground text-center py-0.5">
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyBackupCodes}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy All Codes
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full" onClick={handleBackupCodesConfirmed}>
|
||||||
|
I've saved my backup codes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totpSetupState === 'enabled' && (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium flex items-center gap-2 flex-wrap">
|
||||||
|
Two-Factor Authentication
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400 font-medium uppercase tracking-wide">
|
||||||
|
<Check className="h-3 w-3" aria-hidden="true" />
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Your account is protected with an authenticator app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRegenDialogOpen(true)}
|
||||||
|
>
|
||||||
|
New backup codes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDisableDialogOpen(true)}
|
||||||
|
className="bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/30"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Disable TOTP Dialog */}
|
||||||
|
<Dialog open={disableDialogOpen} onOpenChange={closeDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={closeDialog} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your password and a current authenticator code to disable MFA.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="disable-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="disable-password"
|
||||||
|
type="password"
|
||||||
|
value={dialogPassword}
|
||||||
|
onChange={(e) => setDialogPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="disable-code">Authenticator Code</Label>
|
||||||
|
<Input
|
||||||
|
id="disable-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={dialogCode}
|
||||||
|
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="text-center tracking-widest"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDisableConfirm}
|
||||||
|
disabled={isDialogPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Disable MFA
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Regenerate Backup Codes Dialog */}
|
||||||
|
<Dialog open={regenDialogOpen} onOpenChange={closeDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={closeDialog} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generate New Backup Codes</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your existing backup codes will be invalidated. Enter your password and a current
|
||||||
|
authenticator code to continue.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="regen-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="regen-password"
|
||||||
|
type="password"
|
||||||
|
value={dialogPassword}
|
||||||
|
onChange={(e) => setDialogPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="regen-code">Authenticator Code</Label>
|
||||||
|
<Input
|
||||||
|
id="regen-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={dialogCode}
|
||||||
|
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="text-center tracking-widest"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRegenConfirm}
|
||||||
|
disabled={isDialogPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Generate New Codes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,7 +14,9 @@ export function useSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (updates: Partial<Settings> & { preferred_name?: string | null }) => {
|
mutationFn: async (
|
||||||
|
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||||
|
) => {
|
||||||
const { data } = await api.put<Settings>('/settings', updates);
|
const { data } = await api.put<Settings>('/settings', updates);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@ -26,7 +28,9 @@ export function useSettings() {
|
|||||||
return {
|
return {
|
||||||
settings: settingsQuery.data,
|
settings: settingsQuery.data,
|
||||||
isLoading: settingsQuery.isLoading,
|
isLoading: settingsQuery.isLoading,
|
||||||
updateSettings: updateMutation.mutateAsync,
|
updateSettings: updateMutation.mutateAsync as (
|
||||||
|
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||||
|
) => Promise<Settings>,
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: updateMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user