This commit is contained in:
retoor 2026-01-05 21:50:41 +01:00
parent 7e75f62219
commit 6950455bbf
16 changed files with 2316 additions and 2 deletions

View File

@ -27,3 +27,8 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=no-reply@example.com SMTP_FROM_EMAIL=no-reply@example.com
TOTP_ISSUER=MyWebdav TOTP_ISSUER=MyWebdav
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-password
ADMIN_SESSION_SECRET=change-this-to-a-random-secret
ADMIN_SESSION_EXPIRE_HOURS=24

View File

@ -50,8 +50,11 @@ MyWebdav stands out as a premier cloud storage SaaS solution, offering the cheap
- **Webhook Support**: Integration with external services via webhooks - **Webhook Support**: Integration with external services via webhooks
### Administration ### Administration
- **Admin Panel**: Full-featured backend panel at `/manage/` for system administration
- **User Management**: Create, edit, delete users; manage quotas and subscriptions
- **Payment Overview**: Invoice tracking, payment status, revenue reporting
- **Pricing Configuration**: Adjust storage and bandwidth pricing
- **Usage Analytics**: Detailed reporting on storage consumption and bandwidth usage - **Usage Analytics**: Detailed reporting on storage consumption and bandwidth usage
- **Admin Console**: Centralized user management and system monitoring
- **API Access**: RESTful API for third-party integrations - **API Access**: RESTful API for third-party integrations
## Pricing ## Pricing
@ -94,7 +97,15 @@ Access the web application through your browser. The interface provides:
- Folder management and navigation - Folder management and navigation
- Search and filtering capabilities - Search and filtering capabilities
- User profile and settings - User profile and settings
- Administrative controls (for admins)
### Admin Panel
Access the administration panel at `/manage/` to:
- View system statistics and revenue
- Manage users, quotas, and subscriptions
- Review invoices and payment status
- Configure pricing settings
Admin credentials are configured via environment variables (see `.env.example`).
### API Usage ### API Usage
MyWebdav provides a comprehensive REST API. Example requests: MyWebdav provides a comprehensive REST API. Example requests:

96
mywebdav/admin_auth.py Normal file
View File

@ -0,0 +1,96 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta
from typing import Optional
import secrets
import hashlib
from fastapi import Request, HTTPException
from fastapi.responses import RedirectResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from mywebdav.settings import settings
class AdminSessionManager:
def __init__(self):
self.serializer = URLSafeTimedSerializer(settings.ADMIN_SESSION_SECRET)
self.cookie_name = "admin_session"
self.max_age = settings.ADMIN_SESSION_EXPIRE_HOURS * 3600
def create_session(self, username: str) -> str:
data = {
"username": username,
"created": datetime.utcnow().isoformat(),
"nonce": secrets.token_hex(8)
}
return self.serializer.dumps(data)
def verify_session(self, token: str) -> Optional[dict]:
try:
data = self.serializer.loads(token, max_age=self.max_age)
return data
except (BadSignature, SignatureExpired):
return None
def get_session_from_request(self, request: Request) -> Optional[dict]:
token = request.cookies.get(self.cookie_name)
if not token:
return None
return self.verify_session(token)
session_manager = AdminSessionManager()
def verify_admin_credentials(username: str, password: str) -> bool:
expected_username = settings.ADMIN_USERNAME
expected_password = settings.ADMIN_PASSWORD
username_hash = hashlib.sha256(username.encode()).digest()
expected_hash = hashlib.sha256(expected_username.encode()).digest()
username_match = secrets.compare_digest(username_hash, expected_hash)
password_hash = hashlib.sha256(password.encode()).digest()
expected_pw_hash = hashlib.sha256(expected_password.encode()).digest()
password_match = secrets.compare_digest(password_hash, expected_pw_hash)
return username_match and password_match
def get_admin_session(request: Request) -> Optional[dict]:
return session_manager.get_session_from_request(request)
def require_admin(request: Request) -> dict:
session = get_admin_session(request)
if not session:
raise HTTPException(status_code=303, headers={"Location": "/manage/login"})
return session
def create_session_response(response: RedirectResponse, username: str) -> RedirectResponse:
token = session_manager.create_session(username)
response.set_cookie(
key=session_manager.cookie_name,
value=token,
max_age=session_manager.max_age,
httponly=True,
samesite="lax",
secure=False
)
return response
def clear_session_response(response: RedirectResponse) -> RedirectResponse:
response.delete_cookie(key=session_manager.cookie_name)
return response
def generate_csrf_token(session: dict) -> str:
data = f"{session.get('nonce', '')}{settings.ADMIN_SESSION_SECRET}"
return hashlib.sha256(data.encode()).hexdigest()[:32]
def verify_csrf_token(session: dict, token: str) -> bool:
expected = generate_csrf_token(session)
return secrets.compare_digest(expected, token)

View File

