Add multi-user support with admin user management

- Rewrite auth.py for multi-user schema (users table with roles)
- Auto-migrate from single-user admin_user table to new schema
- Add @admin_required decorator for protected routes
- Admin routes: /admin/users, /admin/users/new, delete, reset-password
- New templates: admin/users.html, user_new.html, user_created.html, password_reset.html
- Update login.html for username field, base.html and account.html for admin nav
- Max 16 users + 1 admin, session invalidation on delete/password reset

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-03 22:52:39 -05:00
parent a8f6ae1dd2
commit 7b33501495
9 changed files with 998 additions and 75 deletions

View File

@@ -30,13 +30,27 @@ import time
from pathlib import Path from pathlib import Path
from auth import ( from auth import (
MAX_USERS,
admin_required,
can_create_user,
change_password, change_password,
create_admin_user,
create_user, create_user,
delete_user,
generate_temp_password,
get_all_users,
get_current_user,
get_non_admin_count,
get_user_by_id,
get_username, get_username,
is_admin,
is_authenticated, is_authenticated,
login_required, login_required,
login_user,
logout_user,
reset_user_password,
user_exists, user_exists,
verify_password, verify_user_password,
) )
from auth import ( from auth import (
init_app as init_auth, init_app as init_auth,
@@ -204,6 +218,8 @@ def inject_globals():
"auth_enabled": app.config.get("AUTH_ENABLED", True), "auth_enabled": app.config.get("AUTH_ENABLED", True),
"is_authenticated": is_authenticated(), "is_authenticated": is_authenticated(),
"username": get_username() if is_authenticated() else None, "username": get_username() if is_authenticated() else None,
# NEW in v4.1.0 - Admin state
"is_admin": is_admin(),
} }
@@ -1217,7 +1233,7 @@ def decode_page():
except DecryptionError: except DecryptionError:
flash( flash(
"Decryption failed. Check your passphrase, PIN, RSA key, reference photo, and channel key.", "Decryption failed. Check passphrase, PIN, RSA key, reference photo, and channel key.",
"error", "error",
) )
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
@@ -1326,29 +1342,31 @@ def login():
return redirect(url_for("index")) return redirect(url_for("index"))
if request.method == "POST": if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "") password = request.form.get("password", "")
if verify_password(password): user = verify_user_password(username, password)
session["authenticated"] = True if user:
login_user(user)
session.permanent = True session.permanent = True
flash("Login successful", "success") flash("Login successful", "success")
return redirect(url_for("index")) return redirect(url_for("index"))
else: else:
flash("Invalid password", "error") flash("Invalid username or password", "error")
return render_template("login.html", username=get_username()) return render_template("login.html")
@app.route("/logout") @app.route("/logout")
def logout(): def logout():
"""Logout and clear session.""" """Logout and clear session."""
session.clear() logout_user()
flash("Logged out successfully", "success") flash("Logged out successfully", "success")
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route("/setup", methods=["GET", "POST"]) @app.route("/setup", methods=["GET", "POST"])
def setup(): def setup():
"""First-run setup page.""" """First-run setup page - create admin account."""
if not app.config.get("AUTH_ENABLED", True): if not app.config.get("AUTH_ENABLED", True):
return redirect(url_for("index")) return redirect(url_for("index"))
@@ -1360,19 +1378,20 @@ def setup():
password = request.form.get("password", "") password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "") password_confirm = request.form.get("password_confirm", "")
if len(password) < 8: if password != password_confirm:
flash("Password must be at least 8 characters", "error")
elif password != password_confirm:
flash("Passwords do not match", "error") flash("Passwords do not match", "error")
else: else:
try: success, message = create_admin_user(username, password)
create_user(username, password) if success:
session["authenticated"] = True # Auto-login the new admin
user = verify_user_password(username, password)
if user:
login_user(user)
session.permanent = True session.permanent = True
flash("Admin account created successfully!", "success") flash("Admin account created successfully!", "success")
return redirect(url_for("index")) return redirect(url_for("index"))
except Exception as e: else:
flash(f"Error creating account: {e}", "error") flash(message, "error")
return render_template("setup.html") return render_template("setup.html")
@@ -1381,6 +1400,8 @@ def setup():
@login_required @login_required
def account(): def account():
"""Account management page.""" """Account management page."""
current_user = get_current_user()
if request.method == "POST": if request.method == "POST":
current = request.form.get("current_password", "") current = request.form.get("current_password", "")
new = request.form.get("new_password", "") new = request.form.get("new_password", "")
@@ -1389,10 +1410,126 @@ def account():
if new != new_confirm: if new != new_confirm:
flash("New passwords do not match", "error") flash("New passwords do not match", "error")
else: else:
success, message = change_password(current, new) success, message = change_password(current_user.id, current, new)
flash(message, "success" if success else "error") flash(message, "success" if success else "error")
return render_template("account.html", username=get_username()) return render_template(
"account.html",
username=current_user.username,
user=current_user,
is_admin=current_user.is_admin,
)
# ============================================================================
# ADMIN ROUTES (v4.1.0)
# ============================================================================
@app.route("/admin/users")
@admin_required
def admin_users():
"""User management page (admin only)."""
users = get_all_users()
current_user = get_current_user()
return render_template(
"admin/users.html",
users=users,
current_user=current_user,
user_count=get_non_admin_count(),
max_users=MAX_USERS,
can_create=can_create_user(),
)
@app.route("/admin/users/new", methods=["GET", "POST"])
@admin_required
def admin_user_new():
"""Create new user (admin only)."""
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
success, message, user = create_user(username, password)
if success:
flash(f"User '{username}' created successfully", "success")
# Store password temporarily for display
session["temp_password"] = password
session["temp_username"] = username
return redirect(url_for("admin_user_created"))
else:
flash(message, "error")
# Generate a temp password for the form
temp_password = generate_temp_password()
return render_template("admin/user_new.html", temp_password=temp_password)
@app.route("/admin/users/created")
@admin_required
def admin_user_created():
"""Show created user confirmation with password."""
username = session.pop("temp_username", None)
password = session.pop("temp_password", None)
if not username or not password:
return redirect(url_for("admin_users"))
return render_template(
"admin/user_created.html",
username=username,
password=password,
)
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
@admin_required
def admin_user_delete(user_id):
"""Delete a user (admin only)."""
current_user = get_current_user()
success, message = delete_user(user_id, current_user.id)
flash(message, "success" if success else "error")
return redirect(url_for("admin_users"))
@app.route("/admin/users/<int:user_id>/reset-password", methods=["POST"])
@admin_required
def admin_user_reset_password(user_id):
"""Reset a user's password (admin only)."""
user = get_user_by_id(user_id)
if not user:
flash("User not found", "error")
return redirect(url_for("admin_users"))
# Generate new password
new_password = generate_temp_password()
success, message = reset_user_password(user_id, new_password)
if success:
# Store for display
session["temp_password"] = new_password
session["temp_username"] = user.username
return redirect(url_for("admin_user_password_reset"))
else:
flash(message, "error")
return redirect(url_for("admin_users"))
@app.route("/admin/users/password-reset")
@admin_required
def admin_user_password_reset():
"""Show password reset confirmation."""
username = session.pop("temp_username", None)
password = session.pop("temp_password", None)
if not username or not password:
return redirect(url_for("admin_users"))
return render_template(
"admin/password_reset.html",
username=username,
password=password,
)
# ============================================================================ # ============================================================================

