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.project import Project
|
||||
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_templates import (
|
||||
build_event_notification,
|
||||
@ -211,6 +213,19 @@ async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_notification_dispatch() -> None:
|
||||
@ -242,6 +257,11 @@ async def run_notification_dispatch() -> None:
|
||||
# Daily housekeeping: purge stale dedup records
|
||||
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:
|
||||
# Broad catch: job failure must never crash the scheduler or the app
|
||||
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.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
|
||||
|
||||
# Import models so Alembic's autogenerate can discover them
|
||||
from app.models import user as _user_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
|
||||
@ -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(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||
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("/")
|
||||
|
||||
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
|
||||
bcrypt==4.2.1
|
||||
argon2-cffi>=23.1.0
|
||||
pyotp>=2.9.0
|
||||
qrcode[pil]>=7.4.0
|
||||
cryptography>=42.0.0
|
||||
python-multipart==0.0.20
|
||||
python-dateutil==2.9.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';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import api from '@/lib/api';
|
||||
import type { GeoLocation } from '@/types';
|
||||
import TotpSetupSection from './TotpSetupSection';
|
||||
import NtfySettingsSection from './NtfySettingsSection';
|
||||
|
||||
const accentColors = [
|
||||
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
||||
@ -345,9 +346,12 @@ export default function SettingsPage() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right column: Calendar, Dashboard ── */}
|
||||
{/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Authentication (TOTP + password change) */}
|
||||
<TotpSetupSection />
|
||||
|
||||
{/* Calendar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -443,6 +447,9 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Integrations (ntfy push notifications) */}
|
||||
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
</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({
|
||||
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);
|
||||
return data;
|
||||
},
|
||||
@ -26,7 +28,9 @@ export function useSettings() {
|
||||
return {
|
||||
settings: settingsQuery.data,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user