@ -19,6 +19,7 @@ from .routers import (
starred, starred,
billing, billing,
admin_billing, admin_billing,
manage,
) )
from . import webdav from . import webdav
from .schemas import ErrorResponse from .schemas import ErrorResponse
@ -227,6 +228,7 @@ app.include_router(admin.router)
app.include_router(starred.router) app.include_router(starred.router)
app.include_router(billing.router) app.include_router(billing.router)
app.include_router(admin_billing.router) app.include_router(admin_billing.router)
app.include_router(manage.router)
app.include_router(webdav.router) app.include_router(webdav.router)
app.include_router(health_router) app.include_router(health_router)

View File

@ -0,0 +1,28 @@
# retoor <retoor@molodetz.nl>
from . import (
admin,
admin_billing,
auth,
billing,
files,
folders,
manage,
search,
shares,
starred,
users,
)
__all__ = [
"admin",
"admin_billing",
"auth",
"billing",
"files",
"folders",
"manage",
"search",
"shares",
"starred",
"users",
]

417
mywebdav/routers/manage.py Normal file
View File

@ -0,0 +1,417 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
import math
from fastapi import APIRouter, Request, Form, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from mywebdav.models import User, Activity
from mywebdav.billing.models import (
Invoice, InvoiceLineItem, PricingConfig,
UserSubscription, UsageAggregate
)
from mywebdav.admin_auth import (
verify_admin_credentials, get_admin_session,
create_session_response, clear_session_response,
generate_csrf_token, verify_csrf_token
)
from mywebdav.auth import get_password_hash
router = APIRouter(prefix="/manage", tags=["admin-panel"])
templates = Jinja2Templates(directory="mywebdav/templates")
def format_bytes(bytes_value: int) -> str:
if bytes_value is None:
return "0 B"
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if abs(bytes_value) < 1024.0:
return f"{bytes_value:.1f} {unit}"
bytes_value /= 1024.0
return f"{bytes_value:.1f} PB"
def format_currency(value: float) -> str:
return f"${value:.2f}"
templates.env.filters['format_bytes'] = format_bytes
templates.env.filters['format_currency'] = format_currency
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None):
session = get_admin_session(request)
if session:
return RedirectResponse(url="/manage/", status_code=303)
return templates.TemplateResponse("admin/login.html", {
"request": request,
"error": error
})
@router.post("/login")
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...)
):
if verify_admin_credentials(username, password):
response = RedirectResponse(url="/manage/", status_code=303)
return create_session_response(response, username)
return RedirectResponse(url="/manage/login?error=invalid", status_code=303)
@router.get("/logout")
async def logout(request: Request):
response = RedirectResponse(url="/manage/login", status_code=303)
return clear_session_response(response)
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
total_users = await User.all().count()
active_users = await User.filter(is_active=True).count()
inactive_users = total_users - active_users
total_storage = 0
users = await User.all()
for user in users:
total_storage += user.used_storage_bytes or 0
current_month = date.today().replace(day=1)
paid_invoices = await Invoice.filter(
status="paid",
period_start__gte=current_month
).all()
monthly_revenue = sum(float(inv.total) for inv in paid_invoices)
pending_invoices = await Invoice.filter(status="open").count()
recent_activities = await Activity.all().order_by("-timestamp").limit(10)
activity_list = []
for act in recent_activities:
user = await User.get_or_none(id=act.user_id)
activity_list.append({
"user": user.username if user else "Unknown",
"action": act.action,
"timestamp": act.timestamp
})
return templates.TemplateResponse("admin/dashboard.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"stats": {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"total_storage": total_storage,
"monthly_revenue": monthly_revenue,
"pending_invoices": pending_invoices
},
"recent_activities": activity_list
})
@router.get("/users", response_class=HTMLResponse)
async def users_list(
request: Request,
search: Optional[str] = None,
status: Optional[str] = None,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=5, le=100)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
query = User.all()
if search:
query = query.filter(username__icontains=search) | User.filter(email__icontains=search)
if status == "active":
query = query.filter(is_active=True)
elif status == "inactive":
query = query.filter(is_active=False)
elif status == "superuser":
query = query.filter(is_superuser=True)
total = await query.count()
total_pages = math.ceil(total / per_page) if total > 0 else 1
offset = (page - 1) * per_page
users = await query.order_by("-created_at").offset(offset).limit(per_page)
return templates.TemplateResponse("admin/users/list.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"users": users,
"search": search or "",
"status": status or "",
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages
})
@router.get("/users/{user_id}", response_class=HTMLResponse)
async def user_detail(request: Request, user_id: int):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
user = await User.get_or_none(id=user_id)
if not user:
return RedirectResponse(url="/manage/users?error=not_found", status_code=303)
subscription = await UserSubscription.get_or_none(user_id=user_id)
invoices = await Invoice.filter(user_id=user_id).order_by("-created_at").limit(5)
usage_percent = 0
if user.storage_quota_bytes and user.storage_quota_bytes > 0:
usage_percent = (user.used_storage_bytes or 0) / user.storage_quota_bytes * 100
return templates.TemplateResponse("admin/users/detail.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"user": user,
"subscription": subscription,
"invoices": invoices,
"usage_percent": min(100, usage_percent)
})
@router.post("/users/{user_id}")
async def user_update(
request: Request,
user_id: int,
csrf_token: str = Form(...),
username: str = Form(...),
email: str = Form(...),
password: Optional[str] = Form(None),
storage_quota_gb: float = Form(...),
plan_type: str = Form(...),
is_active: bool = Form(False),
is_superuser: bool = Form(False)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url=f"/manage/users/{user_id}?error=csrf", status_code=303)
user = await User.get_or_none(id=user_id)
if not user:
return RedirectResponse(url="/manage/users?error=not_found", status_code=303)
user.username = username
user.email = email
user.storage_quota_bytes = int(storage_quota_gb * 1024 * 1024 * 1024)
user.plan_type = plan_type
user.is_active = is_active
user.is_superuser = is_superuser
if password and password.strip():
user.hashed_password = get_password_hash(password)
await user.save()
return RedirectResponse(url=f"/manage/users/{user_id}?success=1", status_code=303)
@router.post("/users/{user_id}/delete")
async def user_delete(
request: Request,
user_id: int,
csrf_token: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url=f"/manage/users/{user_id}?error=csrf", status_code=303)
user = await User.get_or_none(id=user_id)
if user:
await user.delete()
return RedirectResponse(url="/manage/users?deleted=1", status_code=303)
@router.post("/users/{user_id}/toggle-active")
async def user_toggle_active(
request: Request,
user_id: int,
csrf_token: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url="/manage/users?error=csrf", status_code=303)
user = await User.get_or_none(id=user_id)
if user:
user.is_active = not user.is_active
await user.save()
return RedirectResponse(url="/manage/users", status_code=303)
@router.get("/payments", response_class=HTMLResponse)
async def payments_list(
request: Request,
status: Optional[str] = None,
user_id: Optional[int] = None,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=5, le=100)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
query = Invoice.all()
if status:
query = query.filter(status=status)
if user_id:
query = query.filter(user_id=user_id)
total = await query.count()
total_pages = math.ceil(total / per_page) if total > 0 else 1
offset = (page - 1) * per_page
invoices = await query.order_by("-created_at").offset(offset).limit(per_page)
invoice_list = []
for inv in invoices:
user = await User.get_or_none(id=inv.user_id)
invoice_list.append({
"invoice": inv,
"user": user
})
total_revenue = await Invoice.filter(status="paid").all()
revenue_sum = sum(float(inv.total) for inv in total_revenue)
pending_count = await Invoice.filter(status="open").count()
pending_invoices = await Invoice.filter(status="open").all()
pending_sum = sum(float(inv.total) for inv in pending_invoices)
return templates.TemplateResponse("admin/payments/list.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"invoices": invoice_list,
"status_filter": status or "",
"user_id_filter": user_id,
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"summary": {
"total_revenue": revenue_sum,
"pending_count": pending_count,
"pending_amount": pending_sum
}
})
@router.get("/payments/{invoice_id}", response_class=HTMLResponse)
async def payment_detail(request: Request, invoice_id: int):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
invoice = await Invoice.get_or_none(id=invoice_id)
if not invoice:
return RedirectResponse(url="/manage/payments?error=not_found", status_code=303)
user = await User.get_or_none(id=invoice.user_id)
line_items = await InvoiceLineItem.filter(invoice_id=invoice_id).all()
return templates.TemplateResponse("admin/payments/detail.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"invoice": invoice,
"user": user,
"line_items": line_items
})
@router.post("/payments/{invoice_id}/mark-paid")
async def payment_mark_paid(
request: Request,
invoice_id: int,
csrf_token: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url=f"/manage/payments/{invoice_id}?error=csrf", status_code=303)
invoice = await Invoice.get_or_none(id=invoice_id)
if invoice:
invoice.status = "paid"
invoice.paid_at = datetime.utcnow()
await invoice.save()
return RedirectResponse(url=f"/manage/payments/{invoice_id}?success=1", status_code=303)
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
pricing_configs = await PricingConfig.all().order_by("config_key")
return templates.TemplateResponse("admin/settings.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"pricing_configs": pricing_configs
})
@router.post("/settings/pricing/{config_id}")
async def update_pricing(
request: Request,
config_id: int,
csrf_token: str = Form(...),
value: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url="/manage/settings?error=csrf", status_code=303)
config = await PricingConfig.get_or_none(id=config_id)
if config:
config.config_value = Decimal(value)
config.updated_at = datetime.utcnow()
await config.save()
return RedirectResponse(url="/manage/settings?success=1", status_code=303)

