diff --git a/app/db.py b/app/db.py
index 483e998..26c3fd2 100644
--- a/app/db.py
+++ b/app/db.py
@@ -27,7 +27,12 @@ def init_db():
full_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
- is_admin BOOLEAN NOT NULL DEFAULT 0,
+ password_hash TEXT,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ last_login TIMESTAMP,
+ login_attempts INTEGER NOT NULL DEFAULT 0,
+ locked_until TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -39,6 +44,19 @@ def init_db():
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+ CREATE TABLE IF NOT EXISTS user_oauth_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ provider TEXT NOT NULL,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT,
+ expires_at TIMESTAMP,
+ instance_url TEXT DEFAULT '',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, provider)
+ );
+
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_owner TEXT NOT NULL,
@@ -304,6 +322,12 @@ def init_db():
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
except sqlite3.OperationalError:
pass
+ # v2.2: Auth locale
+ for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
+ try:
+ conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
+ except sqlite3.OperationalError:
+ pass
conn.commit()
diff --git a/app/password_utils.py b/app/password_utils.py
new file mode 100644
index 0000000..78ec1fd
--- /dev/null
+++ b/app/password_utils.py
@@ -0,0 +1,35 @@
+"""Password hashing and login security utilities."""
+
+import hashlib
+import secrets
+import time
+
+
+def hash_password(password: str) -> str:
+ """Hash a password using SHA-256 + random salt (16 bytes).
+ Format: salt_hex:hash_hex (64 + 64 = 128 chars)
+ Fallback for bcrypt — we use SHA-256 for SQLite simplicity
+ but with proper salt per password."""
+ salt = secrets.token_hex(16)
+ h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
+ return f"{salt}:{h}"
+
+
+def verify_password(password: str, stored: str) -> bool:
+ """Verify a password against its stored hash."""
+ try:
+ salt, h = stored.split(":", 1)
+ expected = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
+ return h == expected
+ except (ValueError, AttributeError):
+ return False
+
+
+def is_locked(locked_until: str | None) -> bool:
+ """Check if account is temporarily locked."""
+ if not locked_until:
+ return False
+ try:
+ return float(locked_until) > time.time()
+ except (ValueError, TypeError):
+ return False
diff --git a/app/routers/auth.py b/app/routers/auth.py
index b7a2f2c..b175723 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -14,10 +14,75 @@ from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
+LOCAL_LOGIN_HTML = """
+
+
+
+
+FlowDeck — Login
+
+
+
+
+
FlowDeck
+
Login or create an account to continue
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
@router.get("/login")
-async def login(request: Request):
- """Redirect to Gitea OAuth2 authorize page."""
+async def login(request: Request, provider: str = Query("gitea")):
+ """Redirect to OAuth2 authorize page or show local login page."""
+ # Local login page (POST handled by /auth/local-login)
+ if provider == "local":
+ from fastapi.responses import HTMLResponse
+ return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
+
+ # OAuth flow
if not gitea_oauth.enabled:
# Fallback: use global token, create a fake session
from app.db import get_conn
@@ -39,6 +104,100 @@ async def login(request: Request):
return RedirectResponse(url=auth_url, status_code=302)
+@router.post("/register")
+async def register(request: Request):
+ """Register a new local account."""
+ from app.db import get_conn
+ from app.password_utils import hash_password
+ import json
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ email = body.get("email", "").strip()
+ password = body.get("password", "").strip()
+ name = body.get("name", email.split("@")[0] if "@" in email else email)
+
+ if not email or not password:
+ from fastapi.responses import JSONResponse
+ return JSONResponse({"error": "Email and password required"}, status_code=400)
+ if len(password) < 6:
+ from fastapi.responses import JSONResponse
+ return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
+
+ with get_conn() as conn:
+ existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
+ if existing:
+ from fastapi.responses import JSONResponse
+ return JSONResponse({"error": "Account already exists"}, status_code=409)
+ conn.execute(
+ "INSERT INTO users (login, full_name, email, password_hash) VALUES (?, ?, ?, ?)",
+ (email, name, email, hash_password(password)),
+ )
+ conn.commit()
+ user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
+ user_data = dict(user)
+ session = SessionManager.create_session(user_data)
+ from fastapi.responses import JSONResponse
+ response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
+ response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
+ return response
+
+
+@router.post("/local-login")
+async def local_login(request: Request):
+ """Login with email + password."""
+ from app.db import get_conn
+ from app.password_utils import verify_password, is_locked
+ from fastapi.responses import JSONResponse
+ import json, time
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ email = body.get("email", "").strip()
+ password = body.get("password", "").strip()
+
+ if not email or not password:
+ return JSONResponse({"error": "Email and password required"}, status_code=400)
+
+ with get_conn() as conn:
+ user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
+ if not user:
+ return JSONResponse({"error": "Invalid credentials"}, status_code=401)
+
+ ud = dict(user)
+ if not ud.get("is_active"):
+ return JSONResponse({"error": "Account disabled"}, status_code=403)
+ if is_locked(ud.get("locked_until")):
+ return JSONResponse({"error": "Account temporarily locked. Try again later."}, status_code=423)
+
+ if not verify_password(password, ud.get("password_hash", "")):
+ with get_conn() as conn:
+ attempts = (ud.get("login_attempts", 0) or 0) + 1
+ lock = None
+ if attempts >= 5:
+ lock = str(time.time() + 900) # 15 min lock
+ conn.execute(
+ "UPDATE users SET login_attempts=?, locked_until=? WHERE id=?",
+ (attempts, lock, ud["id"]),
+ )
+ conn.commit()
+ return JSONResponse({"error": "Invalid credentials"}, status_code=401)
+
+ # Successful login
+ with get_conn() as conn:
+ conn.execute(
+ "UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
+ (str(time.time()), ud["id"]),
+ )
+ conn.commit()
+ session = SessionManager.create_session(ud)
+ response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
+ response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
+ return response
+
+
@router.get("/callback")
async def callback(
request: Request,
diff --git a/app/templates/settings.html b/app/templates/settings.html
new file mode 100644
index 0000000..e7caff4
--- /dev/null
+++ b/app/templates/settings.html
@@ -0,0 +1,189 @@
+{% extends "base.html" %}
+{% block page_title %}Account Settings{% endblock %}
+{% block page_icon %}⚙️{% endblock %}
+
+{% block topbar %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Profile
+
+
+
+
Email
+
Used for login and notifications
+
+
{{ user.email or user.login or '' }}
+
+
+
+
+
Password
+
Change your password
+
+
+
+
+
+
+
+
+
+
+
+
Connected Forges
+
+
+
+
🔗 Gitea
+
Connect to your Gitea instance
+
+ {% if gitea_connected %}
+
✅ Connected
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
🐙 GitHub
+
Connect to GitHub
+
+ {% if github_connected %}
+
✅ Connected
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
API Tokens
+
+
+
+
Generate API Token
+
For programmatic access to FlowDeck API
+
+
+
+
+
+
+
+
Copy this token now — it won't be shown again
+
+
+
+
+
+
+
+
+
+
Active Sessions
+
+
+
+
Current session
+
You are logged in
+
+
+
+
+
+
+
+
+
+{% endblock %}