From 6950455bbfeabaab8dfb11d5a5b1b1a93e03174b Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 5 Jan 2026 21:50:41 +0100 Subject: [PATCH] Update. --- .env.example | 5 + README.md | 15 +- mywebdav/admin_auth.py | 96 ++ mywebdav/main.py | 2 + mywebdav/routers/__init__.py | 28 + mywebdav/routers/manage.py | 417 ++++++++ mywebdav/settings.py | 5 + mywebdav/templates/admin/base.html | 111 +++ mywebdav/templates/admin/dashboard.html | 76 ++ mywebdav/templates/admin/login.html | 39 + mywebdav/templates/admin/payments/detail.html | 122 +++ mywebdav/templates/admin/payments/list.html | 112 +++ mywebdav/templates/admin/settings.html | 80 ++ mywebdav/templates/admin/users/detail.html | 185 ++++ mywebdav/templates/admin/users/list.html | 101 ++ static/css/admin.css | 924 ++++++++++++++++++ 16 files changed, 2316 insertions(+), 2 deletions(-) create mode 100644 mywebdav/admin_auth.py create mode 100644 mywebdav/routers/__init__.py create mode 100644 mywebdav/routers/manage.py create mode 100644 mywebdav/templates/admin/base.html create mode 100644 mywebdav/templates/admin/dashboard.html create mode 100644 mywebdav/templates/admin/login.html create mode 100644 mywebdav/templates/admin/payments/detail.html create mode 100644 mywebdav/templates/admin/payments/list.html create mode 100644 mywebdav/templates/admin/settings.html create mode 100644 mywebdav/templates/admin/users/detail.html create mode 100644 mywebdav/templates/admin/users/list.html create mode 100644 static/css/admin.css diff --git a/.env.example b/.env.example index 9e7f43b..739f949 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index dc38467..e8af3e7 100644 --- a/README.md +++ b/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: diff --git a/mywebdav/admin_auth.py b/mywebdav/admin_auth.py new file mode 100644 index 0000000..228d307 --- /dev/null +++ b/mywebdav/admin_auth.py @@ -0,0 +1,96 @@ +# retoor +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) diff --git a/mywebdav/main.py b/mywebdav/main.py index 25c7ba5..52fb445 100644 --- a/mywebdav/main.py +++ b/mywebdav/main.py @@ -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) diff --git a/mywebdav/routers/__init__.py b/mywebdav/routers/__init__.py new file mode 100644 index 0000000..43e2902 --- /dev/null +++ b/mywebdav/routers/__init__.py @@ -0,0 +1,28 @@ +# retoor +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", +] diff --git a/mywebdav/routers/manage.py b/mywebdav/routers/manage.py new file mode 100644 index 0000000..5cd79d4 --- /dev/null +++ b/mywebdav/routers/manage.py @@ -0,0 +1,417 @@ +# retoor +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) diff --git a/mywebdav/settings.py b/mywebdav/settings.py index 29cdafb..631d935 100644 --- a/mywebdav/settings.py +++ b/mywebdav/settings.py @@ -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() diff --git a/mywebdav/templates/admin/base.html b/mywebdav/templates/admin/base.html new file mode 100644 index 0000000..93560b6 --- /dev/null +++ b/mywebdav/templates/admin/base.html @@ -0,0 +1,111 @@ + + + + + + {% block title %}Admin Panel{% endblock %} - MyWebdav + + + {% block extra_css %}{% endblock %} + + +
+
+
+ + +
+
+ {{ session.username }} + Logout +
+
+ +
+ + + + +
+ {% if request.query_params.get('success') %} +
Changes saved successfully.
+ {% endif %} + {% if request.query_params.get('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 %} +
+ {% endif %} + {% if request.query_params.get('deleted') %} +
Item deleted successfully.
+ {% endif %} + {% block content %}{% endblock %} +
+
+
+ + + {% block extra_js %}{% endblock %} + + diff --git a/mywebdav/templates/admin/dashboard.html b/mywebdav/templates/admin/dashboard.html new file mode 100644 index 0000000..2dd3803 --- /dev/null +++ b/mywebdav/templates/admin/dashboard.html @@ -0,0 +1,76 @@ +{% extends "admin/base.html" %} + +{% block title %}Dashboard{% endblock %} + +{% block content %} + + +
+
+
Total Users
+
{{ stats.total_users }}
+
+
+
Active Users
+
{{ stats.active_users }}
+
+
+
Total Storage Used
+
{{ stats.total_storage | format_bytes }}
+
+
+
Monthly Revenue
+
{{ stats.monthly_revenue | format_currency }}
+
+
+
Pending Invoices
+
{{ stats.pending_invoices }}
+
+
+
Inactive Users
+
{{ stats.inactive_users }}
+
+
+ +
+
+
+

Quick Actions

+
+ +
+ +
+
+

Recent Activity

+
+ {% if recent_activities %} +
+ {% for activity in recent_activities %} +
+
{{ activity.user[0] | upper }}
+
+
+ {{ activity.user }} {{ activity.action }} +
+
{{ activity.timestamp.strftime('%Y-%m-%d %H:%M') }}
+
+
+ {% endfor %} +
+ {% else %} +
+
📋
+
No recent activity
+
+ {% endif %} +
+
+{% endblock %} diff --git a/mywebdav/templates/admin/login.html b/mywebdav/templates/admin/login.html new file mode 100644 index 0000000..457600b --- /dev/null +++ b/mywebdav/templates/admin/login.html @@ -0,0 +1,39 @@ + + + + + + Admin Login - MyWebdav + + + + + + + diff --git a/mywebdav/templates/admin/payments/detail.html b/mywebdav/templates/admin/payments/detail.html new file mode 100644 index 0000000..1024ba6 --- /dev/null +++ b/mywebdav/templates/admin/payments/detail.html @@ -0,0 +1,122 @@ +{% extends "admin/base.html" %} + +{% block title %}Invoice: {{ invoice.invoice_number }}{% endblock %} + +{% block content %} + + +
+
+

Invoice Details

+
+ Status + + {% if invoice.status == 'paid' %} + Paid + {% elif invoice.status == 'open' %} + Open + {% else %} + {{ invoice.status }} + {% endif %} + +
+
+ User + + {% if user %} + {{ user.username }} + {% else %} + Unknown + {% endif %} + +
+
+ Period + {{ invoice.period_start.strftime('%Y-%m-%d') }} - {{ invoice.period_end.strftime('%Y-%m-%d') }} +
+
+ Due Date + {% if invoice.due_date %}{{ invoice.due_date.strftime('%Y-%m-%d') }}{% else %}-{% endif %} +
+ {% if invoice.paid_at %} +
+ Paid At + {{ invoice.paid_at.strftime('%Y-%m-%d %H:%M') }} +
+ {% endif %} +
+ +
+

Totals

+
+ Subtotal + {{ invoice.subtotal | format_currency }} +
+
+ Tax + {{ invoice.tax | format_currency }} +
+
+ Total + {{ invoice.total | format_currency }} +
+
+
+ +
+
+

Line Items

+
+
+ + + + + + + + + + + + {% for item in line_items %} + + + + + + + + {% else %} + + + + {% endfor %} + +
DescriptionTypeQuantityUnit PriceAmount
{{ item.description }} + {{ item.item_type }} + {{ "%.2f" | format(item.quantity) }}{{ item.unit_price | format_currency }}{{ item.amount | format_currency }}
+
No line items
+
+
+
+ +{% if invoice.status != 'paid' %} +
+
+

Actions

+
+
+ + +
+
+{% endif %} + +
+ ← Back to Payments +
+{% endblock %} diff --git a/mywebdav/templates/admin/payments/list.html b/mywebdav/templates/admin/payments/list.html new file mode 100644 index 0000000..11ec76a --- /dev/null +++ b/mywebdav/templates/admin/payments/list.html @@ -0,0 +1,112 @@ +{% extends "admin/base.html" %} + +{% block title %}Payments{% endblock %} + +{% block content %} + + +
+
+
Total Revenue
+
{{ summary.total_revenue | format_currency }}
+
+
+
Pending Invoices
+
{{ summary.pending_count }}
+
+
+
Pending Amount
+
{{ summary.pending_amount | format_currency }}
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + {% for item in invoices %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
Invoice #UserPeriodSubtotalTaxTotalStatusActions
{{ item.invoice.invoice_number }} + {% if item.user %} + {{ item.user.username }} + {% else %} + Unknown + {% endif %} + {{ item.invoice.period_start.strftime('%Y-%m-%d') }} - {{ item.invoice.period_end.strftime('%Y-%m-%d') }}{{ item.invoice.subtotal | format_currency }}{{ item.invoice.tax | format_currency }}{{ item.invoice.total | format_currency }} + {% if item.invoice.status == 'paid' %} + Paid + {% elif item.invoice.status == 'open' %} + Open + {% else %} + {{ item.invoice.status }} + {% endif %} + + View +
+
💳
+
No invoices found
+
+
+ + {% if total_pages > 1 %} + + {% endif %} +
+{% endblock %} diff --git a/mywebdav/templates/admin/settings.html b/mywebdav/templates/admin/settings.html new file mode 100644 index 0000000..3f200c0 --- /dev/null +++ b/mywebdav/templates/admin/settings.html @@ -0,0 +1,80 @@ +{% extends "admin/base.html" %} + +{% block title %}Settings{% endblock %} + +{% block content %} + + +
+
+

Pricing Configuration

+
+ + {% if pricing_configs %} +
+ + + + + + + + + + + + {% for config in pricing_configs %} + + + + + + + + {% endfor %} + +
SettingDescriptionValueUnitActions
{{ config.config_key }}{{ config.description or '-' }} +
+ + +
+
{{ config.unit or '-' }} + +
+
+ {% else %} +
+
⚙️
+
No pricing configuration found
+

+ Run the database initialization to set up default pricing. +

+
+ {% endif %} +
+ +
+
+

System Information

+
+
+ Application + MyWebdav Cloud Storage +
+
+ Admin Panel Version + 1.0.0 +
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/mywebdav/templates/admin/users/detail.html b/mywebdav/templates/admin/users/detail.html new file mode 100644 index 0000000..a418612 --- /dev/null +++ b/mywebdav/templates/admin/users/detail.html @@ -0,0 +1,185 @@ +{% extends "admin/base.html" %} + +{% block title %}User: {{ user.username }}{% endblock %} + +{% block content %} + + +
+
+

Storage Usage

+
+ Used + {{ user.used_storage_bytes | format_bytes }} +
+
+ Quota + {{ user.storage_quota_bytes | format_bytes }} +
+
+
+
+
+
+ {{ "%.1f" | format(usage_percent) }}% used +
+
+
+ +
+

Account Status

+
+ Status + + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + +
+
+ Role + + {% if user.is_superuser %} + Administrator + {% else %} + User + {% endif %} + +
+
+ 2FA + + {% if user.is_2fa_enabled %} + Enabled + {% else %} + Disabled + {% endif %} + +
+
+ Plan + {{ user.plan_type }} +
+
+
+ +
+
+

Edit User

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + Cancel +
+
+
+ +{% if invoices %} +
+
+

Recent Invoices

+ View All +
+
+ + + + + + + + + + + {% for invoice in invoices %} + + + + + + + {% endfor %} + +
Invoice #PeriodTotalStatus
{{ invoice.invoice_number }}{{ invoice.period_start.strftime('%Y-%m-%d') }} - {{ invoice.period_end.strftime('%Y-%m-%d') }}{{ invoice.total | format_currency }} + {% if invoice.status == 'paid' %} + Paid + {% elif invoice.status == 'open' %} + Open + {% else %} + {{ invoice.status }} + {% endif %} +
+
+
+{% endif %} + +
+
+

Danger Zone

+
+

+ Deleting a user is permanent and cannot be undone. All files and data associated with this user will be lost. +

+
+ + +
+
+{% endblock %} diff --git a/mywebdav/templates/admin/users/list.html b/mywebdav/templates/admin/users/list.html new file mode 100644 index 0000000..8d5420c --- /dev/null +++ b/mywebdav/templates/admin/users/list.html @@ -0,0 +1,101 @@ +{% extends "admin/base.html" %} + +{% block title %}Users{% endblock %} + +{% block content %} + + +
+ + +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
UserEmailStoragePlanStatusCreatedActions
+ {{ user.username }} + {% if user.is_superuser %} + Admin + {% endif %} + {{ user.email }} + {{ user.used_storage_bytes | format_bytes }} / {{ user.storage_quota_bytes | format_bytes }} + {{ user.plan_type }} + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + {{ user.created_at.strftime('%Y-%m-%d') }} + Edit +
+ + +
+
+
👥
+
No users found
+
+
+ + {% if total_pages > 1 %} + + {% endif %} +
+{% endblock %} diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..7a2c3be --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,924 @@ +/* retoor */ +/* 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; + } +}