View File

@ -32,6 +32,11 @@ class Settings(BaseSettings):
STRIPE_WEBHOOK_SECRET: str = "" STRIPE_WEBHOOK_SECRET: str = ""
BILLING_ENABLED: bool = False BILLING_ENABLED: bool = False
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin"
ADMIN_SESSION_SECRET: str = "admin_session_secret_change_me"
ADMIN_SESSION_EXPIRE_HOURS: int = 24
settings = Settings() settings = Settings()

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %} - MyWebdav</title>
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<div class="header-left">
<button class="hamburger-btn" id="sidebar-toggle" aria-label="Toggle sidebar">
<span></span>
<span></span>
<span></span>
</button>
<div class="admin-logo">
<span class="logo-icon"></span>
<span class="logo-text">My<span class="logo-accent">Webdav</span></span>
<span class="admin-badge">Admin</span>
</div>
</div>
<div class="header-right">
<span class="admin-user">{{ session.username }}</span>
<a href="/manage/logout" class="btn btn-outline">Logout</a>
</div>
</header>
<div class="admin-body">
<aside class="admin-sidebar" id="admin-sidebar">
<nav class="sidebar-nav">
<a href="/manage/" class="nav-item {% if request.url.path == '/manage/' %}active{% endif %}">
<span class="nav-icon">📊</span>
<span class="nav-text">Dashboard</span>
</a>
<a href="/manage/users" class="nav-item {% if '/manage/users' in request.url.path %}active{% endif %}">
<span class="nav-icon">👥</span>
<span class="nav-text">Users</span>
</a>
<a href="/manage/payments" class="nav-item {% if '/manage/payments' in request.url.path %}active{% endif %}">
<span class="nav-icon">💳</span>
<span class="nav-text">Payments</span>
</a>
<a href="/manage/settings" class="nav-item {% if '/manage/settings' in request.url.path %}active{% endif %}">
<span class="nav-icon">⚙️</span>
<span class="nav-text">Settings</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/" class="nav-item">
<span class="nav-icon">🌐</span>
<span class="nav-text">View Site</span>
</a>
</div>
</aside>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<main class="admin-main">
{% if request.query_params.get('success') %}
<div class="alert alert-success">Changes saved successfully.</div>
{% endif %}
{% if request.query_params.get('error') %}
<div class="alert alert-error">
{% if request.query_params.get('error') == 'csrf' %}
Security token expired. Please try again.
{% elif request.query_params.get('error') == 'not_found' %}
Item not found.
{% else %}
An error occurred. Please try again.
{% endif %}
</div>
{% endif %}
{% if request.query_params.get('deleted') %}
<div class="alert alert-success">Item deleted successfully.</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<script>
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('admin-sidebar');
const overlay = document.getElementById('sidebar-overlay');
function toggleSidebar() {
sidebar.classList.toggle('open');
overlay.classList.toggle('visible');
document.body.classList.toggle('sidebar-open');
}
sidebarToggle.addEventListener('click', toggleSidebar);
overlay.addEventListener('click', toggleSidebar);
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
if (window.innerWidth < 768) {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
document.body.classList.remove('sidebar-open');
}
});
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,76 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">Overview of your MyWebdav instance</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Users</div>
<div class="stat-value">{{ stats.total_users }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Users</div>
<div class="stat-value success">{{ stats.active_users }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Storage Used</div>
<div class="stat-value">{{ stats.total_storage | format_bytes }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Monthly Revenue</div>
<div class="stat-value success">{{ stats.monthly_revenue | format_currency }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending Invoices</div>
<div class="stat-value {% if stats.pending_invoices > 0 %}warning{% endif %}">{{ stats.pending_invoices }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Inactive Users</div>
<div class="stat-value {% if stats.inactive_users > 0 %}danger{% endif %}">{{ stats.inactive_users }}</div>
</div>
</div>
<div class="detail-grid">
<div class="card">
<div class="card-header">
<h2 class="card-title">Quick Actions</h2>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<a href="/manage/users" class="btn btn-primary">Manage Users</a>
<a href="/manage/payments" class="btn btn-outline">View Payments</a>
<a href="/manage/settings" class="btn btn-outline">Settings</a>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Recent Activity</h2>
</div>
{% if recent_activities %}
<div class="activity-list">
{% for activity in recent_activities %}
<div class="activity-item">
<div class="activity-icon">{{ activity.user[0] | upper }}</div>
<div class="activity-content">
<div class="activity-text">
<strong>{{ activity.user }}</strong> {{ activity.action }}
</div>
<div class="activity-time">{{ activity.timestamp.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📋</div>
<div class="empty-state-text">No recent activity</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - MyWebdav</title>
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
</head>
<body>
<div class="login-container">
<div class="login-box">
<div class="login-logo">
<span class="logo-icon"></span>
<span class="logo-text">My<span class="logo-accent">Webdav</span></span>
</div>
<h1 class="login-title">Admin Panel</h1>
{% if error %}
<div class="login-error">
Invalid username or password.
</div>
{% endif %}
<form method="POST" action="/manage/login" class="login-form">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,122 @@
{% extends "admin/base.html" %}
{% block title %}Invoice: {{ invoice.invoice_number }}{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ invoice.invoice_number }}</h1>
<p class="page-subtitle">Created: {{ invoice.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
</div>
<div class="detail-grid">
<div class="detail-section">
<h3 class="detail-section-title">Invoice Details</h3>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value">
{% if invoice.status == 'paid' %}
<span class="badge badge-success">Paid</span>
{% elif invoice.status == 'open' %}
<span class="badge badge-warning">Open</span>
{% else %}
<span class="badge badge-secondary">{{ invoice.status }}</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">User</span>
<span class="detail-value">
{% if user %}
<a href="/manage/users/{{ user.id }}">{{ user.username }}</a>
{% else %}
Unknown
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Period</span>
<span class="detail-value">{{ invoice.period_start.strftime('%Y-%m-%d') }} - {{ invoice.period_end.strftime('%Y-%m-%d') }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Due Date</span>
<span class="detail-value">{% if invoice.due_date %}{{ invoice.due_date.strftime('%Y-%m-%d') }}{% else %}-{% endif %}</span>
</div>
{% if invoice.paid_at %}
<div class="detail-row">
<span class="detail-label">Paid At</span>
<span class="detail-value">{{ invoice.paid_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% endif %}
</div>
<div class="detail-section">
<h3 class="detail-section-title">Totals</h3>
<div class="detail-row">
<span class="detail-label">Subtotal</span>
<span class="detail-value">{{ invoice.subtotal | format_currency }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Tax</span>
<span class="detail-value">{{ invoice.tax | format_currency }}</span>
</div>
<div class="detail-row" style="font-size: 1.1rem; font-weight: 600;">
<span class="detail-label">Total</span>
<span class="detail-value">{{ invoice.total | format_currency }}</span>
</div>
</div>
</div>
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Line Items</h2>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Description</th>
<th>Type</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{% for item in line_items %}
<tr>
<td>{{ item.description }}</td>
<td>
<span class="badge badge-secondary">{{ item.item_type }}</span>
</td>
<td>{{ "%.2f" | format(item.quantity) }}</td>
<td>{{ item.unit_price | format_currency }}</td>
<td><strong>{{ item.amount | format_currency }}</strong></td>
</tr>
{% else %}
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-text">No line items</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if invoice.status != 'paid' %}
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Actions</h2>
</div>
<form method="POST" action="/manage/payments/{{ invoice.id }}/mark-paid" onsubmit="return confirm('Mark this invoice as paid?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-success">Mark as Paid</button>
</form>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="/manage/payments" class="btn btn-outline">&larr; Back to Payments</a>
</div>
{% endblock %}

View File

@ -0,0 +1,112 @@
{% extends "admin/base.html" %}
{% block title %}Payments{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Payments</h1>
<p class="page-subtitle">Overview of all invoices and payments</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Revenue</div>
<div class="stat-value success">{{ summary.total_revenue | format_currency }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending Invoices</div>
<div class="stat-value {% if summary.pending_count > 0 %}warning{% endif %}">{{ summary.pending_count }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending Amount</div>
<div class="stat-value {% if summary.pending_amount > 0 %}warning{% endif %}">{{ summary.pending_amount | format_currency }}</div>
</div>
</div>
<div class="card">
<form method="GET" action="/manage/payments" class="search-bar">
<select name="status" class="form-select">
<option value="">All Status</option>
<option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>Draft</option>
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>Open</option>
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Paid</option>
</select>
<button type="submit" class="btn btn-primary">Filter</button>
{% if status_filter or user_id_filter %}
<a href="/manage/payments" class="btn btn-outline">Clear</a>
{% endif %}
</form>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Invoice #</th>
<th>User</th>
<th>Period</th>
<th>Subtotal</th>
<th>Tax</th>
<th>Total</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in invoices %}
<tr>
<td><a href="/manage/payments/{{ item.invoice.id }}">{{ item.invoice.invoice_number }}</a></td>
<td>
{% if item.user %}
<a href="/manage/users/{{ item.user.id }}">{{ item.user.username }}</a>
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
<td>{{ item.invoice.period_start.strftime('%Y-%m-%d') }} - {{ item.invoice.period_end.strftime('%Y-%m-%d') }}</td>
<td>{{ item.invoice.subtotal | format_currency }}</td>
<td>{{ item.invoice.tax | format_currency }}</td>
<td><strong>{{ item.invoice.total | format_currency }}</strong></td>
<td>
{% if item.invoice.status == 'paid' %}
<span class="badge badge-success">Paid</span>
{% elif item.invoice.status == 'open' %}
<span class="badge badge-warning">Open</span>
{% else %}
<span class="badge badge-secondary">{{ item.invoice.status }}</span>
{% endif %}
</td>
<td class="actions">
<a href="/manage/payments/{{ item.invoice.id }}" class="btn btn-sm btn-outline">View</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="empty-state">
<div class="empty-state-icon">💳</div>
<div class="empty-state-text">No invoices found</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="/manage/payments?page={{ page - 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}">&laquo; Previous</a>
{% else %}
<span class="disabled">&laquo; Previous</span>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="/manage/payments?page={{ page + 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Next &raquo;</a>
{% else %}
<span class="disabled">Next &raquo;</span>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,80 @@
{% extends "admin/base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Settings</h1>
<p class="page-subtitle">Configure pricing and system settings</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Pricing Configuration</h2>
</div>
{% if pricing_configs %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Setting</th>
<th>Description</th>
<th>Value</th>
<th>Unit</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for config in pricing_configs %}
<tr>
<td><strong>{{ config.config_key }}</strong></td>
<td>{{ config.description or '-' }}</td>
<td>
<form method="POST" action="/manage/settings/pricing/{{ config.id }}" class="inline-form" id="form-{{ config.id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="value" class="form-input" value="{{ config.config_value }}" style="width: 120px;">
</form>
</td>
<td>{{ config.unit or '-' }}</td>
<td>
<button type="submit" form="form-{{ config.id }}" class="btn btn-sm btn-primary">Save</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">⚙️</div>
<div class="empty-state-text">No pricing configuration found</div>
<p style="color: var(--text-color-light); margin-top: 8px;">
Run the database initialization to set up default pricing.
</p>
</div>
{% endif %}
</div>
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">System Information</h2>
</div>
<div class="detail-row">
<span class="detail-label">Application</span>
<span class="detail-value">MyWebdav Cloud Storage</span>
</div>
<div class="detail-row">
<span class="detail-label">Admin Panel Version</span>
<span class="detail-value">1.0.0</span>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.inline-form {
display: inline;
}
</style>
{% endblock %}

View File

@ -0,0 +1,185 @@
{% extends "admin/base.html" %}
{% block title %}User: {{ user.username }}{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ user.username }}</h1>
<p class="page-subtitle">User ID: {{ user.id }} | Created: {{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
</div>
<div class="detail-grid">
<div class="detail-section">
<h3 class="detail-section-title">Storage Usage</h3>
<div class="detail-row">
<span class="detail-label">Used</span>
<span class="detail-value">{{ user.used_storage_bytes | format_bytes }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Quota</span>
<span class="detail-value">{{ user.storage_quota_bytes | format_bytes }}</span>
</div>
<div style="margin-top: 12px;">
<div class="progress-bar">
<div class="progress-fill {% if usage_percent > 90 %}danger{% elif usage_percent > 75 %}warning{% endif %}" style="width: {{ usage_percent }}%"></div>
</div>
<div style="text-align: center; margin-top: 8px; font-size: 0.85rem; color: var(--text-color-light);">
{{ "%.1f" | format(usage_percent) }}% used
</div>
</div>
</div>
<div class="detail-section">
<h3 class="detail-section-title">Account Status</h3>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value">
{% if user.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-danger">Inactive</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Role</span>
<span class="detail-value">
{% if user.is_superuser %}
<span class="badge badge-info">Administrator</span>
{% else %}
<span class="badge badge-secondary">User</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">2FA</span>
<span class="detail-value">
{% if user.is_2fa_enabled %}
<span class="badge badge-success">Enabled</span>
{% else %}
<span class="badge badge-secondary">Disabled</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Plan</span>
<span class="detail-value">{{ user.plan_type }}</span>
</div>
</div>
</div>
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Edit User</h2>
</div>
<form method="POST" action="/manage/users/{{ user.id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input type="email" id="email" name="email" class="form-input" value="{{ user.email }}" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="password">New Password (leave empty to keep current)</label>
<input type="password" id="password" name="password" class="form-input" placeholder="Enter new password...">
</div>
<div class="form-group">
<label class="form-label" for="storage_quota_gb">Storage Quota (GB)</label>
<input type="number" id="storage_quota_gb" name="storage_quota_gb" class="form-input"
value="{{ (user.storage_quota_bytes / 1073741824) | round(2) }}" step="0.1" min="0" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="plan_type">Plan Type</label>
<select id="plan_type" name="plan_type" class="form-select">
<option value="free" {% if user.plan_type == 'free' %}selected{% endif %}>Free</option>
<option value="basic" {% if user.plan_type == 'basic' %}selected{% endif %}>Basic</option>
<option value="premium" {% if user.plan_type == 'premium' %}selected{% endif %}>Premium</option>
<option value="enterprise" {% if user.plan_type == 'enterprise' %}selected{% endif %}>Enterprise</option>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<div style="display: flex; gap: 24px; padding-top: 8px;">
<label class="form-checkbox">
<input type="checkbox" name="is_active" value="true" {% if user.is_active %}checked{% endif %}>
<span>Active</span>
</label>
<label class="form-checkbox">
<input type="checkbox" name="is_superuser" value="true" {% if user.is_superuser %}checked{% endif %}>
<span>Administrator</span>
</label>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/manage/users" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
{% if invoices %}
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Recent Invoices</h2>
<a href="/manage/payments?user_id={{ user.id }}" class="btn btn-sm btn-outline">View All</a>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Invoice #</th>
<th>Period</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td><a href="/manage/payments/{{ invoice.id }}">{{ invoice.invoice_number }}</a></td>
<td>{{ invoice.period_start.strftime('%Y-%m-%d') }} - {{ invoice.period_end.strftime('%Y-%m-%d') }}</td>
<td>{{ invoice.total | format_currency }}</td>
<td>
{% if invoice.status == 'paid' %}
<span class="badge badge-success">Paid</span>
{% elif invoice.status == 'open' %}
<span class="badge badge-warning">Open</span>
{% else %}
<span class="badge badge-secondary">{{ invoice.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="card" style="margin-top: 24px; border-color: var(--danger-color);">
<div class="card-header">
<h2 class="card-title" style="color: var(--danger-color);">Danger Zone</h2>
</div>
<p style="margin-bottom: 16px; color: var(--text-color-light);">
Deleting a user is permanent and cannot be undone. All files and data associated with this user will be lost.
</p>
<form method="POST" action="/manage/users/{{ user.id }}/delete" onsubmit="return confirm('Are you sure you want to delete this user? This action cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Delete User</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,101 @@
{% extends "admin/base.html" %}
{% block title %}Users{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Users</h1>
<p class="page-subtitle">Manage all registered users</p>
</div>
<div class="card">
<form method="GET" action="/manage/users" class="search-bar">
<input type="text" name="search" class="form-input" placeholder="Search by username or email..." value="{{ search }}">
<select name="status" class="form-select">
<option value="">All Status</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="superuser" {% if status == 'superuser' %}selected{% endif %}>Superuser</option>
</select>
<button type="submit" class="btn btn-primary">Search</button>
{% if search or status %}
<a href="/manage/users" class="btn btn-outline">Clear</a>
{% endif %}
</form>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Storage</th>
<th>Plan</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<strong>{{ user.username }}</strong>
{% if user.is_superuser %}
<span class="badge badge-info">Admin</span>
{% endif %}
</td>
<td>{{ user.email }}</td>
<td>
{{ user.used_storage_bytes | format_bytes }} / {{ user.storage_quota_bytes | format_bytes }}
</td>
<td>{{ user.plan_type }}</td>
<td>
{% if user.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-danger">Inactive</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td class="actions">
<a href="/manage/users/{{ user.id }}" class="btn btn-sm btn-outline">Edit</a>
<form method="POST" action="/manage/users/{{ user.id }}/toggle-active" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-success{% endif %}">
{% if user.is_active %}Deactivate{% else %}Activate{% endif %}
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="empty-state">
<div class="empty-state-icon">👥</div>
<div class="empty-state-text">No users found</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="/manage/users?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}{% if status %}&status={{ status }}{% endif %}">&laquo; Previous</a>
{% else %}
<span class="disabled">&laquo; Previous</span>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="/manage/users?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}{% if status %}&status={{ status }}{% endif %}">Next &raquo;</a>
{% else %}
<span class="disabled">Next &raquo;</span>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

924
static/css/admin.css Normal file
View File

@ -0,0 +1,924 @@
/* retoor <retoor@molodetz.nl> */
/* Admin Panel Styles */
:root {
--primary-color: #003399;
--secondary-color: #CC0000;
--accent-color: #FFFFFF;
--background-color: #F0F2F5;
--text-color: #333333;
--text-color-light: #666666;
--border-color: #DDDDDD;
--shadow-color: rgba(0, 0, 0, 0.1);
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--info-color: #17a2b8;
--sidebar-width: 250px;
--header-height: 60px;
--font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.5;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.admin-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-header {
height: var(--header-height);
background-color: var(--accent-color);
border-bottom: 2px solid var(--primary-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-shadow: 0 2px 4px var(--shadow-color);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
}
.hamburger-btn span {
display: block;
width: 24px;
height: 2px;
background-color: var(--primary-color);
border-radius: 2px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.hamburger-btn:hover {
background-color: var(--background-color);
}
.admin-logo {
display: flex;
align-items: center;
gap: 8px;
}
.logo-icon {
color: var(--primary-color);
font-size: 1.5rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.logo-accent {
color: var(--secondary-color);
}
.admin-badge {
background-color: var(--primary-color);
color: var(--accent-color);
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.admin-user {
color: var(--text-color-light);
font-size: 0.9rem;
}
.admin-body {
display: flex;
margin-top: var(--header-height);
min-height: calc(100vh - var(--header-height));
}
.admin-sidebar {
width: var(--sidebar-width);
background-color: var(--accent-color);
border-right: 1px solid var(--border-color);
position: fixed;
top: var(--header-height);
left: 0;
bottom: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-nav {
padding: 16px 0;
flex: 1;
}
.sidebar-footer {
padding: 16px 0;
border-top: 1px solid var(--border-color);
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.2s, color 0.2s;
}
.nav-item:hover {
background-color: var(--background-color);
text-decoration: none;
}
.nav-item.active {
background-color: var(--primary-color);
color: var(--accent-color);
}
.nav-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
}
.nav-text {
font-weight: 500;
}
.sidebar-overlay {
display: none;
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 89;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.sidebar-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.admin-main {
flex: 1;
margin-left: var(--sidebar-width);
padding: 24px;
min-height: calc(100vh - var(--header-height));
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
}
.page-subtitle {
color: var(--text-color-light);
font-size: 0.95rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover {
text-decoration: none;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background-color: var(--primary-color);
color: var(--accent-color);
}
.btn-primary:hover {
background-color: #002277;
}
.btn-secondary {
background-color: var(--text-color-light);
color: var(--accent-color);
}
.btn-danger {
background-color: var(--danger-color);
color: var(--accent-color);
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-success {
background-color: var(--success-color);
color: var(--accent-color);
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.btn-outline:hover {
background-color: var(--background-color);
}
.btn-sm {
padding: 4px 12px;
font-size: 0.8rem;
}
.card {
background-color: var(--accent-color);
border-radius: 8px;
box-shadow: 0 1px 3px var(--shadow-color);
padding: 20px;
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background-color: var(--accent-color);
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px var(--shadow-color);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-color-light);
margin-bottom: 8px;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-color);
}
.stat-value.success {
color: var(--success-color);
}
.stat-value.warning {
color: var(--warning-color);
}
.stat-value.danger {
color: var(--danger-color);
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background-color: var(--background-color);
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
}
.data-table tr:hover {
background-color: var(--background-color);
}
.data-table .actions {
display: flex;
gap: 8px;
}
.badge {
display: inline-block;
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 12px;
text-transform: uppercase;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-info {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-secondary {
background-color: #e9ecef;
color: #495057;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-color);
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--accent-color);
color: var(--text-color);
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 51, 153, 0.1);
}
.form-select {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--accent-color);
color: var(--text-color);
cursor: pointer;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 0.9rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 20px;
}
.pagination a,
.pagination span {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
text-decoration: none;
font-size: 0.9rem;
}
.pagination a:hover {
background-color: var(--background-color);
text-decoration: none;
}
.pagination .active {
background-color: var(--primary-color);
color: var(--accent-color);
border-color: var(--primary-color);
}
.pagination .disabled {
color: var(--text-color-light);
cursor: not-allowed;
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-bar .form-input {
flex: 1;
min-width: 200px;
}
.search-bar .form-select {
min-width: 150px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.progress-fill.warning {
background-color: var(--warning-color);
}
.progress-fill.danger {
background-color: var(--danger-color);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.detail-section {
background-color: var(--accent-color);
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px var(--shadow-color);
}
.detail-section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--background-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: var(--text-color-light);
font-size: 0.9rem;
}
.detail-value {
font-weight: 500;
color: var(--text-color);
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-color-light);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state-text {
font-size: 1.1rem;
margin-bottom: 16px;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background-color: var(--background-color);
border-radius: 6px;
}
.activity-icon {
width: 32px;
height: 32px;
background-color: var(--primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-color);
font-size: 0.8rem;
flex-shrink: 0;
}
.activity-content {
flex: 1;
}
.activity-text {
font-size: 0.9rem;
color: var(--text-color);
}
.activity-time {
font-size: 0.8rem;
color: var(--text-color-light);
margin-top: 4px;
}
.confirm-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog-content {
background-color: var(--accent-color);
border-radius: 8px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 12px var(--shadow-color);
}
.confirm-dialog-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 12px;
}
.confirm-dialog-text {
color: var(--text-color-light);
margin-bottom: 20px;
}
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 767px) {
.hamburger-btn {
display: flex;
}
.admin-sidebar {
position: fixed;
left: 0;
top: var(--header-height);
bottom: 0;
width: 280px;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 90;
}
.admin-sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 12px var(--shadow-color);
}
.sidebar-overlay {
display: block;
}
.admin-main {
margin-left: 0;
padding: 16px;
}
.page-title {
font-size: 1.4rem;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-card {
padding: 16px;
}
.stat-value {
font-size: 1.4rem;
}
.card {
padding: 16px;
}
.search-bar {
flex-direction: column;
}
.search-bar .form-input,
.search-bar .form-select {
min-width: 100%;
}
.data-table {
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 10px 12px;
}
.form-actions {
flex-direction: column;
}
.form-actions .btn {
width: 100%;
}
.detail-grid {
grid-template-columns: 1fr;
}
.header-right .admin-user {
display: none;
}
.pagination {
flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.table-container {
margin: 0 -16px;
}
.data-table th:nth-child(n+3),
.data-table td:nth-child(n+3) {
display: none;
}
.data-table .show-mobile {
display: table-cell;
}
}
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, #001f5c 100%);
padding: 20px;
}
.login-box {
background-color: var(--accent-color);
border-radius: 8px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo .logo-icon {
font-size: 2.5rem;
}
.login-logo .logo-text {
font-size: 1.5rem;
display: block;
margin-top: 8px;
}
.login-title {
text-align: center;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 24px;
}
.login-form .form-group {
margin-bottom: 20px;
}
.login-form .btn {
width: 100%;
padding: 12px;
font-size: 1rem;
}
.login-error {
background-color: #f8d7da;
color: #721c24;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
text-align: center;
font-size: 0.9rem;
}
@media (max-width: 480px) {
.login-box {
padding: 24px;
}
}