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:
@@ -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.
|
||||
|
||||
255
backend/alembic/versions/005_add_core_tables.py
Normal file
255
backend/alembic/versions/005_add_core_tables.py
Normal 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).
|
||||
Reference in New Issue
Block a user