Add TOTP for Admin login
This commit is contained in:
18
README.md
18
README.md
@@ -9,6 +9,7 @@ A web-based gate access management and control system. Authorized users can remo
|
|||||||
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date
|
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date
|
||||||
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) to trigger gate macros
|
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) to trigger gate macros
|
||||||
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
|
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
|
||||||
|
- **Two-factor authentication (TOTP)** — admins can enable app-based 2FA (Google Authenticator, Authy, etc.) on their account
|
||||||
- **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated
|
- **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated
|
||||||
- **Keypass QR codes** — generate a scannable QR code for each keypass; scanning opens the PWA and logs in automatically
|
- **Keypass QR codes** — generate a scannable QR code for each keypass; scanning opens the PWA and logs in automatically
|
||||||
- **Keypass code options** — choose character set (alphanumeric, alpha, numeric, or a 4-word passphrase) and length when auto-generating codes
|
- **Keypass code options** — choose character set (alphanumeric, alpha, numeric, or a 4-word passphrase) and length when auto-generating codes
|
||||||
@@ -23,6 +24,7 @@ A web-based gate access management and control system. Authorized users can remo
|
|||||||
| ORM | SQLAlchemy |
|
| ORM | SQLAlchemy |
|
||||||
| Database | SQLite |
|
| Database | SQLite |
|
||||||
| Auth | JWT (HS256) + bcrypt |
|
| Auth | JWT (HS256) + bcrypt |
|
||||||
|
| 2FA | TOTP (RFC 6238) via pyotp |
|
||||||
| Credential storage | Fernet symmetric encryption |
|
| Credential storage | Fernet symmetric encryption |
|
||||||
| Gate integration | AVConnect HTTP API |
|
| Gate integration | AVConnect HTTP API |
|
||||||
| Notifications | Telegram Bot API |
|
| Notifications | Telegram Bot API |
|
||||||
@@ -101,6 +103,9 @@ data/
|
|||||||
| POST | `/api/admin/admins` | Create an admin user |
|
| POST | `/api/admin/admins` | Create an admin user |
|
||||||
| DELETE | `/api/admin/admins/{username}` | Delete an admin user |
|
| DELETE | `/api/admin/admins/{username}` | Delete an admin user |
|
||||||
| PATCH | `/api/admin/admins/{username}/password` | Change password |
|
| PATCH | `/api/admin/admins/{username}/password` | Change password |
|
||||||
|
| POST | `/api/admin/admins/{username}/totp/setup` | Generate a new TOTP secret and return provisioning URI + QR |
|
||||||
|
| POST | `/api/admin/admins/{username}/totp/enable` | Verify a TOTP code and activate 2FA |
|
||||||
|
| DELETE | `/api/admin/admins/{username}/totp` | Disable 2FA and discard the secret |
|
||||||
|
|
||||||
### Admin — AVConnect Credentials (admin only)
|
### Admin — AVConnect Credentials (admin only)
|
||||||
|
|
||||||
@@ -232,6 +237,19 @@ Configure a Telegram bot to receive a message in a group or chat every time a ga
|
|||||||
|
|
||||||
Notifications are sent in a background thread and never block the gate open response. Failures are logged as warnings and do not affect gate operation.
|
Notifications are sent in a background thread and never block the gate open response. Failures are logged as warnings and do not affect gate operation.
|
||||||
|
|
||||||
|
## Two-Factor Authentication (TOTP)
|
||||||
|
|
||||||
|
Each admin account can independently enable TOTP-based two-factor authentication:
|
||||||
|
|
||||||
|
1. Open **Admin → Admin Users** and click **Enable 2FA** on your own row
|
||||||
|
2. Scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.)
|
||||||
|
3. Enter the 6-digit code to confirm — 2FA is only activated after a successful verification
|
||||||
|
4. On subsequent logins, after entering your password you will be prompted for the current TOTP code
|
||||||
|
|
||||||
|
To disable, click **Disable 2FA** on your row and confirm.
|
||||||
|
|
||||||
|
> Only the account owner can enable or disable their own 2FA. TOTP secrets are stored Fernet-encrypted in the database.
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
| Role | Permissions |
|
| Role | Permissions |
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ requests>=2.31.0
|
|||||||
fake-useragent>=1.5.0
|
fake-useragent>=1.5.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
pyotp>=2.9
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ class AdminUser(Base):
|
|||||||
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
||||||
|
totp_secret_enc: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Fernet-encrypted TOTP secret
|
||||||
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
class TelegramConfig(Base):
|
class TelegramConfig(Base):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from core.database import Keypass
|
|||||||
class AdminLoginRequest(BaseModel):
|
class AdminLoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
otp_code: Optional[str] = None # 6-digit TOTP code; required when account has 2FA enabled
|
||||||
|
|
||||||
|
|
||||||
class KeypassLoginRequest(BaseModel):
|
class KeypassLoginRequest(BaseModel):
|
||||||
@@ -23,6 +24,12 @@ class TokenResponse(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginResponse(BaseModel):
|
||||||
|
token: Optional[str] = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
otp_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ── Keypasses ─────────────────────────────────────────────────────────────────
|
# ── Keypasses ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class KeypassCreate(BaseModel):
|
class KeypassCreate(BaseModel):
|
||||||
@@ -114,6 +121,12 @@ class AdminUserResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
role: str # 'admin' | 'manager'
|
role: str # 'admin' | 'manager'
|
||||||
|
totp_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TotpSetupResponse(BaseModel):
|
||||||
|
provisioning_uri: str # otpauth:// URI for QR scanning
|
||||||
|
qr_image_b64: str # base64-encoded PNG of the QR code
|
||||||
|
|
||||||
|
|
||||||
class AdminUserCreate(BaseModel):
|
class AdminUserCreate(BaseModel):
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import hash_password
|
from core.auth import decrypt_secret, encrypt_secret, hash_password
|
||||||
from core.database import AdminUser, get_db
|
from core.database import AdminUser, get_db
|
||||||
from core.dependencies import require_admin
|
from core.dependencies import require_admin
|
||||||
from core.schemas import AdminUserCreate, AdminUserResponse, AdminPasswordChange
|
from core.schemas import AdminPasswordChange, AdminUserCreate, AdminUserResponse, TotpSetupResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
|
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
|
||||||
|
|
||||||
@@ -15,7 +20,7 @@ router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
|
|||||||
async def list_admins(
|
async def list_admins(
|
||||||
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
db: Session = Depends(get_db), _: dict = Depends(require_admin)
|
||||||
):
|
):
|
||||||
return [AdminUserResponse(id=u.id, username=u.username, role=u.role) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
|
return [AdminUserResponse(id=u.id, username=u.username, role=u.role, totp_enabled=u.totp_enabled) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AdminUserResponse, status_code=201)
|
@router.post("", response_model=AdminUserResponse, status_code=201)
|
||||||
@@ -73,3 +78,70 @@ async def change_password(
|
|||||||
raise HTTPException(404, "Admin not found")
|
raise HTTPException(404, "Admin not found")
|
||||||
user.password_hash = hash_password(req.new_password)
|
user.password_hash = hash_password(req.new_password)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── TOTP (2FA) ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{username}/totp/setup", response_model=TotpSetupResponse)
|
||||||
|
async def totp_setup(
|
||||||
|
username: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
caller: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Generate a new TOTP secret and return the provisioning URI for QR scanning.
|
||||||
|
Does NOT enable 2FA yet — call /enable with a valid code to activate."""
|
||||||
|
if caller["sub"] != username:
|
||||||
|
raise HTTPException(403, "You can only configure 2FA for your own account")
|
||||||
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "Admin not found")
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
user.totp_secret_enc = encrypt_secret(secret)
|
||||||
|
user.totp_enabled = False # pending confirmation
|
||||||
|
db.commit()
|
||||||
|
uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="Lagomare Gates")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qrcode.make(uri).save(buf)
|
||||||
|
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
return TotpSetupResponse(provisioning_uri=uri, qr_image_b64=qr_b64)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpConfirm(BaseModel):
|
||||||
|
otp_code: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{username}/totp/enable", status_code=204)
|
||||||
|
async def totp_enable(
|
||||||
|
username: str,
|
||||||
|
req: TotpConfirm,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
caller: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Verify the OTP code and activate 2FA for the account."""
|
||||||
|
if caller["sub"] != username:
|
||||||
|
raise HTTPException(403, "You can only configure 2FA for your own account")
|
||||||
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
|
if not user or not user.totp_secret_enc:
|
||||||
|
raise HTTPException(422, "Run /setup first to generate a secret")
|
||||||
|
secret = decrypt_secret(user.totp_secret_enc)
|
||||||
|
if not pyotp.TOTP(secret).verify(req.otp_code.strip(), valid_window=1):
|
||||||
|
raise HTTPException(422, "Invalid OTP code — make sure your authenticator is in sync")
|
||||||
|
user.totp_enabled = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{username}/totp", status_code=204)
|
||||||
|
async def totp_disable(
|
||||||
|
username: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
caller: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Disable 2FA and discard the stored secret."""
|
||||||
|
if caller["sub"] != username:
|
||||||
|
raise HTTPException(403, "You can only configure 2FA for your own account")
|
||||||
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "Admin not found")
|
||||||
|
user.totp_enabled = False
|
||||||
|
user.totp_secret_enc = None
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -2,31 +2,42 @@ import logging
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pyotp
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.auth import create_token, verify_password
|
from core.auth import create_token, decrypt_secret, verify_password
|
||||||
from core.config import utcnow
|
from core.config import utcnow
|
||||||
from core.database import AdminUser, Keypass, get_db
|
from core.database import AdminUser, Keypass, get_db
|
||||||
from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse
|
from core.schemas import AdminLoginRequest, AdminLoginResponse, KeypassLoginRequest, TokenResponse
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin", response_model=TokenResponse)
|
@router.post("/admin", response_model=AdminLoginResponse)
|
||||||
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
|
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
|
||||||
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
|
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
|
||||||
if not user or not verify_password(req.password, user.password_hash):
|
if not user or not verify_password(req.password, user.password_hash):
|
||||||
logger.warning("Failed admin login attempt for username=%r", req.username)
|
logger.warning("Failed admin login attempt for username=%r", req.username)
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
|
||||||
|
|
||||||
|
if user.totp_enabled and user.totp_secret_enc:
|
||||||
|
if not req.otp_code:
|
||||||
|
return AdminLoginResponse(otp_required=True)
|
||||||
|
secret = decrypt_secret(user.totp_secret_enc)
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
if not totp.verify(req.otp_code.strip(), valid_window=1):
|
||||||
|
logger.warning("Invalid OTP for username=%r", user.username)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid OTP code")
|
||||||
|
|
||||||
logger.info("Admin login: username=%r role=%r", user.username, user.role)
|
logger.info("Admin login: username=%r role=%r", user.username, user.role)
|
||||||
token = create_token({
|
token = create_token({
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
"scope": user.role, # 'admin' | 'manager'
|
"scope": user.role,
|
||||||
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
|
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
|
||||||
})
|
})
|
||||||
return TokenResponse(token=token)
|
return AdminLoginResponse(token=token)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keypass", response_model=TokenResponse)
|
@router.post("/keypass", response_model=TokenResponse)
|
||||||
|
|||||||
@@ -97,6 +97,12 @@
|
|||||||
<label for="admin-password">Password</label>
|
<label for="admin-password">Password</label>
|
||||||
<input id="admin-password" type="password" autocomplete="current-password" />
|
<input id="admin-password" type="password" autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field hidden" id="otp-field">
|
||||||
|
<label for="admin-otp">Authenticator code</label>
|
||||||
|
<input id="admin-otp" type="text" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
autocomplete="one-time-code" maxlength="6" placeholder="6-digit code"
|
||||||
|
style="font-family:monospace;font-size:1.3rem;letter-spacing:.2em;text-align:center" />
|
||||||
|
</div>
|
||||||
<p id="login-error" class="error-msg hidden"></p>
|
<p id="login-error" class="error-msg hidden"></p>
|
||||||
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
|
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -531,6 +537,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TOTP setup modal ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="totp-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal" style="max-width:360px;text-align:center">
|
||||||
|
<h3>Set up Two-Factor Authentication</h3>
|
||||||
|
<div id="totp-step-scan">
|
||||||
|
<p style="color:var(--text-muted);font-size:.88rem;margin:.75rem 0 1rem">
|
||||||
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;justify-content:center;background:#fff;padding:.75rem;border-radius:8px;margin-bottom:1rem">
|
||||||
|
<canvas id="totp-qr-canvas" style="max-width:100%;height:auto;display:block"></canvas>
|
||||||
|
</div>
|
||||||
|
<p id="totp-uri-fallback" style="font-size:.72rem;color:var(--text-muted);word-break:break-all;margin-bottom:1rem"></p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="totp-confirm-code">Verification code</label>
|
||||||
|
<input id="totp-confirm-code" type="text" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
maxlength="6" placeholder="000000"
|
||||||
|
style="font-family:monospace;font-size:1.4rem;letter-spacing:.25em;text-align:center" />
|
||||||
|
</div>
|
||||||
|
<p id="totp-error" class="error-msg hidden"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" id="totp-cancel" class="btn btn-ghost">Cancel</button>
|
||||||
|
<button type="button" id="totp-confirm-btn" class="btn btn-primary">Enable 2FA</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,20 +63,42 @@ function showAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
let _pendingCredentials = null; // { username, password } while OTP step is shown
|
||||||
|
|
||||||
document.getElementById("login-form").addEventListener("submit", async e => {
|
document.getElementById("login-form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const username = document.getElementById("admin-username").value.trim();
|
|
||||||
const password = document.getElementById("admin-password").value;
|
|
||||||
const errEl = document.getElementById("login-error");
|
const errEl = document.getElementById("login-error");
|
||||||
const btn = e.target.querySelector("button[type=submit]");
|
const btn = e.target.querySelector("button[type=submit]");
|
||||||
|
const otpField = document.getElementById("otp-field");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
const data = await api("POST", "/api/auth/admin", { username, password });
|
const body = _pendingCredentials
|
||||||
|
? { ..._pendingCredentials, otp_code: document.getElementById("admin-otp").value.trim() }
|
||||||
|
: { username: document.getElementById("admin-username").value.trim(),
|
||||||
|
password: document.getElementById("admin-password").value };
|
||||||
|
const data = await api("POST", "/api/auth/admin", body);
|
||||||
|
if (data.otp_required) {
|
||||||
|
_pendingCredentials = {
|
||||||
|
username: document.getElementById("admin-username").value.trim(),
|
||||||
|
password: document.getElementById("admin-password").value,
|
||||||
|
};
|
||||||
|
otpField.classList.remove("hidden");
|
||||||
|
document.getElementById("admin-otp").focus();
|
||||||
|
errEl.textContent = "Enter the 6-digit code from your authenticator app.";
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
_pendingCredentials = null;
|
||||||
|
otpField.classList.add("hidden");
|
||||||
|
document.getElementById("admin-otp").value = "";
|
||||||
saveToken(data.token);
|
saveToken(data.token);
|
||||||
showAdmin();
|
showAdmin();
|
||||||
} catch (e) {
|
}
|
||||||
errEl.textContent = e.message;
|
} catch (err) {
|
||||||
|
_pendingCredentials = null;
|
||||||
|
otpField.classList.add("hidden");
|
||||||
|
document.getElementById("admin-otp").value = "";
|
||||||
|
errEl.textContent = err.message;
|
||||||
errEl.classList.remove("hidden");
|
errEl.classList.remove("hidden");
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -641,15 +663,39 @@ async function loadAdmins() {
|
|||||||
const roleBadge = u.role === "admin"
|
const roleBadge = u.role === "admin"
|
||||||
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
|
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
|
||||||
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
||||||
|
const totpBadge = u.totp_enabled
|
||||||
|
? '<span class="badge badge-green" style="font-size:.75em;background:var(--accent-warn,#b45309);color:#fff" title="2FA enabled">2FA ✓</span>'
|
||||||
|
: '';
|
||||||
|
const is_me = u.username === me;
|
||||||
|
const totpBtn = is_me
|
||||||
|
? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-totp="${esc(u.username)}" data-totp-enabled="${u.totp_enabled}">${u.totp_enabled ? "Disable 2FA" : "Enable 2FA"}</button>`
|
||||||
|
: "";
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${u.username === me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</div></td>
|
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${is_me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge} ${totpBadge}</div></td>
|
||||||
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
|
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
|
||||||
|
${totpBtn}
|
||||||
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
|
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
|
||||||
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
${!is_me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
||||||
</div></td>`;
|
</div></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
tbody.querySelectorAll("[data-totp]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const username = btn.dataset.totp;
|
||||||
|
const enabled = btn.dataset.totpEnabled === "true";
|
||||||
|
if (enabled) {
|
||||||
|
if (!confirm("Disable two-factor authentication for your account?")) return;
|
||||||
|
try {
|
||||||
|
await api("DELETE", `/api/admin/admins/${encodeURIComponent(username)}/totp`);
|
||||||
|
showToast("2FA disabled");
|
||||||
|
loadAdmins();
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
} else {
|
||||||
|
openTotpSetup(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
document.getElementById("chpw-username").value = btn.dataset.chpw;
|
document.getElementById("chpw-username").value = btn.dataset.chpw;
|
||||||
@@ -671,6 +717,57 @@ async function loadAdmins() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TOTP setup modal ──────────────────────────────────────────────────────────
|
||||||
|
let _totpUsername = null;
|
||||||
|
|
||||||
|
async function openTotpSetup(username) {
|
||||||
|
_totpUsername = username;
|
||||||
|
document.getElementById("totp-confirm-code").value = "";
|
||||||
|
document.getElementById("totp-error").classList.add("hidden");
|
||||||
|
document.getElementById("totp-modal").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
const data = await api("POST", `/api/admin/admins/${encodeURIComponent(username)}/totp/setup`);
|
||||||
|
// Render QR from base64 PNG returned by the server
|
||||||
|
const canvas = document.getElementById("totp-qr-canvas");
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
canvas.style.width = Math.min(img.width, 220) + "px";
|
||||||
|
canvas.style.height = "auto";
|
||||||
|
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = "data:image/png;base64," + data.qr_image_b64;
|
||||||
|
document.getElementById("totp-uri-fallback").textContent = data.provisioning_uri;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("totp-error").textContent = e.message;
|
||||||
|
document.getElementById("totp-error").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("totp-cancel").addEventListener("click", () => {
|
||||||
|
document.getElementById("totp-modal").classList.add("hidden");
|
||||||
|
_totpUsername = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("totp-confirm-btn").addEventListener("click", async () => {
|
||||||
|
if (!_totpUsername) return;
|
||||||
|
const code = document.getElementById("totp-confirm-code").value.trim();
|
||||||
|
const errEl = document.getElementById("totp-error");
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
if (!code) { errEl.textContent = "Enter the 6-digit code from your app."; errEl.classList.remove("hidden"); return; }
|
||||||
|
try {
|
||||||
|
await api("POST", `/api/admin/admins/${encodeURIComponent(_totpUsername)}/totp/enable`, { otp_code: code });
|
||||||
|
document.getElementById("totp-modal").classList.add("hidden");
|
||||||
|
_totpUsername = null;
|
||||||
|
showToast("Two-factor authentication enabled");
|
||||||
|
loadAdmins();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("btn-new-admin").addEventListener("click", () => {
|
document.getElementById("btn-new-admin").addEventListener("click", () => {
|
||||||
document.getElementById("admin-new-username").value = "";
|
document.getElementById("admin-new-username").value = "";
|
||||||
document.getElementById("admin-new-password").value = "";
|
document.getElementById("admin-new-password").value = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user