Add comprehensive backend features and mobile UI improvements

Backend:
- Add 2FA authentication with TOTP support
- Add API keys management system
- Add audit logging for security events
- Add file upload/management system
- Add notifications system with preferences
- Add session management
- Add webhooks integration
- Add analytics endpoints
- Add export functionality
- Add password policy enforcement
- Add new database migrations for core tables

Frontend:
- Add module position system (top/bottom sidebar sections)
- Add search and notifications module configuration tabs
- Add mobile logo replacing hamburger menu
- Center page title absolutely when no tabs present
- Align sidebar footer toggles with navigation items
- Add lighter icon color in dark theme for mobile
- Add API keys management page
- Add notifications page with context
- Add admin analytics and audit logs pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-17 22:27:32 +01:00
parent f698aa4d51
commit 8c4a555b88
76 changed files with 9751 additions and 323 deletions

View File

@@ -16,6 +16,12 @@ from app.config import settings
# Import all models so Alembic can detect them for auto-generating migrations
from app.models.user import User # noqa
from app.models.settings import Settings # noqa
from app.models.audit_log import AuditLog # noqa
from app.models.api_key import APIKey # noqa
from app.models.notification import Notification # noqa
from app.models.session import UserSession # noqa
from app.models.webhook import Webhook, WebhookDelivery # noqa
from app.models.file import StoredFile # noqa
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.

View File