View File

@@ -1,17 +1,24 @@
""" """
Stegasoo Authentication Module Stegasoo Authentication Module (v4.1.0)
Single-admin authentication with Argon2 password hashing. Multi-user authentication with role-based access control.
Uses Flask sessions for authentication state and SQLite3 for storage. - Admin user created at first-run setup
- Admin can create up to 16 additional users
- Uses Argon2id password hashing
- Flask sessions for authentication state
- SQLite3 for user storage
""" """
import functools import functools
import secrets
import sqlite3 import sqlite3
import string
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import VerifyMismatchError
from flask import current_app, g, redirect, session, url_for from flask import current_app, flash, g, redirect, session, url_for
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login) # Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
ph = PasswordHasher( ph = PasswordHasher(
@@ -22,6 +29,25 @@ ph = PasswordHasher(
salt_len=16, salt_len=16,
) )
# Constants
MAX_USERS = 16 # Plus 1 admin = 17 total
ROLE_ADMIN = "admin"
ROLE_USER = "user"
@dataclass
class User:
"""User data class."""
id: int
username: str
role: str
created_at: str
@property
def is_admin(self) -> bool:
return self.role == ROLE_ADMIN
def get_db_path() -> Path: def get_db_path() -> Path:
"""Get database path in Flask instance folder.""" """Get database path in Flask instance folder."""
@@ -46,90 +72,488 @@ def close_db(e=None):
def init_db(): def init_db():
"""Initialize database schema.""" """Initialize database schema with migration support."""
db = get_db() db = get_db()
# Check if we need to migrate from old single-user schema
cursor = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
)
has_old_table = cursor.fetchone() is not None
cursor = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
has_new_table = cursor.fetchone() is not None
if has_old_table and not has_new_table:
# Migrate from old schema
_migrate_from_single_user(db)
elif not has_new_table:
# Fresh install - create new schema
_create_schema(db)
def _create_schema(db: sqlite3.Connection):
"""Create the multi-user schema."""
db.executescript(""" db.executescript("""
CREATE TABLE IF NOT EXISTS admin_user ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL DEFAULT 'admin', username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP updated_at TEXT DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
""") """)
db.commit() db.commit()
def user_exists() -> bool: def _migrate_from_single_user(db: sqlite3.Connection):
"""Check if admin user has been created.""" """Migrate from old single-user admin_user table to multi-user users table."""
db = get_db() # Create new table
result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone() _create_schema(db)
return result is not None
# Copy admin user from old table
old_user = db.execute(
"SELECT username, password_hash, created_at FROM admin_user WHERE id = 1"
).fetchone()
def create_user(username: str, password: str): if old_user:
"""Create admin user (first-run setup)."""
if user_exists():
raise ValueError("Admin user already exists")
password_hash = ph.hash(password)
db = get_db()
db.execute( db.execute(
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)", """
(username, password_hash), INSERT INTO users (username, password_hash, role, created_at)
VALUES (?, ?, 'admin', ?)
""",
(old_user["username"], old_user["password_hash"], old_user["created_at"]),
) )
db.commit() db.commit()
# Drop old table
db.execute("DROP TABLE admin_user")
db.commit()
# =============================================================================
# User Queries
# =============================================================================
def any_users_exist() -> bool:
"""Check if any users have been created (for first-run detection)."""
db = get_db()
result = db.execute("SELECT 1 FROM users LIMIT 1").fetchone()
return result is not None
def user_exists() -> bool:
"""Alias for any_users_exist() for backwards compatibility."""
return any_users_exist()
def get_user_count() -> int:
"""Get total number of users."""
db = get_db()
result = db.execute("SELECT COUNT(*) FROM users").fetchone()
return result[0] if result else 0
def get_non_admin_count() -> int:
"""Get number of non-admin users."""
db = get_db()
result = db.execute("SELECT COUNT(*) FROM users WHERE role != 'admin'").fetchone()
return result[0] if result else 0
def can_create_user() -> bool:
"""Check if we can create more users (within limit)."""
return get_non_admin_count() < MAX_USERS
def get_user_by_id(user_id: int) -> User | None:
"""Get user by ID."""
db = get_db()
row = db.execute(
"SELECT id, username, role, created_at FROM users WHERE id = ?", (user_id,)
).fetchone()
if row:
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
return None
def get_user_by_username(username: str) -> User | None:
"""Get user by username."""
db = get_db()
row = db.execute(
"SELECT id, username, role, created_at FROM users WHERE username = ?",
(username,),
).fetchone()
if row:
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
return None
def get_all_users() -> list[User]:
"""Get all users, admins first, then by creation date."""
db = get_db()
rows = db.execute(
"""
SELECT id, username, role, created_at FROM users
ORDER BY role = 'admin' DESC, created_at ASC
"""
).fetchall()
return [
User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
for row in rows
]
def get_current_user() -> User | None:
"""Get the currently logged-in user from session."""
user_id = session.get("user_id")
if user_id:
return get_user_by_id(user_id)
return None
def get_username() -> str: def get_username() -> str:
"""Get the admin username.""" """Get current user's username (backwards compatibility)."""
db = get_db() user = get_current_user()
row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone() return user.username if user else "unknown"
return row["username"] if row else "admin"
def verify_password(password: str) -> bool: # =============================================================================
"""Verify password against stored hash.""" # Authentication
# =============================================================================
def verify_user_password(username: str, password: str) -> User | None:
"""
Verify password for a user.
Returns User if valid, None if invalid.
Also rehashes password if needed.
"""
db = get_db() db = get_db()
row = db.execute("SELECT password_hash FROM admin_user WHERE id = 1").fetchone() row = db.execute(
"SELECT id, username, role, created_at, password_hash FROM users WHERE username = ?",
(username,),
).fetchone()
if not row: if not row:
return False return None
try: try:
ph.verify(row["password_hash"], password) ph.verify(row["password_hash"], password)
# Rehash if parameters changed # Rehash if parameters changed
if ph.check_needs_rehash(row["password_hash"]): if ph.check_needs_rehash(row["password_hash"]):
new_hash = ph.hash(password) new_hash = ph.hash(password)
db.execute( db.execute(
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1", "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash,), (new_hash, row["id"]),
) )
db.commit() db.commit()
return True
return User(
id=row["id"],
username=row["username"],
role=row["role"],
created_at=row["created_at"],
)
except VerifyMismatchError: except VerifyMismatchError:
return None
def verify_password(password: str) -> bool:
"""Verify password for current user (backwards compatibility)."""
user = get_current_user()
if not user:
return False return False
result = verify_user_password(user.username, password)
return result is not None
def change_password(current_password: str, new_password: str) -> tuple[bool, str]:
"""Change admin password. Returns (success, message)."""
if not verify_password(current_password):
return False, "Current password is incorrect"
if len(new_password) < 8:
return False, "New password must be at least 8 characters"
new_hash = ph.hash(new_password)
db = get_db()
db.execute(
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
(new_hash,),
)
db.commit()
return True, "Password changed successfully"
def is_authenticated() -> bool: def is_authenticated() -> bool:
"""Check if current session is authenticated.""" """Check if current session is authenticated."""
return session.get("authenticated", False) return session.get("user_id") is not None
def is_admin() -> bool:
"""Check if current user is an admin."""
user = get_current_user()
return user.is_admin if user else False
def login_user(user: User):
"""Set up session for logged-in user."""
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
# Legacy compatibility
session["authenticated"] = True
def logout_user():
"""Clear session for logout."""
session.clear()
# =============================================================================
# User Management
# =============================================================================
def generate_temp_password(length: int = 8) -> str:
"""Generate a random temporary password."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def validate_username(username: str) -> tuple[bool, str]:
"""
Validate username format.
Rules: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
"""
if not username:
return False, "Username is required"
if len(username) < 3:
return False, "Username must be at least 3 characters"
if len(username) > 80:
return False, "Username must be at most 80 characters"
# Allow: alphanumeric, underscore, hyphen, @, . (for email-style)
allowed = set(string.ascii_letters + string.digits + "_-@.")
if not all(c in allowed for c in username):
return False, "Username can only contain letters, numbers, underscore, hyphen, @ and ."
# Must start with letter or number
if username[0] not in string.ascii_letters + string.digits:
return False, "Username must start with a letter or number"
return True, ""
def validate_password(password: str) -> tuple[bool, str]:
"""Validate password requirements."""
if not password:
return False, "Password is required"
if len(password) < 8:
return False, "Password must be at least 8 characters"
return True, ""
def create_user(
username: str, password: str, role: str = ROLE_USER
) -> tuple[bool, str, User | None]:
"""
Create a new user.
Returns (success, message, user).
"""
# Validate username
valid, msg = validate_username(username)
if not valid:
return False, msg, None
# Validate password
valid, msg = validate_password(password)
if not valid:
return False, msg, None
# Check if username already exists
if get_user_by_username(username):
return False, "Username already exists", None
# Check user limit (only for non-admin users)
if role != ROLE_ADMIN and not can_create_user():
return False, f"Maximum of {MAX_USERS} users reached", None
# Create user
password_hash = ph.hash(password)
db = get_db()
try:
cursor = db.execute(
"""
INSERT INTO users (username, password_hash, role)
VALUES (?, ?, ?)
""",
(username, password_hash, role),
)
db.commit()
user = get_user_by_id(cursor.lastrowid)
return True, "User created successfully", user
except sqlite3.IntegrityError:
return False, "Username already exists", None
def create_admin_user(username: str, password: str) -> tuple[bool, str]:
"""Create the initial admin user (first-run setup)."""
if any_users_exist():
return False, "Admin user already exists"
success, msg, _ = create_user(username, password, ROLE_ADMIN)
return success, msg
def change_password(
user_id: int, current_password: str, new_password: str
) -> tuple[bool, str]:
"""Change a user's password (requires current password)."""
user = get_user_by_id(user_id)
if not user:
return False, "User not found"
# Verify current password
if not verify_user_password(user.username, current_password):
return False, "Current password is incorrect"
# Validate new password
valid, msg = validate_password(new_password)
if not valid:
return False, msg
# Update password
new_hash = ph.hash(new_password)
db = get_db()
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, user_id),
)
db.commit()
return True, "Password changed successfully"
def reset_user_password(user_id: int, new_password: str) -> tuple[bool, str]:
"""Reset a user's password (admin function, no current password required)."""
user = get_user_by_id(user_id)
if not user:
return False, "User not found"
# Validate new password
valid, msg = validate_password(new_password)
if not valid:
return False, msg
# Update password
new_hash = ph.hash(new_password)
db = get_db()
db.execute(
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_hash, user_id),
)
db.commit()
# Invalidate user's sessions
invalidate_user_sessions(user_id)
return True, "Password reset successfully"
def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
"""
Delete a user.
Cannot delete yourself or the last admin.
"""
if user_id == current_user_id:
return False, "Cannot delete yourself"
user = get_user_by_id(user_id)
if not user:
return False, "User not found"
# Check if this is the last admin
if user.role == ROLE_ADMIN:
db = get_db()
admin_count = db.execute(
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetchone()[0]
if admin_count <= 1:
return False, "Cannot delete the last admin"
# Invalidate user's sessions before deletion
invalidate_user_sessions(user_id)
# Delete user
db = get_db()
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
db.commit()
return True, f"User '{user.username}' deleted"
def invalidate_user_sessions(user_id: int):
"""
Invalidate all sessions for a user.
This is called when a user is deleted or their password is reset.
Since we use server-side sessions, we increment a "session version"
that's checked on each request.
"""
# For Flask's default session (client-side), we can't truly invalidate.
# But we can add a check - store a "valid_from" timestamp in the DB
# and compare against session creation time.
#
# For now, we'll use a simpler approach: store invalidated user IDs
# in app config (memory) which gets checked by login_required.
#
# This works for single-process deployments (like RPi).
# For multi-process, would need Redis or DB-backed sessions.
if "invalidated_users" not in current_app.config:
current_app.config["invalidated_users"] = set()
current_app.config["invalidated_users"].add(user_id)
def is_session_valid() -> bool:
"""Check if current session is still valid (user not deleted/invalidated)."""
user_id = session.get("user_id")
if not user_id:
return False
# Check if user was invalidated
invalidated = current_app.config.get("invalidated_users", set())
if user_id in invalidated:
return False
# Check if user still exists
if not get_user_by_id(user_id):
return False
return True
# =============================================================================
# Decorators
# =============================================================================
def login_required(f): def login_required(f):
@@ -142,18 +566,62 @@ def login_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
# Check for first-run setup # Check for first-run setup
if not user_exists(): if not any_users_exist():
return redirect(url_for("setup")) return redirect(url_for("setup"))
# Check authentication # Check authentication
if not is_authenticated(): if not is_authenticated():
return redirect(url_for("login")) return redirect(url_for("login"))
# Check if session is still valid (user not deleted)
if not is_session_valid():
logout_user()
flash("Your session has expired. Please log in again.", "warning")
return redirect(url_for("login"))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def admin_required(f):
"""Decorator to require admin role for a route."""
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# Check if auth is enabled
if not current_app.config.get("AUTH_ENABLED", True):
return f(*args, **kwargs)
# Check for first-run setup
if not any_users_exist():
return redirect(url_for("setup"))
# Check authentication
if not is_authenticated():
return redirect(url_for("login"))
# Check if session is still valid
if not is_session_valid():
logout_user()
flash("Your session has expired. Please log in again.", "warning")
return redirect(url_for("login"))
# Check admin role
if not is_admin():
flash("Admin access required", "error")
return redirect(url_for("index"))
return f(*args, **kwargs)
return decorated_function
# =============================================================================
# App Initialization
# =============================================================================
def init_app(app): def init_app(app):
"""Initialize auth module with Flask app.""" """Initialize auth module with Flask app."""
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)

