Răsfoiți Sursa

Add password authentication for admin console

Implemented session-based password authentication to secure the admin console,
addressing the security concern of unrestricted access to the management interface.

Features:
- Password-only login (no username required)
- 30-day session expiration
- Session token stored in localStorage
- Automatic redirect to /login when unauthenticated
- All admin API endpoints protected with session verification

Implementation:
- Created login.html: Clean password-only login page with TailwindCSS
- Added session management: In-memory token storage with datetime expiration
- Added authentication endpoints: POST /api/login and GET /login
- Added verify_admin_session(): FastAPI dependency for route protection
- Protected all admin API routes: /v2/accounts/*, /v2/auth/*
- Updated frontend: Auth token handling and automatic session checks
- Added ADMIN_PASSWORD env var: Configurable password (default: "admin")
- Updated README.md: Complete documentation of authentication system

Security:
- Sessions expire after 30 days
- Password configurable via ADMIN_PASSWORD environment variable
- Default password is "admin" (should be changed in production)
- All API endpoints require valid session token
- Frontend HTML served freely but immediately redirects if unauthenticated
- 401 responses trigger automatic redirect to login page

Authentication Flow:
- / route serves index.html without server-side auth check
- Frontend JavaScript checks for token on page load
- If no token found, redirects to /login immediately
- After successful login, token stored and user redirected to /
- All API calls include Authorization: Bearer {token} header
- Invalid/expired tokens result in 401 → automatic redirect to /login

This approach allows the HTML to load while maintaining API security,
preventing the 401 error that would occur if the / route itself required auth.

Documentation fixes:
- Corrected endpoint path from /v2/accounts/batch to /v2/accounts/feed
  (README had wrong endpoint name; actual implementation was always /feed)

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

Co-Authored-By: Claude <[email protected]>
Zhi Yang 2 luni în urmă
părinte
comite
c0079448fe
5 a modificat fișierele cu 281 adăugiri și 32 ștergeri
  1. 4 0
      .env.example
  2. 55 13
      README.md
  3. 92 10
      app.py
  4. 43 9
      frontend/index.html
  5. 87 0
      frontend/login.html

+ 4 - 0
.env.example

@@ -25,5 +25,9 @@ HTTP_PROXY=""
 # 设置为 "false" 或 "0" 可禁用管理控制台和相关API端点
 ENABLE_CONSOLE="true"
 
+# 管理控制台登录密码(默认 "admin")
+# 用于访问管理控制台的密码,会话有效期为30天
+ADMIN_PASSWORD="admin"
+
 # 主服务端口(默认 8000)
 PORT=8000

+ 55 - 13
README.md

@@ -115,6 +115,10 @@ HTTP_PROXY=""
 # 设置为 "false" 或 "0" 可禁用管理控制台和相关API端点
 ENABLE_CONSOLE="true"
 
+# 管理控制台登录密码(默认 "admin")
+# 用于访问管理控制台的密码,会话有效期为30天
+ADMIN_PASSWORD="admin"
+
 # 主服务端口(默认 8000)
 PORT=8000
 ```
@@ -125,6 +129,7 @@ PORT=8000
 - API Key 仅用于访问控制,不映射到特定账号
 - 账号选择策略:从所有启用账号中随机选择
 - `ENABLE_CONSOLE` 设为 `false` 或 `0`:禁用 Web 管理控制台和账号管理 API
+- `ADMIN_PASSWORD`:管理控制台登录密码,默认为 "admin",建议修改为强密码
 
 #### 3. 启动服务
 
@@ -137,11 +142,25 @@ uvicorn app:app --host 0.0.0.0 --port 8000 --reload
 
 ## 📖 使用指南
 
+### 管理控制台登录
+
+首次访问管理控制台需要登录:
+
+1. 访问 http://localhost:8000/ 将自动跳转到登录页面
+2. 输入管理员密码(默认为 `admin`,可通过 `ADMIN_PASSWORD` 环境变量配置)
+3. 登录成功后,会话有效期为 **30 天**
+4. 会话过期后需要重新登录
+
+**安全建议:**
+- 生产环境务必修改 `ADMIN_PASSWORD` 为强密码
+- 登录凭证存储在浏览器 localStorage 中
+- 所有管理 API 请求需要在 Authorization 头中携带会话 token
+
 ### 账号管理
 
 #### 方式一:Web 控制台(推荐)
 
-访问 http://localhost:8000/ 使用可视化界面:
+登录管理控制台后,使用可视化界面:
 - 查看所有账号及详细状态
 - URL 登录(设备授权)快速添加账号
 - 创建/删除/编辑账号
@@ -171,10 +190,22 @@ curl -X POST http://localhost:8000/v2/auth/claim/{authId}
 
 #### 方式三:REST API 手动管理
 
+**注意:** 所有管理 API 请求需要携带登录凭证(Authorization Bearer Token)
+
+**先登录获取 Token**
+```bash
+# 登录并获取 token
+TOKEN=$(curl -X POST http://localhost:8000/api/login \
+  -H "Content-Type: application/json" \
+  -d '{"password": "admin"}' \
+  | jq -r '.token')
+```
+
 **创建账号**
 ```bash
 curl -X POST http://localhost:8000/v2/accounts \
   -H "Content-Type: application/json" \
+  -H "Authorization: Bearer $TOKEN" \
   -d '{
     "label": "手动创建的账号",
     "clientId": "your-client-id",
@@ -186,24 +217,28 @@ curl -X POST http://localhost:8000/v2/accounts \
 
 **列出所有账号**
 ```bash
-curl http://localhost:8000/v2/accounts
+curl http://localhost:8000/v2/accounts \
+  -H "Authorization: Bearer $TOKEN"
 ```
 
 **更新账号**
 ```bash
 curl -X PATCH http://localhost:8000/v2/accounts/{account_id} \
   -H "Content-Type: application/json" \
+  -H "Authorization: Bearer $TOKEN" \
   -d '{"enabled": false}'
 ```
 
 **刷新 Token**
 ```bash
-curl -X POST http://localhost:8000/v2/accounts/{account_id}/refresh
+curl -X POST http://localhost:8000/v2/accounts/{account_id}/refresh \
+  -H "Authorization: Bearer $TOKEN"
 ```
 
 **删除账号**
 ```bash
-curl -X DELETE http://localhost:8000/v2/accounts/{account_id}
+curl -X DELETE http://localhost:8000/v2/accounts/{account_id} \
+  -H "Authorization: Bearer $TOKEN"
 ```
 
 ### OpenAI 兼容 API
@@ -387,6 +422,7 @@ v2/
 | `MAX_ERROR_COUNT` | 错误次数阈值 | 100 | `50` |
 | `HTTP_PROXY` | HTTP代理地址 | 空 | `"http://127.0.0.1:7890"` |
 | `ENABLE_CONSOLE` | 管理控制台开关 | `"true"` | `"false"` |
+| `ADMIN_PASSWORD` | 管理控制台登录密码 | `"admin"` | `"your-secure-password"` |
 | `PORT` | 服务端口 | 8000 | `8080` |
 
 ### 数据库结构
@@ -412,16 +448,20 @@ CREATE TABLE accounts (
 
 ## 📝 完整 API 端点列表
 
-### 账号管理(需启用 ENABLE_CONSOLE)
+### 管理员认证(需启用 ENABLE_CONSOLE)
+- `POST /api/login` - 管理员登录,获取会话 token
+- `GET /login` - 登录页面
+
+### 账号管理(需启用 ENABLE_CONSOLE,需登录)
 - `POST /v2/accounts` - 创建账号
-- `POST /v2/accounts/batch` - 批量创建账号
+- `POST /v2/accounts/feed` - 批量创建账号
 - `GET /v2/accounts` - 列出所有账号
 - `GET /v2/accounts/{id}` - 获取账号详情
 - `PATCH /v2/accounts/{id}` - 更新账号
 - `DELETE /v2/accounts/{id}` - 删除账号
 - `POST /v2/accounts/{id}/refresh` - 刷新 Token
 
-### 设备授权(需启用 ENABLE_CONSOLE)
+### 设备授权(需启用 ENABLE_CONSOLE,需登录
 - `POST /v2/auth/start` - 启动登录流程
 - `GET /v2/auth/status/{authId}` - 查询登录状态
 - `POST /v2/auth/claim/{authId}` - 等待并创建账号(最多5分钟)
@@ -434,7 +474,7 @@ CREATE TABLE accounts (
 - `POST /v1/messages/count_tokens` - Token 计数接口(预先统计消息的 token 数量)
 
 ### 其他
-- `GET /` - Web 控制台(需启用 ENABLE_CONSOLE)
+- `GET /` - Web 控制台首页(需启用 ENABLE_CONSOLE,需登录
 - `GET /healthz` - 健康检查
 - `GET /docs` - API 文档(Swagger UI)
 
@@ -489,11 +529,13 @@ server {
 
 ## 🔒 安全建议
 
-1. **生产环境必须配置 `OPENAI_KEYS`**
-2. **使用 HTTPS 反向代理(Nginx + Let's Encrypt)**
-3. **定期备份数据库**(SQLite: `data.sqlite3`,或 PG/MySQL 数据库)
-4. **限制数据库访问权限**
-5. **配置防火墙规则,限制访问来源**
+1. **生产环境必须修改 `ADMIN_PASSWORD` 为强密码**
+2. **生产环境必须配置 `OPENAI_KEYS`**
+3. **使用 HTTPS 反向代理(Nginx + Let's Encrypt)**
+4. **定期备份数据库**(SQLite: `data.sqlite3`,或 PG/MySQL 数据库)
+5. **限制数据库访问权限**
+6. **配置防火墙规则,限制访问来源**
+7. **管理控制台会话有效期为 30 天,建议定期重新登录**
 
 ## 📄 许可证
 

+ 92 - 10
app.py

@@ -6,6 +6,8 @@ import time
 import asyncio
 import importlib.util
 import random
+import secrets
+from datetime import datetime, timedelta
 from pathlib import Path
 from typing import Dict, Optional, List, Any, AsyncGenerator, Tuple
 
@@ -255,6 +257,13 @@ def _is_console_enabled() -> bool:
 
 CONSOLE_ENABLED: bool = _is_console_enabled()
 
+# Admin authentication configuration
+ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin")
+SESSION_EXPIRE_DAYS: int = 30
+
+# Admin session storage: {token: expiration_datetime}
+ADMIN_SESSIONS: Dict[str, datetime] = {}
+
 def _extract_bearer(token_header: Optional[str]) -> Optional[str]:
     if not token_header:
         return None
@@ -466,6 +475,31 @@ async def require_account(authorization: Optional[str] = Header(default=None)) -
     bearer = _extract_bearer(authorization)
     return await resolve_account_for_key(bearer)
 
+def verify_admin_session(authorization: Optional[str] = Header(None)) -> bool:
+    """Verify admin session token for console access"""
+    if not authorization or not authorization.startswith("Bearer "):
+        raise HTTPException(
+            status_code=401,
+            detail={"error": "Unauthorized access", "code": "UNAUTHORIZED"}
+        )
+
+    token = authorization[7:]  # Remove "Bearer " prefix
+
+    if token not in ADMIN_SESSIONS:
+        raise HTTPException(
+            status_code=401,
+            detail={"error": "Invalid session", "code": "SESSION_INVALID"}
+        )
+
+    if datetime.now() > ADMIN_SESSIONS[token]:
+        del ADMIN_SESSIONS[token]
+        raise HTTPException(
+            status_code=401,
+            detail={"error": "Session expired", "code": "SESSION_EXPIRED"}
+        )
+
+    return True
+
 # ------------------------------------------------------------------------------
 # OpenAI-compatible Chat endpoint
 # ------------------------------------------------------------------------------
@@ -882,6 +916,14 @@ class AuthStartBody(BaseModel):
     label: Optional[str] = None
     enabled: Optional[bool] = True
 
+class AdminLoginRequest(BaseModel):
+    password: str
+
+class AdminLoginResponse(BaseModel):
+    success: bool
+    token: Optional[str] = None
+    message: str
+
 async def _create_account_from_tokens(
     client_id: str,
     client_secret: str,
@@ -917,8 +959,45 @@ async def _create_account_from_tokens(
 
 # 管理控制台相关端点 - 仅在启用时注册
 if CONSOLE_ENABLED:
+    # ------------------------------------------------------------------------------
+    # Admin Authentication Endpoints
+    # ------------------------------------------------------------------------------
+
+    @app.post("/api/login", response_model=AdminLoginResponse)
+    async def admin_login(request: AdminLoginRequest) -> AdminLoginResponse:
+        """Admin login endpoint - password only"""
+        if request.password != ADMIN_PASSWORD:
+            return AdminLoginResponse(
+                success=False,
+                message="Invalid password"
+            )
+
+        # Generate session token
+        session_token = secrets.token_urlsafe(32)
+
+        # Store session with expiration
+        ADMIN_SESSIONS[session_token] = datetime.now() + timedelta(days=SESSION_EXPIRE_DAYS)
+
+        return AdminLoginResponse(
+            success=True,
+            token=session_token,
+            message="Login successful"
+        )
+
+    @app.get("/login", response_class=FileResponse)
+    def login_page():
+        """Serve the login page"""
+        path = BASE_DIR / "frontend" / "login.html"
+        if not path.exists():
+            raise HTTPException(status_code=404, detail="frontend/login.html not found")
+        return FileResponse(str(path))
+
+    # ------------------------------------------------------------------------------
+    # Device Authorization Endpoints
+    # ------------------------------------------------------------------------------
+
     @app.post("/v2/auth/start")
-    async def auth_start(body: AuthStartBody):
+    async def auth_start(body: AuthStartBody, _: bool = Depends(verify_admin_session)):
         """
         Start device authorization and return verification URL for user login.
         Session lifetime capped at 5 minutes on claim.
@@ -955,7 +1034,7 @@ if CONSOLE_ENABLED:
         }
 
     @app.get("/v2/auth/status/{auth_id}")
-    async def auth_status(auth_id: str):
+    async def auth_status(auth_id: str, _: bool = Depends(verify_admin_session)):
         sess = AUTH_SESSIONS.get(auth_id)
         if not sess:
             raise HTTPException(status_code=404, detail="Auth session not found")
@@ -970,7 +1049,7 @@ if CONSOLE_ENABLED:
         }
 
     @app.post("/v2/auth/claim/{auth_id}")
-    async def auth_claim(auth_id: str):
+    async def auth_claim(auth_id: str, _: bool = Depends(verify_admin_session)):
         """
         Block up to 5 minutes to exchange the device code for tokens after user completed login.
         On success, creates an enabled account and returns it.
@@ -1025,7 +1104,7 @@ if CONSOLE_ENABLED:
     # ------------------------------------------------------------------------------
 
     @app.post("/v2/accounts")
-    async def create_account(body: AccountCreate):
+    async def create_account(body: AccountCreate, _: bool = Depends(verify_admin_session)):
         now = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
         acc_id = str(uuid.uuid4())
         other_str = json.dumps(body.other, ensure_ascii=False) if body.other is not None else None
@@ -1074,7 +1153,7 @@ if CONSOLE_ENABLED:
                 traceback.print_exc()
 
     @app.post("/v2/accounts/feed")
-    async def create_accounts_feed(request: BatchAccountCreate):
+    async def create_accounts_feed(request: BatchAccountCreate, _: bool = Depends(verify_admin_session)):
         """
         统一的投喂接口,接收账号列表,立即存入并后台异步验证。
         """
@@ -1120,23 +1199,23 @@ if CONSOLE_ENABLED:
         }
 
     @app.get("/v2/accounts")
-    async def list_accounts():
+    async def list_accounts(_: bool = Depends(verify_admin_session)):
         rows = await _db.fetchall("SELECT * FROM accounts ORDER BY created_at DESC")
         return [_row_to_dict(r) for r in rows]
 
     @app.get("/v2/accounts/{account_id}")
-    async def get_account_detail(account_id: str):
+    async def get_account_detail(account_id: str, _: bool = Depends(verify_admin_session)):
         return await get_account(account_id)
 
     @app.delete("/v2/accounts/{account_id}")
-    async def delete_account(account_id: str):
+    async def delete_account(account_id: str, _: bool = Depends(verify_admin_session)):
         rowcount = await _db.execute("DELETE FROM accounts WHERE id=?", (account_id,))
         if rowcount == 0:
             raise HTTPException(status_code=404, detail="Account not found")
         return {"deleted": account_id}
 
     @app.patch("/v2/accounts/{account_id}")
-    async def update_account(account_id: str, body: AccountUpdate):
+    async def update_account(account_id: str, body: AccountUpdate, _: bool = Depends(verify_admin_session)):
         now = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
         fields = []
         values: List[Any] = []
@@ -1169,7 +1248,7 @@ if CONSOLE_ENABLED:
         return _row_to_dict(row)
 
     @app.post("/v2/accounts/{account_id}/refresh")
-    async def manual_refresh(account_id: str):
+    async def manual_refresh(account_id: str, _: bool = Depends(verify_admin_session)):
         return await refresh_access_token_in_db(account_id)
 
     # ------------------------------------------------------------------------------
@@ -1177,6 +1256,9 @@ if CONSOLE_ENABLED:
     # ------------------------------------------------------------------------------
 
     # Frontend inline HTML removed; serving ./frontend/index.html instead (see route below)
+    # Note: This route is NOT protected - the HTML file is served freely,
+    # but the frontend JavaScript checks authentication and redirects to /login if needed.
+    # All API endpoints remain protected.
 
     @app.get("/", response_class=FileResponse)
     def index():

+ 43 - 9
frontend/index.html

@@ -314,6 +314,33 @@ function api(path){
   return pathClean;
 }
 
+// Authentication helpers
+function getAuthToken() {
+  return localStorage.getItem('adminToken');
+}
+
+function getAuthHeaders() {
+  const token = getAuthToken();
+  if (!token) return {};
+  return { 'Authorization': `Bearer ${token}` };
+}
+
+// Authenticated fetch wrapper
+async function authFetch(url, options = {}) {
+  const headers = { ...getAuthHeaders(), ...options.headers };
+  const response = await fetch(url, { ...options, headers });
+
+  if (response.status === 401) {
+    localStorage.removeItem('adminToken');
+    window.location.href = '/login';
+    throw new Error('Unauthorized');
+  }
+
+  return response;
+}
+
+// Check authentication on page load - removed, combined with loadAccounts below
+
 // Virtual scroll state
 let accountsData = [];
 let virtualScroll = null;
@@ -597,7 +624,7 @@ function renderAccounts(list){
 
 async function loadAccounts(){
   try{
-    const r = await fetch(api('/v2/accounts'));
+    const r = await authFetch(api('/v2/accounts'));
     const j = await r.json();
     renderAccounts(j);
   } catch(e){
@@ -620,7 +647,7 @@ async function createAccount(){
     })()
   };
   try{
-    const r = await fetch(api('/v2/accounts'), {
+    const r = await authFetch(api('/v2/accounts'), {
       method:'POST',
       headers:{ 'content-type':'application/json' },
       body: JSON.stringify(body)
@@ -638,7 +665,7 @@ async function createAccount(){
 async function deleteAccount(id){
   if (!confirm('确认删除该账号?')) return;
   try{
-    const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id)), { method:'DELETE' });
+    const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id)), { method:'DELETE' });
     if (!r.ok) { throw new Error(await r.text()); }
     await loadAccounts();
   } catch(e){
@@ -648,7 +675,7 @@ async function deleteAccount(id){
 
 async function updateAccount(id, patch){
   try{
-    const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id)), {
+    const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id)), {
       method:'PATCH',
       headers:{ 'content-type':'application/json' },
       body: JSON.stringify(patch)
@@ -662,7 +689,7 @@ async function updateAccount(id, patch){
 
 async function refreshAccount(id){
   try{
-    const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id) + '/refresh'), { method:'POST' });
+    const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id) + '/refresh'), { method:'POST' });
     if (!r.ok) { throw new Error(await r.text()); }
     await loadAccounts();
   } catch(e){
@@ -678,7 +705,7 @@ async function refreshAccount(id){
      enabled: document.getElementById('auth_enabled').checked
    };
    try {
-     const r = await fetch(api('/v2/auth/start'), {
+     const r = await authFetch(api('/v2/auth/start'), {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body)
@@ -708,7 +735,7 @@ async function refreshAccount(id){
    }
    document.getElementById('auth_info').textContent += '\\n\\n正在等待授权并创建账号(最多5分钟)...';
    try{
-     const r = await fetch(api('/v2/auth/claim/' + encodeURIComponent(currentAuth.authId)), { method: 'POST' });
+     const r = await authFetch(api('/v2/auth/claim/' + encodeURIComponent(currentAuth.authId)), { method: 'POST' });
      const text = await r.text();
      let j;
      try { j = JSON.parse(text); } catch { j = { raw: text }; }
@@ -733,7 +760,7 @@ async function send() {
   const headers = { 'content-type': 'application/json' };
 
   if (!stream) {
-    const r = await fetch(api('/v1/chat/completions'), {
+    const r = await authFetch(api('/v1/chat/completions'), {
       method:'POST',
       headers,
       body: JSON.stringify(body)
@@ -742,7 +769,7 @@ async function send() {
     try { out.textContent = JSON.stringify(JSON.parse(text), null, 2); }
     catch { out.textContent = text; }
   } else {
-    const r = await fetch(api('/v1/chat/completions'), {
+    const r = await authFetch(api('/v1/chat/completions'), {
       method:'POST',
       headers,
       body: JSON.stringify(body)
@@ -758,6 +785,13 @@ async function send() {
 }
 
 window.addEventListener('DOMContentLoaded', () => {
+  // Check authentication first
+  const token = getAuthToken();
+  if (!token) {
+    window.location.href = '/login';
+    return;
+  }
+  // Only load accounts if authenticated
   loadAccounts();
 });
 </script>

+ 87 - 0
frontend/login.html

@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Login - Q2API</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <script>
+        tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
+    </script>
+    <style>
+        @keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
+        .animate-slide-up{animation:slide-up .3s ease-out}
+    </style>
+</head>
+<body class="h-full bg-background text-foreground antialiased">
+    <div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
+        <div class="sm:mx-auto sm:w-full sm:max-w-md">
+            <div class="text-center">
+                <h1 class="text-4xl font-bold">Q2API</h1>
+                <p class="mt-2 text-sm text-muted-foreground">Admin Console</p>
+            </div>
+        </div>
+
+        <div class="sm:mx-auto sm:w-full sm:max-w-md">
+            <div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
+                <form id="loginForm" class="space-y-6">
+                    <div class="space-y-2">
+                        <label for="password" class="text-sm font-medium">Password</label>
+                        <input type="password" id="password" name="password" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="Enter password" autofocus>
+                    </div>
+                    <button type="submit" id="loginButton" class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-10 w-full disabled:opacity-50">Login</button>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const form=document.getElementById('loginForm'),btn=document.getElementById('loginButton');
+        form.addEventListener('submit',async(e)=>{
+            e.preventDefault();
+            btn.disabled=true;
+            btn.textContent='Logging in...';
+            try{
+                const fd=new FormData(form),r=await fetch('/api/login',{
+                    method:'POST',
+                    headers:{'Content-Type':'application/json'},
+                    body:JSON.stringify({password:fd.get('password')})
+                });
+                const d=await r.json();
+                if(d.success){
+                    localStorage.setItem('adminToken',d.token);
+                    location.href='/';
+                }else{
+                    showToast(d.message||'Login failed','error');
+                }
+            }catch(e){
+                showToast('Network error, please try again','error');
+            }finally{
+                btn.disabled=false;
+                btn.textContent='Login';
+            }
+        });
+
+        function showToast(m,t='error'){
+            const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};
+            d.className=`fixed bottom-4 right-4 ${bc[t]||bc.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;
+            d.textContent=m;
+            document.body.appendChild(d);
+            setTimeout(()=>{
+                d.style.opacity='0';
+                d.style.transition='opacity .3s';
+                setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)
+            },2000);
+        }
+
+        window.addEventListener('DOMContentLoaded',()=>{
+            const t=localStorage.getItem('adminToken');
+            if(t){
+                fetch('/v2/accounts',{headers:{Authorization:`Bearer ${t}`}})
+                    .then(r=>{if(r.ok)location.href='/'})
+                    .catch(()=>{});
+            }
+        });
+    </script>
+</body>
+</html>