@@ -0,0 +1,255 @@
"""Add core tables (audit, sessions, notifications, webhooks, files, api keys, 2FA fields)
Revision ID: 005
Revises: 004
Create Date: 2025-12-15 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(inspector, table_name: str, column_name: str) -> bool:
try:
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
except Exception:
return False
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = set(inspector.get_table_names())
# -------------------------------------------------------------------------
# Users table: add 2FA columns if missing
# -------------------------------------------------------------------------
if "users" in existing_tables:
if not _has_column(inspector, "users", "totp_secret"):
op.add_column("users", sa.Column("totp_secret", sa.String(length=32), nullable=True))
if not _has_column(inspector, "users", "totp_enabled"):
op.add_column(
"users",
sa.Column("totp_enabled", sa.Boolean(), nullable=False, server_default="0"),
)
if not _has_column(inspector, "users", "totp_backup_codes"):
op.add_column("users", sa.Column("totp_backup_codes", sa.Text(), nullable=True))
# -------------------------------------------------------------------------
# Audit logs
# -------------------------------------------------------------------------
if "audit_logs" not in existing_tables:
op.create_table(
"audit_logs",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("username", sa.String(length=100), nullable=True),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("resource_type", sa.String(length=50), nullable=True),
sa.Column("resource_id", sa.String(length=255), nullable=True),
sa.Column("details", sa.Text(), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'success'")),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
)
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
op.create_index("ix_audit_logs_resource_type", "audit_logs", ["resource_type"])
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
op.create_index("ix_audit_user_action", "audit_logs", ["user_id", "action"])
op.create_index("ix_audit_resource", "audit_logs", ["resource_type", "resource_id"])
# -------------------------------------------------------------------------
# User sessions
# -------------------------------------------------------------------------
if "user_sessions" not in existing_tables:
op.create_table(
"user_sessions",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("device_name", sa.String(length=200), nullable=True),
sa.Column("device_type", sa.String(length=50), nullable=True),
sa.Column("browser", sa.String(length=100), nullable=True),
sa.Column("os", sa.String(length=100), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("location", sa.String(length=200), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("is_current", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("last_active_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"])
op.create_index("ix_user_sessions_token_hash", "user_sessions", ["token_hash"], unique=True)
op.create_index("ix_user_sessions_created_at", "user_sessions", ["created_at"])
# -------------------------------------------------------------------------
# Notifications
# -------------------------------------------------------------------------
if "notifications" not in existing_tables:
op.create_table(
"notifications",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("type", sa.String(length=50), nullable=False, server_default=sa.text("'info'")),
sa.Column("link", sa.String(length=500), nullable=True),
sa.Column("metadata", sa.Text(), nullable=True),
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("read_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
op.create_index("ix_notifications_is_read", "notifications", ["is_read"])
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
# -------------------------------------------------------------------------
# API keys
# -------------------------------------------------------------------------
if "api_keys" not in existing_tables:
op.create_table(
"api_keys",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("key_hash", sa.String(length=255), nullable=False),
sa.Column("key_prefix", sa.String(length=20), nullable=False),
sa.Column("scopes", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("last_used_ip", sa.String(length=45), nullable=True),
sa.Column("usage_count", sa.String(length=20), nullable=False, server_default="0"),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
# -------------------------------------------------------------------------
# Webhooks
# -------------------------------------------------------------------------
if "webhooks" not in existing_tables:
op.create_table(
"webhooks",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("url", sa.String(length=500), nullable=False),
sa.Column("secret", sa.String(length=64), nullable=True),
sa.Column("events", sa.Text(), nullable=False, server_default=sa.text("'[\"*\"]'")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="3"),
sa.Column("timeout_seconds", sa.Integer(), nullable=False, server_default="30"),
sa.Column("created_by", sa.String(length=36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("last_triggered_at", sa.DateTime(), nullable=True),
sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failure_count", sa.Integer(), nullable=False, server_default="0"),
)
if "webhook_deliveries" not in existing_tables:
op.create_table(
"webhook_deliveries",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("webhook_id", sa.String(length=36), sa.ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("payload", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("response_body", sa.Text(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_retry_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("delivered_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_webhook_deliveries_webhook_id", "webhook_deliveries", ["webhook_id"])
op.create_index("ix_webhook_deliveries_status", "webhook_deliveries", ["status"])
# -------------------------------------------------------------------------
# Stored files
# -------------------------------------------------------------------------
if "stored_files" not in existing_tables:
op.create_table(
"stored_files",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("original_filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=100), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("storage_path", sa.String(length=500), nullable=False),
sa.Column("storage_type", sa.String(length=20), nullable=False, server_default=sa.text("'local'")),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("tags", sa.Text(), nullable=True),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("uploaded_by", sa.String(length=36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("file_hash", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_stored_files_file_hash", "stored_files", ["file_hash"])
def downgrade() -> None:
# Best-effort downgrade (may not be supported on SQLite for some operations).
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = set(inspector.get_table_names())
if "stored_files" in existing_tables:
op.drop_index("ix_stored_files_file_hash", table_name="stored_files")
op.drop_table("stored_files")
if "webhook_deliveries" in existing_tables:
op.drop_index("ix_webhook_deliveries_status", table_name="webhook_deliveries")
op.drop_index("ix_webhook_deliveries_webhook_id", table_name="webhook_deliveries")
op.drop_table("webhook_deliveries")
if "webhooks" in existing_tables:
op.drop_table("webhooks")
if "api_keys" in existing_tables:
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
op.drop_index("ix_api_keys_user_id", table_name="api_keys")
op.drop_table("api_keys")
if "notifications" in existing_tables:
op.drop_index("ix_notifications_created_at", table_name="notifications")
op.drop_index("ix_notifications_is_read", table_name="notifications")
op.drop_index("ix_notifications_user_id", table_name="notifications")
op.drop_table("notifications")
if "user_sessions" in existing_tables:
op.drop_index("ix_user_sessions_created_at", table_name="user_sessions")
op.drop_index("ix_user_sessions_token_hash", table_name="user_sessions")
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
op.drop_table("user_sessions")
if "audit_logs" in existing_tables:
op.drop_index("ix_audit_resource", table_name="audit_logs")
op.drop_index("ix_audit_user_action", table_name="audit_logs")
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
op.drop_index("ix_audit_logs_resource_type", table_name="audit_logs")
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_table("audit_logs")
# Columns on users are intentionally not removed (SQLite limitations).