View File

@@ -12,8 +12,21 @@
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Logged in as <strong>{{ username }}</strong> Logged in as <strong>{{ username }}</strong>
{% if is_admin %}
<span class="badge bg-warning text-dark ms-2">
<i class="bi bi-shield-check me-1"></i>Admin
</span>
{% endif %}
</p> </p>
{% if is_admin %}
<div class="mb-4">
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-primary w-100">
<i class="bi bi-people me-2"></i>Manage Users
</a>
</div>
{% endif %}
<h6 class="text-muted mb-3">Change Password</h6> <h6 class="text-muted mb-3">Change Password</h6>
<form method="POST" action="{{ url_for('account') }}" id="accountForm"> <form method="POST" action="{{ url_for('account') }}" id="accountForm">

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Password Reset - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card border-warning">
<div class="card-header bg-warning text-dark">
<i class="bi bi-key fs-4 me-2"></i>
<span class="fs-5">Password Reset</span>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Important:</strong> This password will only be shown once.
Make sure to share it with <strong>{{ username }}</strong> securely.
</div>
<p class="text-muted">
The user's sessions have been invalidated. They will need to log in
again with the new password.
</p>
<div class="mb-4">
<label class="form-label text-muted small">New Password for {{ username }}</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg font-monospace"
value="{{ password }}" readonly id="passwordField">
<button class="btn btn-outline-secondary" type="button"
onclick="copyField('passwordField')" title="Copy password">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="d-grid">
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">
<i class="bi bi-arrow-left me-2"></i>Back to Users
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function copyField(fieldId) {
const field = document.getElementById(fieldId);
field.select();
document.execCommand('copy');
// Show brief feedback
const btn = field.nextElementSibling;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check"></i>';
setTimeout(() => btn.innerHTML = originalHTML, 1000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}User Created - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card border-success">
<div class="card-header bg-success text-white">
<i class="bi bi-check-circle fs-4 me-2"></i>
<span class="fs-5">User Created Successfully</span>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Important:</strong> This password will only be shown once.
Make sure to share it with the user securely.
</div>
<div class="mb-3">
<label class="form-label text-muted small">Username</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg font-monospace"
value="{{ username }}" readonly id="usernameField">
<button class="btn btn-outline-secondary" type="button"
onclick="copyField('usernameField')" title="Copy username">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="mb-4">
<label class="form-label text-muted small">Password</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg font-monospace"
value="{{ password }}" readonly id="passwordField">
<button class="btn btn-outline-secondary" type="button"
onclick="copyField('passwordField')" title="Copy password">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="d-grid gap-2">
<a href="{{ url_for('admin_user_new') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-2"></i>Add Another User
</a>
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Users
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function copyField(fieldId) {
const field = document.getElementById(fieldId);
field.select();
document.execCommand('copy');
// Show brief feedback
const btn = field.nextElementSibling;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check"></i>';
setTimeout(() => btn.innerHTML = originalHTML, 1000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Add User - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card">
<div class="card-header">
<i class="bi bi-person-plus fs-4 me-2"></i>
<span class="fs-5">Add New User</span>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin_user_new') }}">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
placeholder="e.g., john_doe or john@example.com"
pattern="[a-zA-Z0-9][a-zA-Z0-9_\-@.]{2,79}"
title="3-80 characters, letters/numbers/underscore/hyphen/@/."
required autofocus>
<div class="form-text">
Letters, numbers, underscore, hyphen, @ and . allowed.
</div>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key me-1"></i> Password
</label>
<div class="input-group">
<input type="text" name="password" id="passwordInput"
class="form-control" value="{{ temp_password }}"
minlength="8" required>
<button class="btn btn-outline-secondary" type="button"
onclick="regeneratePassword()" title="Generate new password">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
<div class="form-text">
Auto-generated password. You can edit or regenerate it.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary flex-grow-1">
<i class="bi bi-person-check me-2"></i>Create User
</button>
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function regeneratePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let password = '';
for (let i = 0; i < 8; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
document.getElementById('passwordInput').value = password;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Manage Users - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-10 col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-people fs-4 me-2"></i>
<span class="fs-5">User Management</span>
</div>
<div class="text-muted small">
{{ user_count }} / {{ max_users }} users
</div>
</div>
<div class="card-body">
{% if can_create %}
<div class="mb-4">
<a href="{{ url_for('admin_user_new') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-2"></i>Add User
</a>
</div>
{% else %}
<div class="alert alert-warning mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Maximum of {{ max_users }} users reached.
</div>
{% endif %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<i class="bi bi-person me-2"></i>
{{ user.username }}
{% if user.id == current_user.id %}
<span class="badge bg-info ms-2">You</span>
{% endif %}
</td>
<td>
{% if user.is_admin %}
<span class="badge bg-warning text-dark">
<i class="bi bi-shield-check me-1"></i>Admin
</span>
{% else %}
<span class="badge bg-secondary">User</span>
{% endif %}
</td>
<td class="text-muted small">
{{ user.created_at[:10] if user.created_at else 'Unknown' }}
</td>
<td class="text-end">
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('admin_user_reset_password', user_id=user.id) }}"
class="d-inline" onsubmit="return confirm('Reset password for {{ user.username }}?')">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Reset Password">
<i class="bi bi-key"></i>
</button>
</form>
<form method="POST" action="{{ url_for('admin_user_delete', user_id=user.id) }}"
class="d-inline" onsubmit="return confirm('Delete user {{ user.username }}? This cannot be undone.')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete User">
<i class="bi bi-trash"></i>
</button>
</form>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small">
<i class="bi bi-info-circle me-1"></i>
Admins can add up to {{ max_users }} regular users.
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -46,6 +46,9 @@
</a> </a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark"> <ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li> <li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
{% if is_admin %}
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li> <li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
</ul> </ul>

View File

@@ -17,7 +17,7 @@
<i class="bi bi-person me-1"></i> Username <i class="bi bi-person me-1"></i> Username
</label> </label>
<input type="text" name="username" class="form-control" <input type="text" name="username" class="form-control"
value="{{ username }}" readonly> placeholder="Enter your username" required autofocus>
</div> </div>
<div class="mb-4"> <div class="mb-4">
@@ -26,7 +26,7 @@
</label> </label>
<div class="input-group"> <div class="input-group">
<input type="password" name="password" class="form-control" <input type="password" name="password" class="form-control"
id="passwordInput" required autofocus> id="passwordInput" required>
<button class="btn btn-outline-secondary" type="button" <button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)"> onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>