Update.
This commit is contained in:
parent
7e75f62219
commit
6950455bbf
@ -27,3 +27,8 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=no-reply@example.com
|
||||
|
||||
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
|
||||
|
||||
15
README.md
15
README.md
@ -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
|
||||
|
||||
### 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
|
||||
- **Admin Console**: Centralized user management and system monitoring
|
||||
- **API Access**: RESTful API for third-party integrations
|
||||
|
||||
## Pricing
|
||||
@ -94,7 +97,15 @@ Access the web application through your browser. The interface provides:
|
||||
- Folder management and navigation
|
||||
- Search and filtering capabilities
|
||||
- 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
|
||||
MyWebdav provides a comprehensive REST API. Example requests:
|
||||
|
||||
96
mywebdav/admin_auth.py
Normal file
96
mywebdav/admin_auth.py
Normal 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)
|
||||
@ -19,6 +19,7 @@ from .routers import (
|
||||
starred,
|
||||
billing,
|
||||
admin_billing,
|
||||
manage,
|
||||
)
|
||||
from . import webdav
|
||||
from .schemas import ErrorResponse
|
||||
@ -227,6 +228,7 @@ app.include_router(admin.router)
|
||||
app.include_router(starred.router)
|
||||
app.include_router(billing.router)
|
||||
app.include_router(admin_billing.router)
|
||||
app.include_router(manage.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(health_router)
|
||||
|
||||
|
||||
28
mywebdav/routers/__init__.py
Normal file
28
mywebdav/routers/__init__.py
Normal 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
417
mywebdav/routers/manage.py
Normal 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)
|
||||
@ -32,6 +32,11 @@ class Settings(BaseSettings):
|
||||
STRIPE_WEBHOOK_SECRET: str = ""
|
||||
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()
|
||||
|
||||
|
||||
111
mywebdav/templates/admin/base.html
Normal file
111
mywebdav/templates/admin/base.html
Normal 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>
|
||||
76
mywebdav/templates/admin/dashboard.html
Normal file
76
mywebdav/templates/admin/dashboard.html
Normal 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 %}
|
||||
39
mywebdav/templates/admin/login.html
Normal file
39
mywebdav/templates/admin/login.html
Normal 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>
|
||||
122
mywebdav/templates/admin/payments/detail.html
Normal file
122
mywebdav/templates/admin/payments/detail.html
Normal 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">← Back to Payments</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
112
mywebdav/templates/admin/payments/list.html
Normal file
112
mywebdav/templates/admin/payments/list.html
Normal 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 %}">« Previous</a>
|
||||
{% else %}
|
||||
<span class="disabled">« 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 »</a>
|
||||
{% else %}
|
||||
<span class="disabled">Next »</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
80
mywebdav/templates/admin/settings.html
Normal file
80
mywebdav/templates/admin/settings.html
Normal 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 %}
|
||||
185
mywebdav/templates/admin/users/detail.html
Normal file
185
mywebdav/templates/admin/users/detail.html
Normal 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"> </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 %}
|
||||
101
mywebdav/templates/admin/users/list.html
Normal file
101
mywebdav/templates/admin/users/list.html
Normal 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 %}">« Previous</a>
|
||||
{% else %}
|
||||
<span class="disabled">« 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 »</a>
|
||||
{% else %}
|
||||
<span class="disabled">Next »</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
924
static/css/admin.css
Normal file
924
static/css/admin.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user