xiaolongmr commited on
Commit ·
519f85d
1
Parent(s): 8034201
Add configurable branding and login sessions
Browse files- api/app.py +50 -5
- api/system.py +7 -0
- services/config.py +74 -0
- web/src/app/login/page.tsx +3 -1
- web/src/app/settings/components/config-card.tsx +98 -0
- web/src/app/settings/store.ts +63 -0
- web/src/components/app-shell.tsx +4 -2
- web/src/components/top-nav.tsx +82 -14
- web/src/lib/api.ts +28 -1
- web/src/lib/auth-session.ts +2 -1
- web/src/lib/branding.ts +108 -0
- web/src/store/auth.ts +32 -2
api/app.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from contextlib import asynccontextmanager
|
|
|
|
| 4 |
from mimetypes import guess_type
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
from threading import Event
|
| 7 |
|
| 8 |
from fastapi import FastAPI, HTTPException, Request
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
-
from fastapi.responses import FileResponse
|
| 11 |
|
| 12 |
from api import accounts, ai, image_tasks, register, shop, system
|
| 13 |
from api.errors import install_exception_handlers
|
|
@@ -19,6 +21,8 @@ STATIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
| 19 |
ASSET_CACHE_CONTROL = "public, max-age=86400"
|
| 20 |
HTML_CACHE_CONTROL = "no-cache"
|
| 21 |
COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
|
|
@@ -55,6 +59,49 @@ def _is_api_like_path(full_path: str) -> bool:
|
|
| 55 |
return clean_path.startswith(("api/", "auth/", "v1/"))
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def create_app() -> FastAPI:
|
| 59 |
app_version = config.app_version
|
| 60 |
|
|
@@ -91,15 +138,13 @@ def create_app() -> FastAPI:
|
|
| 91 |
async def serve_web(full_path: str, request: Request):
|
| 92 |
asset = resolve_web_asset(full_path)
|
| 93 |
if asset is not None:
|
| 94 |
-
|
| 95 |
-
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 96 |
if full_path.strip("/").startswith("_next/"):
|
| 97 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 98 |
fallback = resolve_web_asset("")
|
| 99 |
if fallback is None:
|
| 100 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 101 |
-
|
| 102 |
-
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 103 |
|
| 104 |
@app.head("/{full_path:path}", include_in_schema=False)
|
| 105 |
async def serve_web_head(full_path: str, request: Request):
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
+
from html import escape
|
| 5 |
from mimetypes import guess_type
|
| 6 |
from pathlib import Path
|
| 7 |
+
import re
|
| 8 |
from threading import Event
|
| 9 |
|
| 10 |
from fastapi import FastAPI, HTTPException, Request
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from fastapi.responses import FileResponse, HTMLResponse, Response
|
| 13 |
|
| 14 |
from api import accounts, ai, image_tasks, register, shop, system
|
| 15 |
from api.errors import install_exception_handlers
|
|
|
|
| 21 |
ASSET_CACHE_CONTROL = "public, max-age=86400"
|
| 22 |
HTML_CACHE_CONTROL = "no-cache"
|
| 23 |
COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
|
| 24 |
+
TITLE_RE = re.compile(r"<title>.*?</title>", re.IGNORECASE | re.DOTALL)
|
| 25 |
+
ICON_LINK_RE = re.compile(r"<link\b[^>]*\brel=[\"'][^\"']*(?:icon|apple-touch-icon)[^\"']*[\"'][^>]*>", re.IGNORECASE)
|
| 26 |
|
| 27 |
|
| 28 |
def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
|
|
|
|
| 59 |
return clean_path.startswith(("api/", "auth/", "v1/"))
|
| 60 |
|
| 61 |
|
| 62 |
+
def _guess_icon_type(href: str) -> str:
|
| 63 |
+
clean_href = href.split("?", 1)[0].split("#", 1)[0].lower()
|
| 64 |
+
if clean_href.endswith(".svg"):
|
| 65 |
+
return "image/svg+xml"
|
| 66 |
+
if clean_href.endswith((".jpg", ".jpeg")):
|
| 67 |
+
return "image/jpeg"
|
| 68 |
+
if clean_href.endswith(".webp"):
|
| 69 |
+
return "image/webp"
|
| 70 |
+
if clean_href.endswith(".ico"):
|
| 71 |
+
return "image/x-icon"
|
| 72 |
+
return "image/png"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _inject_branding_head(html_text: str) -> str:
|
| 76 |
+
branding = config.get_branding()
|
| 77 |
+
title = escape(str(branding.get("site_title") or "ChatGPT 号池管理"), quote=False)
|
| 78 |
+
raw_icon_href = str(branding.get("site_logo_url") or "/favicon.ico")
|
| 79 |
+
icon_href = escape(raw_icon_href, quote=True)
|
| 80 |
+
icon_type = escape(_guess_icon_type(raw_icon_href), quote=True)
|
| 81 |
+
icon_markup = (
|
| 82 |
+
f'<link rel="icon" href="{icon_href}" type="{icon_type}" data-branding-icon="favicon">'
|
| 83 |
+
f'<link rel="apple-touch-icon" href="{icon_href}" data-branding-icon="apple-touch-icon">'
|
| 84 |
+
)
|
| 85 |
+
next_html = TITLE_RE.sub(f"<title>{title}</title>", html_text, count=1)
|
| 86 |
+
if next_html == html_text and "</head>" in next_html:
|
| 87 |
+
next_html = next_html.replace("</head>", f"<title>{title}</title></head>", 1)
|
| 88 |
+
next_html = ICON_LINK_RE.sub("", next_html)
|
| 89 |
+
if "</head>" in next_html:
|
| 90 |
+
next_html = next_html.replace("</head>", f"{icon_markup}</head>", 1)
|
| 91 |
+
return next_html
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _web_response(full_path: str, request: Request, asset: Path) -> Response:
|
| 95 |
+
if asset.suffix.lower() == ".html":
|
| 96 |
+
html_text = asset.read_text(encoding="utf-8")
|
| 97 |
+
return HTMLResponse(
|
| 98 |
+
_inject_branding_head(html_text),
|
| 99 |
+
headers=_web_cache_headers(full_path, asset),
|
| 100 |
+
)
|
| 101 |
+
selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
|
| 102 |
+
return FileResponse(selected_asset, headers=headers, media_type=media_type)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
def create_app() -> FastAPI:
|
| 106 |
app_version = config.app_version
|
| 107 |
|
|
|
|
| 138 |
async def serve_web(full_path: str, request: Request):
|
| 139 |
asset = resolve_web_asset(full_path)
|
| 140 |
if asset is not None:
|
| 141 |
+
return _web_response(full_path, request, asset)
|
|
|
|
| 142 |
if full_path.strip("/").startswith("_next/"):
|
| 143 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 144 |
fallback = resolve_web_asset("")
|
| 145 |
if fallback is None:
|
| 146 |
raise HTTPException(status_code=404, detail="Not Found")
|
| 147 |
+
return _web_response("", request, fallback)
|
|
|
|
| 148 |
|
| 149 |
@app.head("/{full_path:path}", include_in_schema=False)
|
| 150 |
async def serve_web_head(full_path: str, request: Request):
|
api/system.py
CHANGED
|
@@ -63,6 +63,7 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 63 |
"subject_id": identity.get("id"),
|
| 64 |
"name": identity.get("name"),
|
| 65 |
"account_pool_enabled": bool(identity.get("role") == "admin" or identity.get("account_pool_enabled")),
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
@router.get("/api/auth/wechat/status")
|
|
@@ -70,8 +71,13 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 70 |
return {
|
| 71 |
"enabled": config.wechat_login_enabled,
|
| 72 |
"auto_register_enabled": config.wechat_auto_register_enabled,
|
|
|
|
| 73 |
}
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@router.post("/api/auth/wechat/login")
|
| 76 |
async def wechat_login(body: WechatLoginRequest):
|
| 77 |
if not config.wechat_login_enabled:
|
|
@@ -121,6 +127,7 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 121 |
"account_pool_enabled": bool(item.get("account_pool_enabled")),
|
| 122 |
"key": raw_key,
|
| 123 |
"created": created,
|
|
|
|
| 124 |
"credits": auth_service.credit_summary(item),
|
| 125 |
"wechat": {
|
| 126 |
"openid": openid,
|
|
|
|
| 63 |
"subject_id": identity.get("id"),
|
| 64 |
"name": identity.get("name"),
|
| 65 |
"account_pool_enabled": bool(identity.get("role") == "admin" or identity.get("account_pool_enabled")),
|
| 66 |
+
"login_session_duration_hours": config.login_session_duration_hours_for_role(identity.get("role")),
|
| 67 |
}
|
| 68 |
|
| 69 |
@router.get("/api/auth/wechat/status")
|
|
|
|
| 71 |
return {
|
| 72 |
"enabled": config.wechat_login_enabled,
|
| 73 |
"auto_register_enabled": config.wechat_auto_register_enabled,
|
| 74 |
+
"login_session_duration_hours": config.normal_user_login_session_duration_hours,
|
| 75 |
}
|
| 76 |
|
| 77 |
+
@router.get("/api/branding")
|
| 78 |
+
async def get_branding():
|
| 79 |
+
return {"branding": config.get_branding()}
|
| 80 |
+
|
| 81 |
@router.post("/api/auth/wechat/login")
|
| 82 |
async def wechat_login(body: WechatLoginRequest):
|
| 83 |
if not config.wechat_login_enabled:
|
|
|
|
| 127 |
"account_pool_enabled": bool(item.get("account_pool_enabled")),
|
| 128 |
"key": raw_key,
|
| 129 |
"created": created,
|
| 130 |
+
"login_session_duration_hours": config.normal_user_login_session_duration_hours,
|
| 131 |
"credits": auth_service.credit_summary(item),
|
| 132 |
"wechat": {
|
| 133 |
"openid": openid,
|
services/config.py
CHANGED
|
@@ -47,6 +47,25 @@ DEFAULT_IMAGE_STORAGE = {
|
|
| 47 |
"public_base_url": "",
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
ANNOUNCEMENT_TYPES = {"notice", "timeline"}
|
| 51 |
ANNOUNCEMENT_TARGETS = {"internal", "normal", "all"}
|
| 52 |
SHARED_ANNOUNCEMENTS_KEY = "announcements"
|
|
@@ -56,6 +75,8 @@ SHARED_SETTINGS_KEYS = (
|
|
| 56 |
"normal_user_daily_credits",
|
| 57 |
"wechat_login_enabled",
|
| 58 |
"wechat_auto_register_enabled",
|
|
|
|
|
|
|
| 59 |
)
|
| 60 |
SHARED_SETTINGS_CACHE_SECONDS = 10.0
|
| 61 |
|
|
@@ -84,6 +105,26 @@ def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> in
|
|
| 84 |
return max(minimum, normalized)
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
def _normalize_backup_include(value: object) -> dict[str, bool]:
|
| 88 |
source = value if isinstance(value, dict) else {}
|
| 89 |
normalized = dict(DEFAULT_BACKUP_INCLUDE)
|
|
@@ -222,6 +263,20 @@ def _normalize_shared_settings(value: object, fallback: dict[str, object] | None
|
|
| 222 |
source.get("wechat_auto_register_enabled"),
|
| 223 |
_normalize_bool(fallback.get("wechat_auto_register_enabled"), True),
|
| 224 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
|
|
@@ -378,6 +433,20 @@ class ConfigStore:
|
|
| 378 |
def wechat_auto_register_enabled(self) -> bool:
|
| 379 |
return _normalize_bool(self._load_shared_settings().get("wechat_auto_register_enabled"), True)
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
@property
|
| 382 |
def shop_purchase_url(self) -> str:
|
| 383 |
return str(self.data.get("shop_purchase_url") or "").strip()
|
|
@@ -481,6 +550,9 @@ class ConfigStore:
|
|
| 481 |
data["normal_user_daily_credits"] = self.normal_user_daily_credits
|
| 482 |
data["wechat_login_enabled"] = self.wechat_login_enabled
|
| 483 |
data["wechat_auto_register_enabled"] = self.wechat_auto_register_enabled
|
|
|
|
|
|
|
|
|
|
| 484 |
data["shop_purchase_url"] = self.shop_purchase_url
|
| 485 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 486 |
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
|
@@ -509,6 +581,8 @@ class ConfigStore:
|
|
| 509 |
_validate_image_storage_settings(next_data["image_storage"])
|
| 510 |
if "image_proxy_base_url" in next_data:
|
| 511 |
next_data["image_proxy_base_url"] = _normalize_image_proxy_base_url(next_data.get("image_proxy_base_url"))
|
|
|
|
|
|
|
| 512 |
if any(key in incoming_data for key in SHARED_SETTINGS_KEYS):
|
| 513 |
next_data.update(self._save_shared_settings(_normalize_shared_settings(next_data, self.data)))
|
| 514 |
if "announcements" in next_data:
|
|
|
|
| 47 |
"public_base_url": "",
|
| 48 |
}
|
| 49 |
|
| 50 |
+
BRANDING_KEYS = (
|
| 51 |
+
"site_title",
|
| 52 |
+
"site_logo_url",
|
| 53 |
+
"site_header_title",
|
| 54 |
+
"site_external_link_label",
|
| 55 |
+
"site_external_link_url",
|
| 56 |
+
"site_external_link_icon_url",
|
| 57 |
+
"site_external_link_icon_svg",
|
| 58 |
+
)
|
| 59 |
+
DEFAULT_BRANDING = {
|
| 60 |
+
"site_title": "ChatGPT 号池管理",
|
| 61 |
+
"site_logo_url": "",
|
| 62 |
+
"site_header_title": "chatgpt2api",
|
| 63 |
+
"site_external_link_label": "GitHub",
|
| 64 |
+
"site_external_link_url": "https://github.com/basketikun/chatgpt2api",
|
| 65 |
+
"site_external_link_icon_url": "",
|
| 66 |
+
"site_external_link_icon_svg": "",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
ANNOUNCEMENT_TYPES = {"notice", "timeline"}
|
| 70 |
ANNOUNCEMENT_TARGETS = {"internal", "normal", "all"}
|
| 71 |
SHARED_ANNOUNCEMENTS_KEY = "announcements"
|
|
|
|
| 75 |
"normal_user_daily_credits",
|
| 76 |
"wechat_login_enabled",
|
| 77 |
"wechat_auto_register_enabled",
|
| 78 |
+
"internal_user_login_session_duration_hours",
|
| 79 |
+
"normal_user_login_session_duration_hours",
|
| 80 |
)
|
| 81 |
SHARED_SETTINGS_CACHE_SECONDS = 10.0
|
| 82 |
|
|
|
|
| 105 |
return max(minimum, normalized)
|
| 106 |
|
| 107 |
|
| 108 |
+
def _normalize_text(value: object, default: str = "", max_length: int = 500) -> str:
|
| 109 |
+
if value is None:
|
| 110 |
+
return default
|
| 111 |
+
normalized = str(value).strip()
|
| 112 |
+
return normalized[:max_length]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _normalize_branding_settings(value: dict[str, object] | None) -> dict[str, str]:
|
| 116 |
+
source = value if isinstance(value, dict) else {}
|
| 117 |
+
return {
|
| 118 |
+
"site_title": _normalize_text(source.get("site_title"), DEFAULT_BRANDING["site_title"], 80) or DEFAULT_BRANDING["site_title"],
|
| 119 |
+
"site_logo_url": _normalize_text(source.get("site_logo_url"), DEFAULT_BRANDING["site_logo_url"], 500),
|
| 120 |
+
"site_header_title": _normalize_text(source.get("site_header_title"), DEFAULT_BRANDING["site_header_title"], 40) or DEFAULT_BRANDING["site_header_title"],
|
| 121 |
+
"site_external_link_label": _normalize_text(source.get("site_external_link_label"), DEFAULT_BRANDING["site_external_link_label"], 40),
|
| 122 |
+
"site_external_link_url": _normalize_text(source.get("site_external_link_url"), DEFAULT_BRANDING["site_external_link_url"], 500),
|
| 123 |
+
"site_external_link_icon_url": _normalize_text(source.get("site_external_link_icon_url"), DEFAULT_BRANDING["site_external_link_icon_url"], 500),
|
| 124 |
+
"site_external_link_icon_svg": _normalize_text(source.get("site_external_link_icon_svg"), DEFAULT_BRANDING["site_external_link_icon_svg"], 6000),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def _normalize_backup_include(value: object) -> dict[str, bool]:
|
| 129 |
source = value if isinstance(value, dict) else {}
|
| 130 |
normalized = dict(DEFAULT_BACKUP_INCLUDE)
|
|
|
|
| 263 |
source.get("wechat_auto_register_enabled"),
|
| 264 |
_normalize_bool(fallback.get("wechat_auto_register_enabled"), True),
|
| 265 |
),
|
| 266 |
+
"internal_user_login_session_duration_hours": _normalize_positive_int(
|
| 267 |
+
source.get("internal_user_login_session_duration_hours"),
|
| 268 |
+
_normalize_positive_int(fallback.get("internal_user_login_session_duration_hours"), 0, 0),
|
| 269 |
+
0,
|
| 270 |
+
),
|
| 271 |
+
"normal_user_login_session_duration_hours": _normalize_positive_int(
|
| 272 |
+
source.get("normal_user_login_session_duration_hours"),
|
| 273 |
+
_normalize_positive_int(
|
| 274 |
+
fallback.get("normal_user_login_session_duration_hours", fallback.get("login_session_duration_hours")),
|
| 275 |
+
72,
|
| 276 |
+
0,
|
| 277 |
+
),
|
| 278 |
+
0,
|
| 279 |
+
),
|
| 280 |
}
|
| 281 |
|
| 282 |
|
|
|
|
| 433 |
def wechat_auto_register_enabled(self) -> bool:
|
| 434 |
return _normalize_bool(self._load_shared_settings().get("wechat_auto_register_enabled"), True)
|
| 435 |
|
| 436 |
+
@property
|
| 437 |
+
def internal_user_login_session_duration_hours(self) -> int:
|
| 438 |
+
return int(self._load_shared_settings().get("internal_user_login_session_duration_hours") or 0)
|
| 439 |
+
|
| 440 |
+
@property
|
| 441 |
+
def normal_user_login_session_duration_hours(self) -> int:
|
| 442 |
+
return int(self._load_shared_settings().get("normal_user_login_session_duration_hours") or 0)
|
| 443 |
+
|
| 444 |
+
def login_session_duration_hours_for_role(self, role: object) -> int:
|
| 445 |
+
return self.normal_user_login_session_duration_hours if str(role or "").strip() == "normal" else self.internal_user_login_session_duration_hours
|
| 446 |
+
|
| 447 |
+
def get_branding(self) -> dict[str, str]:
|
| 448 |
+
return _normalize_branding_settings(self.data)
|
| 449 |
+
|
| 450 |
@property
|
| 451 |
def shop_purchase_url(self) -> str:
|
| 452 |
return str(self.data.get("shop_purchase_url") or "").strip()
|
|
|
|
| 550 |
data["normal_user_daily_credits"] = self.normal_user_daily_credits
|
| 551 |
data["wechat_login_enabled"] = self.wechat_login_enabled
|
| 552 |
data["wechat_auto_register_enabled"] = self.wechat_auto_register_enabled
|
| 553 |
+
data["internal_user_login_session_duration_hours"] = self.internal_user_login_session_duration_hours
|
| 554 |
+
data["normal_user_login_session_duration_hours"] = self.normal_user_login_session_duration_hours
|
| 555 |
+
data.update(self.get_branding())
|
| 556 |
data["shop_purchase_url"] = self.shop_purchase_url
|
| 557 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 558 |
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
|
|
|
| 581 |
_validate_image_storage_settings(next_data["image_storage"])
|
| 582 |
if "image_proxy_base_url" in next_data:
|
| 583 |
next_data["image_proxy_base_url"] = _normalize_image_proxy_base_url(next_data.get("image_proxy_base_url"))
|
| 584 |
+
if any(key in incoming_data for key in BRANDING_KEYS):
|
| 585 |
+
next_data.update(_normalize_branding_settings(next_data))
|
| 586 |
if any(key in incoming_data for key in SHARED_SETTINGS_KEYS):
|
| 587 |
next_data.update(self._save_shared_settings(_normalize_shared_settings(next_data, self.data)))
|
| 588 |
if "announcements" in next_data:
|
web/src/app/login/page.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { Input } from "@/components/ui/input";
|
|
| 11 |
import { fetchWechatLoginStatus, login, wechatLogin } from "@/lib/api";
|
| 12 |
import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
|
| 13 |
import { cn } from "@/lib/utils";
|
| 14 |
-
import { getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
|
| 15 |
|
| 16 |
const WECHAT_QR_CODE_URL = "https://wx.z-l.top/qrcode.png";
|
| 17 |
|
|
@@ -66,6 +66,7 @@ export default function LoginPage() {
|
|
| 66 |
subjectId: data.subject_id,
|
| 67 |
name: data.name,
|
| 68 |
accountPoolEnabled: Boolean(data.account_pool_enabled),
|
|
|
|
| 69 |
});
|
| 70 |
router.replace(getDefaultRouteForRole(data.role));
|
| 71 |
} catch (error) {
|
|
@@ -97,6 +98,7 @@ export default function LoginPage() {
|
|
| 97 |
name: data.name,
|
| 98 |
accountPoolEnabled: Boolean(data.account_pool_enabled),
|
| 99 |
wechatOpenid: data.wechat?.openid,
|
|
|
|
| 100 |
});
|
| 101 |
toast.success(data.created ? "微信登录成功,已自动创建账号" : "微信登录成功");
|
| 102 |
router.replace(getDefaultRouteForRole(data.role));
|
|
|
|
| 11 |
import { fetchWechatLoginStatus, login, wechatLogin } from "@/lib/api";
|
| 12 |
import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
|
| 13 |
import { cn } from "@/lib/utils";
|
| 14 |
+
import { buildSessionExpiresAt, getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
|
| 15 |
|
| 16 |
const WECHAT_QR_CODE_URL = "https://wx.z-l.top/qrcode.png";
|
| 17 |
|
|
|
|
| 66 |
subjectId: data.subject_id,
|
| 67 |
name: data.name,
|
| 68 |
accountPoolEnabled: Boolean(data.account_pool_enabled),
|
| 69 |
+
expiresAt: buildSessionExpiresAt(data.login_session_duration_hours),
|
| 70 |
});
|
| 71 |
router.replace(getDefaultRouteForRole(data.role));
|
| 72 |
} catch (error) {
|
|
|
|
| 98 |
name: data.name,
|
| 99 |
accountPoolEnabled: Boolean(data.account_pool_enabled),
|
| 100 |
wechatOpenid: data.wechat?.openid,
|
| 101 |
+
expiresAt: buildSessionExpiresAt(data.login_session_duration_hours),
|
| 102 |
});
|
| 103 |
toast.success(data.created ? "微信登录成功,已自动创建账号" : "微信登录成功");
|
| 104 |
router.replace(getDefaultRouteForRole(data.role));
|
web/src/app/settings/components/config-card.tsx
CHANGED
|
@@ -30,6 +30,14 @@ export function ConfigCard() {
|
|
| 30 |
const setNormalUserDailyCredits = useSettingsStore((state) => state.setNormalUserDailyCredits);
|
| 31 |
const setWechatLoginEnabled = useSettingsStore((state) => state.setWechatLoginEnabled);
|
| 32 |
const setWechatAutoRegisterEnabled = useSettingsStore((state) => state.setWechatAutoRegisterEnabled);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const setShopPurchaseUrl = useSettingsStore((state) => state.setShopPurchaseUrl);
|
| 34 |
const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
|
| 35 |
const setAutoRemoveRateLimitedAccounts = useSettingsStore((state) => state.setAutoRemoveRateLimitedAccounts);
|
|
@@ -88,6 +96,72 @@ export function ConfigCard() {
|
|
| 88 |
管理员登录密钥继续从部署配置读取,不再在此页面展示;如需分发给其他人,请在下方创建内部用户密钥。
|
| 89 |
</div>
|
| 90 |
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
<div className="space-y-2">
|
| 92 |
<label className="text-sm text-stone-700">账号刷新间隔</label>
|
| 93 |
<Input
|
|
@@ -252,6 +326,30 @@ export function ConfigCard() {
|
|
| 252 |
</span>
|
| 253 |
</span>
|
| 254 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
<div className="space-y-2">
|
| 256 |
<label className="text-sm text-stone-700">卡密购买地址</label>
|
| 257 |
<Input
|
|
|
|
| 30 |
const setNormalUserDailyCredits = useSettingsStore((state) => state.setNormalUserDailyCredits);
|
| 31 |
const setWechatLoginEnabled = useSettingsStore((state) => state.setWechatLoginEnabled);
|
| 32 |
const setWechatAutoRegisterEnabled = useSettingsStore((state) => state.setWechatAutoRegisterEnabled);
|
| 33 |
+
const setInternalUserLoginSessionDurationHours = useSettingsStore((state) => state.setInternalUserLoginSessionDurationHours);
|
| 34 |
+
const setNormalUserLoginSessionDurationHours = useSettingsStore((state) => state.setNormalUserLoginSessionDurationHours);
|
| 35 |
+
const setSiteTitle = useSettingsStore((state) => state.setSiteTitle);
|
| 36 |
+
const setSiteLogoUrl = useSettingsStore((state) => state.setSiteLogoUrl);
|
| 37 |
+
const setSiteHeaderTitle = useSettingsStore((state) => state.setSiteHeaderTitle);
|
| 38 |
+
const setSiteExternalLinkLabel = useSettingsStore((state) => state.setSiteExternalLinkLabel);
|
| 39 |
+
const setSiteExternalLinkUrl = useSettingsStore((state) => state.setSiteExternalLinkUrl);
|
| 40 |
+
const setSiteExternalLinkIconSvg = useSettingsStore((state) => state.setSiteExternalLinkIconSvg);
|
| 41 |
const setShopPurchaseUrl = useSettingsStore((state) => state.setShopPurchaseUrl);
|
| 42 |
const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
|
| 43 |
const setAutoRemoveRateLimitedAccounts = useSettingsStore((state) => state.setAutoRemoveRateLimitedAccounts);
|
|
|
|
| 96 |
管理员登录密钥继续从部署配置读取,不再在此页面展示;如需分发给其他人,请在下方创建内部用户密钥。
|
| 97 |
</div>
|
| 98 |
<div className="grid gap-4 md:grid-cols-2">
|
| 99 |
+
<div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-3 md:col-span-2">
|
| 100 |
+
<div>
|
| 101 |
+
<div className="text-sm font-medium text-stone-900">网站品牌显示</div>
|
| 102 |
+
<p className="mt-1 text-xs leading-5 text-stone-500">
|
| 103 |
+
可自定义浏览器标题、Logo/favicon、左上角名称,以及旁边链接的文字和地址。
|
| 104 |
+
</p>
|
| 105 |
+
</div>
|
| 106 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 107 |
+
<div className="space-y-2">
|
| 108 |
+
<label className="text-sm text-stone-700">网站标题</label>
|
| 109 |
+
<Input
|
| 110 |
+
value={String(config?.site_title || "")}
|
| 111 |
+
onChange={(event) => setSiteTitle(event.target.value)}
|
| 112 |
+
placeholder="ChatGPT 号池管理"
|
| 113 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 114 |
+
/>
|
| 115 |
+
</div>
|
| 116 |
+
<div className="space-y-2">
|
| 117 |
+
<label className="text-sm text-stone-700">网站 Logo 地址</label>
|
| 118 |
+
<Input
|
| 119 |
+
value={String(config?.site_logo_url || "")}
|
| 120 |
+
onChange={(event) => setSiteLogoUrl(event.target.value)}
|
| 121 |
+
placeholder="/favicon.ico 或 https://example.com/logo.png"
|
| 122 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 123 |
+
/>
|
| 124 |
+
</div>
|
| 125 |
+
<div className="space-y-2">
|
| 126 |
+
<label className="text-sm text-stone-700">左上角名称</label>
|
| 127 |
+
<Input
|
| 128 |
+
value={String(config?.site_header_title || "")}
|
| 129 |
+
onChange={(event) => setSiteHeaderTitle(event.target.value)}
|
| 130 |
+
placeholder="chatgpt2api"
|
| 131 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 132 |
+
/>
|
| 133 |
+
</div>
|
| 134 |
+
<div className="space-y-2">
|
| 135 |
+
<label className="text-sm text-stone-700">旁边链接文字</label>
|
| 136 |
+
<Input
|
| 137 |
+
value={String(config?.site_external_link_label ?? "")}
|
| 138 |
+
onChange={(event) => setSiteExternalLinkLabel(event.target.value)}
|
| 139 |
+
placeholder="GitHub"
|
| 140 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 141 |
+
/>
|
| 142 |
+
</div>
|
| 143 |
+
<div className="space-y-2">
|
| 144 |
+
<label className="text-sm text-stone-700">旁边链接 SVG 图标代码</label>
|
| 145 |
+
<Textarea
|
| 146 |
+
value={String(config?.site_external_link_icon_svg || "")}
|
| 147 |
+
onChange={(event) => setSiteExternalLinkIconSvg(event.target.value)}
|
| 148 |
+
placeholder={'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">...</svg>'}
|
| 149 |
+
className="min-h-24 rounded-xl border-stone-200 bg-white font-mono text-xs"
|
| 150 |
+
/>
|
| 151 |
+
<p className="text-xs text-stone-500">建议使用 currentColor 作为 fill 或 stroke,这样 hover 时图标会跟随文字一起变色;留空则使用默认图标。</p>
|
| 152 |
+
</div>
|
| 153 |
+
<div className="space-y-2">
|
| 154 |
+
<label className="text-sm text-stone-700">旁边链接地址</label>
|
| 155 |
+
<Input
|
| 156 |
+
value={String(config?.site_external_link_url ?? "")}
|
| 157 |
+
onChange={(event) => setSiteExternalLinkUrl(event.target.value)}
|
| 158 |
+
placeholder="https://github.com/basketikun/chatgpt2api"
|
| 159 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 160 |
+
/>
|
| 161 |
+
<p className="text-xs text-stone-500">链接文字或地址留空时,顶部不会显示这个链接。</p>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
<div className="space-y-2">
|
| 166 |
<label className="text-sm text-stone-700">账号刷新间隔</label>
|
| 167 |
<Input
|
|
|
|
| 326 |
</span>
|
| 327 |
</span>
|
| 328 |
</label>
|
| 329 |
+
<div className="space-y-2">
|
| 330 |
+
<label className="text-sm text-stone-700">内部用户登录会话时长</label>
|
| 331 |
+
<Input
|
| 332 |
+
value={String(config?.internal_user_login_session_duration_hours ?? 0)}
|
| 333 |
+
onChange={(event) => setInternalUserLoginSessionDurationHours(event.target.value)}
|
| 334 |
+
type="number"
|
| 335 |
+
min="0"
|
| 336 |
+
placeholder="0"
|
| 337 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 338 |
+
/>
|
| 339 |
+
<p className="text-xs text-stone-500">单位小时,0 表示长期有效;适用于管理员和内部密钥登录。</p>
|
| 340 |
+
</div>
|
| 341 |
+
<div className="space-y-2">
|
| 342 |
+
<label className="text-sm text-stone-700">微信/普通用户登录会话时长</label>
|
| 343 |
+
<Input
|
| 344 |
+
value={String(config?.normal_user_login_session_duration_hours ?? 72)}
|
| 345 |
+
onChange={(event) => setNormalUserLoginSessionDurationHours(event.target.value)}
|
| 346 |
+
type="number"
|
| 347 |
+
min="0"
|
| 348 |
+
placeholder="72"
|
| 349 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 350 |
+
/>
|
| 351 |
+
<p className="text-xs text-stone-500">单位小时,默认 72 小时(3 天);过期后需要重新用公众号验证码或密钥登录。</p>
|
| 352 |
+
</div>
|
| 353 |
<div className="space-y-2">
|
| 354 |
<label className="text-sm text-stone-700">卡密购买地址</label>
|
| 355 |
<Input
|
web/src/app/settings/store.ts
CHANGED
|
@@ -92,6 +92,15 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
|
|
| 92 |
normal_user_daily_credits: Number(config.normal_user_daily_credits || 0),
|
| 93 |
wechat_login_enabled: config.wechat_login_enabled !== false,
|
| 94 |
wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
shop_purchase_url: String(config.shop_purchase_url || ""),
|
| 96 |
announcements: normalizeAnnouncements(config.announcements),
|
| 97 |
auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
|
|
@@ -239,6 +248,15 @@ type SettingsStore = {
|
|
| 239 |
setNormalUserDailyCredits: (value: string) => void;
|
| 240 |
setWechatLoginEnabled: (value: boolean) => void;
|
| 241 |
setWechatAutoRegisterEnabled: (value: boolean) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
setShopPurchaseUrl: (value: string) => void;
|
| 243 |
setAnnouncements: (items: Announcement[]) => void;
|
| 244 |
setAutoRemoveInvalidAccounts: (value: boolean) => void;
|
|
@@ -383,6 +401,15 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 383 |
normal_user_daily_credits: Math.max(0, Number(config.normal_user_daily_credits) || 0),
|
| 384 |
wechat_login_enabled: config.wechat_login_enabled !== false,
|
| 385 |
wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
shop_purchase_url: String(config.shop_purchase_url || "").trim(),
|
| 387 |
announcements: normalizeAnnouncements(config.announcements).map((item) => ({
|
| 388 |
...item,
|
|
@@ -480,6 +507,42 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 480 |
set((state) => state.config ? { config: { ...state.config, wechat_auto_register_enabled: value } } : {});
|
| 481 |
},
|
| 482 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
setShopPurchaseUrl: (value) => {
|
| 484 |
set((state) => state.config ? { config: { ...state.config, shop_purchase_url: value } } : {});
|
| 485 |
},
|
|
|
|
| 92 |
normal_user_daily_credits: Number(config.normal_user_daily_credits || 0),
|
| 93 |
wechat_login_enabled: config.wechat_login_enabled !== false,
|
| 94 |
wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
|
| 95 |
+
internal_user_login_session_duration_hours: Math.max(0, Number(config.internal_user_login_session_duration_hours) || 0),
|
| 96 |
+
normal_user_login_session_duration_hours: Math.max(0, Number(config.normal_user_login_session_duration_hours ?? config.login_session_duration_hours ?? 72)),
|
| 97 |
+
site_title: String(config.site_title || "ChatGPT 号池管理"),
|
| 98 |
+
site_logo_url: String(config.site_logo_url || ""),
|
| 99 |
+
site_header_title: String(config.site_header_title || "chatgpt2api"),
|
| 100 |
+
site_external_link_label: String(config.site_external_link_label ?? "GitHub"),
|
| 101 |
+
site_external_link_url: String(config.site_external_link_url ?? "https://github.com/basketikun/chatgpt2api"),
|
| 102 |
+
site_external_link_icon_url: String(config.site_external_link_icon_url || ""),
|
| 103 |
+
site_external_link_icon_svg: String(config.site_external_link_icon_svg || ""),
|
| 104 |
shop_purchase_url: String(config.shop_purchase_url || ""),
|
| 105 |
announcements: normalizeAnnouncements(config.announcements),
|
| 106 |
auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
|
|
|
|
| 248 |
setNormalUserDailyCredits: (value: string) => void;
|
| 249 |
setWechatLoginEnabled: (value: boolean) => void;
|
| 250 |
setWechatAutoRegisterEnabled: (value: boolean) => void;
|
| 251 |
+
setInternalUserLoginSessionDurationHours: (value: string) => void;
|
| 252 |
+
setNormalUserLoginSessionDurationHours: (value: string) => void;
|
| 253 |
+
setSiteTitle: (value: string) => void;
|
| 254 |
+
setSiteLogoUrl: (value: string) => void;
|
| 255 |
+
setSiteHeaderTitle: (value: string) => void;
|
| 256 |
+
setSiteExternalLinkLabel: (value: string) => void;
|
| 257 |
+
setSiteExternalLinkUrl: (value: string) => void;
|
| 258 |
+
setSiteExternalLinkIconUrl: (value: string) => void;
|
| 259 |
+
setSiteExternalLinkIconSvg: (value: string) => void;
|
| 260 |
setShopPurchaseUrl: (value: string) => void;
|
| 261 |
setAnnouncements: (items: Announcement[]) => void;
|
| 262 |
setAutoRemoveInvalidAccounts: (value: boolean) => void;
|
|
|
|
| 401 |
normal_user_daily_credits: Math.max(0, Number(config.normal_user_daily_credits) || 0),
|
| 402 |
wechat_login_enabled: config.wechat_login_enabled !== false,
|
| 403 |
wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
|
| 404 |
+
internal_user_login_session_duration_hours: Math.max(0, Number(config.internal_user_login_session_duration_hours) || 0),
|
| 405 |
+
normal_user_login_session_duration_hours: Math.max(0, Number(config.normal_user_login_session_duration_hours) || 0),
|
| 406 |
+
site_title: String(config.site_title || "").trim(),
|
| 407 |
+
site_logo_url: String(config.site_logo_url || "").trim(),
|
| 408 |
+
site_header_title: String(config.site_header_title || "").trim(),
|
| 409 |
+
site_external_link_label: String(config.site_external_link_label ?? "").trim(),
|
| 410 |
+
site_external_link_url: String(config.site_external_link_url ?? "").trim(),
|
| 411 |
+
site_external_link_icon_url: String(config.site_external_link_icon_url || "").trim(),
|
| 412 |
+
site_external_link_icon_svg: String(config.site_external_link_icon_svg || "").trim(),
|
| 413 |
shop_purchase_url: String(config.shop_purchase_url || "").trim(),
|
| 414 |
announcements: normalizeAnnouncements(config.announcements).map((item) => ({
|
| 415 |
...item,
|
|
|
|
| 507 |
set((state) => state.config ? { config: { ...state.config, wechat_auto_register_enabled: value } } : {});
|
| 508 |
},
|
| 509 |
|
| 510 |
+
setInternalUserLoginSessionDurationHours: (value) => {
|
| 511 |
+
set((state) => state.config ? { config: { ...state.config, internal_user_login_session_duration_hours: value } } : {});
|
| 512 |
+
},
|
| 513 |
+
|
| 514 |
+
setNormalUserLoginSessionDurationHours: (value) => {
|
| 515 |
+
set((state) => state.config ? { config: { ...state.config, normal_user_login_session_duration_hours: value } } : {});
|
| 516 |
+
},
|
| 517 |
+
|
| 518 |
+
setSiteTitle: (value) => {
|
| 519 |
+
set((state) => state.config ? { config: { ...state.config, site_title: value } } : {});
|
| 520 |
+
},
|
| 521 |
+
|
| 522 |
+
setSiteLogoUrl: (value) => {
|
| 523 |
+
set((state) => state.config ? { config: { ...state.config, site_logo_url: value } } : {});
|
| 524 |
+
},
|
| 525 |
+
|
| 526 |
+
setSiteHeaderTitle: (value) => {
|
| 527 |
+
set((state) => state.config ? { config: { ...state.config, site_header_title: value } } : {});
|
| 528 |
+
},
|
| 529 |
+
|
| 530 |
+
setSiteExternalLinkLabel: (value) => {
|
| 531 |
+
set((state) => state.config ? { config: { ...state.config, site_external_link_label: value } } : {});
|
| 532 |
+
},
|
| 533 |
+
|
| 534 |
+
setSiteExternalLinkUrl: (value) => {
|
| 535 |
+
set((state) => state.config ? { config: { ...state.config, site_external_link_url: value } } : {});
|
| 536 |
+
},
|
| 537 |
+
|
| 538 |
+
setSiteExternalLinkIconUrl: (value) => {
|
| 539 |
+
set((state) => state.config ? { config: { ...state.config, site_external_link_icon_url: value } } : {});
|
| 540 |
+
},
|
| 541 |
+
|
| 542 |
+
setSiteExternalLinkIconSvg: (value) => {
|
| 543 |
+
set((state) => state.config ? { config: { ...state.config, site_external_link_icon_svg: value } } : {});
|
| 544 |
+
},
|
| 545 |
+
|
| 546 |
setShopPurchaseUrl: (value) => {
|
| 547 |
set((state) => state.config ? { config: { ...state.config, shop_purchase_url: value } } : {});
|
| 548 |
},
|
web/src/components/app-shell.tsx
CHANGED
|
@@ -4,9 +4,11 @@ import { usePathname } from "next/navigation";
|
|
| 4 |
import type { ReactNode } from "react";
|
| 5 |
|
| 6 |
import { TopNav } from "@/components/top-nav";
|
|
|
|
| 7 |
|
| 8 |
export function AppShell({ children }: { children: ReactNode }) {
|
| 9 |
const pathname = usePathname();
|
|
|
|
| 10 |
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
|
| 11 |
const isLoginPage = normalizedPath === "/login";
|
| 12 |
const isImagePage = normalizedPath === "/image";
|
|
@@ -20,7 +22,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|
| 20 |
<main className="app-page-background min-h-screen overflow-x-hidden px-4 pt-0 pb-2 text-stone-900 sm:px-6 lg:px-8">
|
| 21 |
<div className="app-page-background fixed inset-x-0 top-0 z-40 px-4 pt-[env(safe-area-inset-top)] pb-2 sm:px-6 sm:pt-2 lg:px-8">
|
| 22 |
<div className="mx-auto max-w-[1440px]">
|
| 23 |
-
<TopNav />
|
| 24 |
</div>
|
| 25 |
</div>
|
| 26 |
<div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[calc(env(safe-area-inset-top)+4.5rem)] sm:gap-5 sm:pt-[5.25rem]">
|
|
@@ -33,7 +35,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|
| 33 |
return (
|
| 34 |
<main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 sm:px-6 sm:pt-2 lg:px-8">
|
| 35 |
<div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
|
| 36 |
-
<TopNav />
|
| 37 |
{children}
|
| 38 |
</div>
|
| 39 |
</main>
|
|
|
|
| 4 |
import type { ReactNode } from "react";
|
| 5 |
|
| 6 |
import { TopNav } from "@/components/top-nav";
|
| 7 |
+
import { useBranding } from "@/lib/branding";
|
| 8 |
|
| 9 |
export function AppShell({ children }: { children: ReactNode }) {
|
| 10 |
const pathname = usePathname();
|
| 11 |
+
const branding = useBranding({ applyDocument: true });
|
| 12 |
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
|
| 13 |
const isLoginPage = normalizedPath === "/login";
|
| 14 |
const isImagePage = normalizedPath === "/image";
|
|
|
|
| 22 |
<main className="app-page-background min-h-screen overflow-x-hidden px-4 pt-0 pb-2 text-stone-900 sm:px-6 lg:px-8">
|
| 23 |
<div className="app-page-background fixed inset-x-0 top-0 z-40 px-4 pt-[env(safe-area-inset-top)] pb-2 sm:px-6 sm:pt-2 lg:px-8">
|
| 24 |
<div className="mx-auto max-w-[1440px]">
|
| 25 |
+
<TopNav branding={branding} />
|
| 26 |
</div>
|
| 27 |
</div>
|
| 28 |
<div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[calc(env(safe-area-inset-top)+4.5rem)] sm:gap-5 sm:pt-[5.25rem]">
|
|
|
|
| 35 |
return (
|
| 36 |
<main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 sm:px-6 sm:pt-2 lg:px-8">
|
| 37 |
<div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
|
| 38 |
+
<TopNav branding={branding} />
|
| 39 |
{children}
|
| 40 |
</div>
|
| 41 |
</main>
|
web/src/components/top-nav.tsx
CHANGED
|
@@ -2,12 +2,13 @@
|
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
import { useEffect, useState } from "react";
|
| 5 |
-
import { Github } from "lucide-react";
|
| 6 |
import { usePathname } from "next/navigation";
|
| 7 |
|
| 8 |
import webConfig from "@/constants/common-env";
|
| 9 |
import { AnnouncementsPopover } from "@/components/announcements-popover";
|
| 10 |
import { getValidatedAuthSession } from "@/lib/auth-session";
|
|
|
|
| 11 |
import { cn } from "@/lib/utils";
|
| 12 |
import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
| 13 |
|
|
@@ -35,7 +36,58 @@ function getRoleLabel(role: StoredAuthSession["role"]) {
|
|
| 35 |
return "内部用户";
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
const pathname = usePathname();
|
| 40 |
const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
|
| 41 |
|
|
@@ -91,6 +143,13 @@ export function TopNav() {
|
|
| 91 |
: userNavItems.filter((item) => item.href !== "/accounts");
|
| 92 |
const roleLabel = getRoleLabel(session.role);
|
| 93 |
const displayName = session.name.trim() || roleLabel;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
return (
|
| 96 |
<header className="border-b border-stone-100/50">
|
|
@@ -98,20 +157,29 @@ export function TopNav() {
|
|
| 98 |
<div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
|
| 99 |
<Link
|
| 100 |
href="/image"
|
| 101 |
-
className="shrink-0 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
|
| 102 |
>
|
| 103 |
-
|
|
|
|
| 104 |
</Link>
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
<button
|
| 116 |
type="button"
|
| 117 |
className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
|
|
|
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
import { useEffect, useState } from "react";
|
| 5 |
+
import { ExternalLink, Github } from "lucide-react";
|
| 6 |
import { usePathname } from "next/navigation";
|
| 7 |
|
| 8 |
import webConfig from "@/constants/common-env";
|
| 9 |
import { AnnouncementsPopover } from "@/components/announcements-popover";
|
| 10 |
import { getValidatedAuthSession } from "@/lib/auth-session";
|
| 11 |
+
import { DEFAULT_BRANDING, type normalizeBranding } from "@/lib/branding";
|
| 12 |
import { cn } from "@/lib/utils";
|
| 13 |
import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
| 14 |
|
|
|
|
| 36 |
return "内部用户";
|
| 37 |
}
|
| 38 |
|
| 39 |
+
function sanitizeSvgMarkup(markup: string) {
|
| 40 |
+
const trimmed = markup.trim();
|
| 41 |
+
if (!/^\s*<svg[\s>][\s\S]*<\/svg>\s*$/i.test(trimmed)) {
|
| 42 |
+
return "";
|
| 43 |
+
}
|
| 44 |
+
const safeMarkup = trimmed
|
| 45 |
+
.replace(/<\s*(script|foreignObject)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
|
| 46 |
+
.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
| 47 |
+
.replace(/\s+(?:href|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi, "");
|
| 48 |
+
return colorizeSvgForHover(addMissingViewBox(safeMarkup));
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function readSvgAttribute(markup: string, name: string) {
|
| 52 |
+
const match = markup.match(new RegExp(`<svg\\b[^>]*\\s${name}=["']?([^"'\\s>]+)`, "i"));
|
| 53 |
+
return match?.[1] || "";
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function addMissingViewBox(markup: string) {
|
| 57 |
+
if (/\sviewBox\s*=/i.test(markup)) {
|
| 58 |
+
return markup;
|
| 59 |
+
}
|
| 60 |
+
const width = Number.parseFloat(readSvgAttribute(markup, "width"));
|
| 61 |
+
const height = Number.parseFloat(readSvgAttribute(markup, "height"));
|
| 62 |
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
| 63 |
+
return markup;
|
| 64 |
+
}
|
| 65 |
+
return markup.replace(/<svg\b/i, `<svg viewBox="0 0 ${width} ${height}"`);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function colorizeSvgForHover(markup: string) {
|
| 69 |
+
return markup
|
| 70 |
+
.replace(/\s(fill|stroke)=["'](?!none\b|currentColor\b|url\()[^"']+["']/gi, ' $1="currentColor"')
|
| 71 |
+
.replace(/\b(fill|stroke)\s*:\s*(?!none\b|currentColor\b|url\()[^;"']+/gi, "$1:currentColor");
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function InlineSvgIcon({ svg }: { svg: string }) {
|
| 75 |
+
const safeSvg = sanitizeSvgMarkup(svg);
|
| 76 |
+
if (!safeSvg) {
|
| 77 |
+
return null;
|
| 78 |
+
}
|
| 79 |
+
return (
|
| 80 |
+
<span
|
| 81 |
+
className="inline-flex size-4 shrink-0 items-center justify-center overflow-visible text-current leading-none [&_svg]:block [&_svg]:size-full [&_svg]:shrink-0 [&_svg]:overflow-visible"
|
| 82 |
+
aria-hidden="true"
|
| 83 |
+
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
| 84 |
+
/>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
type Branding = ReturnType<typeof normalizeBranding>;
|
| 89 |
+
|
| 90 |
+
export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding }) {
|
| 91 |
const pathname = usePathname();
|
| 92 |
const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
|
| 93 |
|
|
|
|
| 143 |
: userNavItems.filter((item) => item.href !== "/accounts");
|
| 144 |
const roleLabel = getRoleLabel(session.role);
|
| 145 |
const displayName = session.name.trim() || roleLabel;
|
| 146 |
+
const headerTitle = branding.site_header_title.trim() || DEFAULT_BRANDING.site_header_title;
|
| 147 |
+
const logoUrl = branding.site_logo_url.trim();
|
| 148 |
+
const externalLinkLabel = branding.site_external_link_label.trim();
|
| 149 |
+
const externalLinkUrl = branding.site_external_link_url.trim();
|
| 150 |
+
const externalLinkIconUrl = branding.site_external_link_icon_url.trim();
|
| 151 |
+
const externalLinkIconSvg = branding.site_external_link_icon_svg.trim();
|
| 152 |
+
const ExternalLinkIcon = /github/i.test(`${externalLinkLabel} ${externalLinkUrl}`) ? Github : ExternalLink;
|
| 153 |
|
| 154 |
return (
|
| 155 |
<header className="border-b border-stone-100/50">
|
|
|
|
| 157 |
<div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
|
| 158 |
<Link
|
| 159 |
href="/image"
|
| 160 |
+
className="inline-flex shrink-0 items-center gap-1.5 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
|
| 161 |
>
|
| 162 |
+
{logoUrl ? <img src={logoUrl} alt="" className="size-5 rounded-md object-cover" /> : null}
|
| 163 |
+
<span>{headerTitle}</span>
|
| 164 |
</Link>
|
| 165 |
+
{externalLinkUrl && externalLinkLabel ? (
|
| 166 |
+
<a
|
| 167 |
+
href={externalLinkUrl}
|
| 168 |
+
target="_blank"
|
| 169 |
+
rel="noreferrer"
|
| 170 |
+
className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700"
|
| 171 |
+
aria-label={externalLinkLabel}
|
| 172 |
+
>
|
| 173 |
+
{externalLinkIconSvg ? (
|
| 174 |
+
<InlineSvgIcon svg={externalLinkIconSvg} />
|
| 175 |
+
) : externalLinkIconUrl ? (
|
| 176 |
+
<img src={externalLinkIconUrl} alt="" className="size-4 rounded-sm object-cover" />
|
| 177 |
+
) : (
|
| 178 |
+
<ExternalLinkIcon className="size-4" />
|
| 179 |
+
)}
|
| 180 |
+
<span className="hidden md:inline">{externalLinkLabel}</span>
|
| 181 |
+
</a>
|
| 182 |
+
) : null}
|
| 183 |
<button
|
| 184 |
type="button"
|
| 185 |
className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
|
web/src/lib/api.ts
CHANGED
|
@@ -107,6 +107,16 @@ export type SettingsConfig = {
|
|
| 107 |
normal_user_daily_credits?: number | string;
|
| 108 |
wechat_login_enabled?: boolean;
|
| 109 |
wechat_auto_register_enabled?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
shop_purchase_url?: string;
|
| 111 |
auto_remove_invalid_accounts?: boolean;
|
| 112 |
auto_remove_rate_limited_accounts?: boolean;
|
|
@@ -266,10 +276,21 @@ export type LoginResponse = {
|
|
| 266 |
subject_id: string;
|
| 267 |
name: string;
|
| 268 |
account_pool_enabled: boolean;
|
|
|
|
| 269 |
key?: string;
|
| 270 |
created?: boolean;
|
| 271 |
};
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
export type UserKey = {
|
| 274 |
id: string;
|
| 275 |
name: string;
|
|
@@ -792,7 +813,13 @@ export async function wechatLogin(code: string) {
|
|
| 792 |
}
|
| 793 |
|
| 794 |
export async function fetchWechatLoginStatus() {
|
| 795 |
-
return httpRequest<{ enabled: boolean; auto_register_enabled?: boolean }>("/api/auth/wechat/status", {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
redirectOnUnauthorized: false,
|
| 797 |
});
|
| 798 |
}
|
|
|
|
| 107 |
normal_user_daily_credits?: number | string;
|
| 108 |
wechat_login_enabled?: boolean;
|
| 109 |
wechat_auto_register_enabled?: boolean;
|
| 110 |
+
internal_user_login_session_duration_hours?: number | string;
|
| 111 |
+
normal_user_login_session_duration_hours?: number | string;
|
| 112 |
+
login_session_duration_hours?: number | string;
|
| 113 |
+
site_title?: string;
|
| 114 |
+
site_logo_url?: string;
|
| 115 |
+
site_header_title?: string;
|
| 116 |
+
site_external_link_label?: string;
|
| 117 |
+
site_external_link_url?: string;
|
| 118 |
+
site_external_link_icon_url?: string;
|
| 119 |
+
site_external_link_icon_svg?: string;
|
| 120 |
shop_purchase_url?: string;
|
| 121 |
auto_remove_invalid_accounts?: boolean;
|
| 122 |
auto_remove_rate_limited_accounts?: boolean;
|
|
|
|
| 276 |
subject_id: string;
|
| 277 |
name: string;
|
| 278 |
account_pool_enabled: boolean;
|
| 279 |
+
login_session_duration_hours?: number;
|
| 280 |
key?: string;
|
| 281 |
created?: boolean;
|
| 282 |
};
|
| 283 |
|
| 284 |
+
export type BrandingConfig = {
|
| 285 |
+
site_title?: string;
|
| 286 |
+
site_logo_url?: string;
|
| 287 |
+
site_header_title?: string;
|
| 288 |
+
site_external_link_label?: string;
|
| 289 |
+
site_external_link_url?: string;
|
| 290 |
+
site_external_link_icon_url?: string;
|
| 291 |
+
site_external_link_icon_svg?: string;
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
export type UserKey = {
|
| 295 |
id: string;
|
| 296 |
name: string;
|
|
|
|
| 813 |
}
|
| 814 |
|
| 815 |
export async function fetchWechatLoginStatus() {
|
| 816 |
+
return httpRequest<{ enabled: boolean; auto_register_enabled?: boolean; login_session_duration_hours?: number }>("/api/auth/wechat/status", {
|
| 817 |
+
redirectOnUnauthorized: false,
|
| 818 |
+
});
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
export async function fetchBranding() {
|
| 822 |
+
return httpRequest<{ branding: BrandingConfig }>("/api/branding", {
|
| 823 |
redirectOnUnauthorized: false,
|
| 824 |
});
|
| 825 |
}
|
web/src/lib/auth-session.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { login } from "@/lib/api";
|
| 4 |
-
import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
| 5 |
|
| 6 |
const VALIDATION_CACHE_MS = 10_000;
|
| 7 |
let cachedSession: StoredAuthSession | null = null;
|
|
@@ -52,6 +52,7 @@ async function validateStoredSession(storedSession: StoredAuthSession): Promise<
|
|
| 52 |
name: data.name,
|
| 53 |
accountPoolEnabled: Boolean(data.account_pool_enabled),
|
| 54 |
wechatOpenid: storedSession.wechatOpenid,
|
|
|
|
| 55 |
};
|
| 56 |
await setStoredAuthSession(nextSession);
|
| 57 |
cachedSession = nextSession;
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { login } from "@/lib/api";
|
| 4 |
+
import { buildSessionExpiresAt, clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
|
| 5 |
|
| 6 |
const VALIDATION_CACHE_MS = 10_000;
|
| 7 |
let cachedSession: StoredAuthSession | null = null;
|
|
|
|
| 52 |
name: data.name,
|
| 53 |
accountPoolEnabled: Boolean(data.account_pool_enabled),
|
| 54 |
wechatOpenid: storedSession.wechatOpenid,
|
| 55 |
+
expiresAt: storedSession.expiresAt || buildSessionExpiresAt(data.login_session_duration_hours),
|
| 56 |
};
|
| 57 |
await setStoredAuthSession(nextSession);
|
| 58 |
cachedSession = nextSession;
|
web/src/lib/branding.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
|
| 5 |
+
import { fetchBranding, type BrandingConfig } from "@/lib/api";
|
| 6 |
+
|
| 7 |
+
export const DEFAULT_BRANDING: Required<BrandingConfig> = {
|
| 8 |
+
site_title: "ChatGPT 号池管理",
|
| 9 |
+
site_logo_url: "",
|
| 10 |
+
site_header_title: "chatgpt2api",
|
| 11 |
+
site_external_link_label: "GitHub",
|
| 12 |
+
site_external_link_url: "https://github.com/basketikun/chatgpt2api",
|
| 13 |
+
site_external_link_icon_url: "",
|
| 14 |
+
site_external_link_icon_svg: "",
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
function normalizeText(value: unknown, fallback: string) {
|
| 18 |
+
const normalized = String(value ?? "").trim();
|
| 19 |
+
return normalized || fallback;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function normalizeBranding(value: BrandingConfig | null | undefined): Required<BrandingConfig> {
|
| 23 |
+
return {
|
| 24 |
+
site_title: normalizeText(value?.site_title, DEFAULT_BRANDING.site_title),
|
| 25 |
+
site_logo_url: String(value?.site_logo_url ?? "").trim(),
|
| 26 |
+
site_header_title: normalizeText(value?.site_header_title, DEFAULT_BRANDING.site_header_title),
|
| 27 |
+
site_external_link_label: String(value?.site_external_link_label ?? DEFAULT_BRANDING.site_external_link_label).trim(),
|
| 28 |
+
site_external_link_url: String(value?.site_external_link_url ?? DEFAULT_BRANDING.site_external_link_url).trim(),
|
| 29 |
+
site_external_link_icon_url: String(value?.site_external_link_icon_url ?? "").trim(),
|
| 30 |
+
site_external_link_icon_svg: String(value?.site_external_link_icon_svg ?? "").trim(),
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function updateFavicon(href: string) {
|
| 35 |
+
const targetHref = href || "/favicon.ico";
|
| 36 |
+
let icon = document.querySelector<HTMLLinkElement>("link[data-branding-icon='favicon']");
|
| 37 |
+
if (!icon) {
|
| 38 |
+
icon = document.createElement("link");
|
| 39 |
+
icon.rel = "icon";
|
| 40 |
+
icon.setAttribute("data-branding-icon", "favicon");
|
| 41 |
+
document.head.appendChild(icon);
|
| 42 |
+
}
|
| 43 |
+
icon.rel = "icon";
|
| 44 |
+
icon.href = targetHref;
|
| 45 |
+
icon.type = guessIconType(targetHref);
|
| 46 |
+
|
| 47 |
+
let appleIcon = document.querySelector<HTMLLinkElement>("link[data-branding-icon='apple-touch-icon']");
|
| 48 |
+
if (!appleIcon) {
|
| 49 |
+
appleIcon = document.createElement("link");
|
| 50 |
+
appleIcon.rel = "apple-touch-icon";
|
| 51 |
+
appleIcon.setAttribute("data-branding-icon", "apple-touch-icon");
|
| 52 |
+
document.head.appendChild(appleIcon);
|
| 53 |
+
}
|
| 54 |
+
appleIcon.rel = "apple-touch-icon";
|
| 55 |
+
appleIcon.href = targetHref;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function guessIconType(href: string) {
|
| 59 |
+
const cleanHref = href.split(/[?#]/, 1)[0]?.toLowerCase() || "";
|
| 60 |
+
if (cleanHref.endsWith(".svg")) {
|
| 61 |
+
return "image/svg+xml";
|
| 62 |
+
}
|
| 63 |
+
if (cleanHref.endsWith(".jpg") || cleanHref.endsWith(".jpeg")) {
|
| 64 |
+
return "image/jpeg";
|
| 65 |
+
}
|
| 66 |
+
if (cleanHref.endsWith(".webp")) {
|
| 67 |
+
return "image/webp";
|
| 68 |
+
}
|
| 69 |
+
if (cleanHref.endsWith(".ico")) {
|
| 70 |
+
return "image/x-icon";
|
| 71 |
+
}
|
| 72 |
+
return "image/png";
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function applyBrandingToDocument(branding: Required<BrandingConfig>) {
|
| 76 |
+
document.title = branding.site_title;
|
| 77 |
+
updateFavicon(branding.site_logo_url);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export function useBranding(options: { applyDocument?: boolean } = {}) {
|
| 81 |
+
const [branding, setBranding] = useState(() => normalizeBranding(null));
|
| 82 |
+
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
let active = true;
|
| 85 |
+
void fetchBranding()
|
| 86 |
+
.then((data) => {
|
| 87 |
+
if (active) {
|
| 88 |
+
setBranding(normalizeBranding(data.branding));
|
| 89 |
+
}
|
| 90 |
+
})
|
| 91 |
+
.catch(() => {
|
| 92 |
+
if (active) {
|
| 93 |
+
setBranding(normalizeBranding(null));
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
return () => {
|
| 97 |
+
active = false;
|
| 98 |
+
};
|
| 99 |
+
}, []);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
if (options.applyDocument) {
|
| 103 |
+
applyBrandingToDocument(branding);
|
| 104 |
+
}
|
| 105 |
+
}, [branding, options.applyDocument]);
|
| 106 |
+
|
| 107 |
+
return branding;
|
| 108 |
+
}
|
web/src/store/auth.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type StoredAuthSession = {
|
|
| 11 |
name: string;
|
| 12 |
accountPoolEnabled?: boolean;
|
| 13 |
wechatOpenid?: string;
|
|
|
|
| 14 |
};
|
| 15 |
|
| 16 |
export const AUTH_KEY_STORAGE_KEY = "chatgpt2api_auth_key";
|
|
@@ -49,9 +50,26 @@ function normalizeSession(value: unknown, fallbackKey = ""): StoredAuthSession |
|
|
| 49 |
name: String(candidate.name || "").trim(),
|
| 50 |
accountPoolEnabled: candidate.accountPoolEnabled === true,
|
| 51 |
wechatOpenid: String(candidate.wechatOpenid || "").trim(),
|
|
|
|
| 52 |
};
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
export function getDefaultRouteForRole(role: AuthRole) {
|
| 56 |
return role === "admin" ? "/accounts" : "/image";
|
| 57 |
}
|
|
@@ -63,8 +81,16 @@ export async function getStoredAuthKey() {
|
|
| 63 |
if (authSessionClearedInMemory) {
|
| 64 |
return "";
|
| 65 |
}
|
| 66 |
-
const
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
export async function getStoredAuthSession() {
|
|
@@ -82,6 +108,10 @@ export async function getStoredAuthSession() {
|
|
| 82 |
|
| 83 |
const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
|
| 84 |
if (normalizedSession) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
if (normalizedSession.key !== String(storedKey || "").trim()) {
|
| 86 |
await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key);
|
| 87 |
}
|
|
|
|
| 11 |
name: string;
|
| 12 |
accountPoolEnabled?: boolean;
|
| 13 |
wechatOpenid?: string;
|
| 14 |
+
expiresAt?: string;
|
| 15 |
};
|
| 16 |
|
| 17 |
export const AUTH_KEY_STORAGE_KEY = "chatgpt2api_auth_key";
|
|
|
|
| 50 |
name: String(candidate.name || "").trim(),
|
| 51 |
accountPoolEnabled: candidate.accountPoolEnabled === true,
|
| 52 |
wechatOpenid: String(candidate.wechatOpenid || "").trim(),
|
| 53 |
+
expiresAt: String(candidate.expiresAt || "").trim(),
|
| 54 |
};
|
| 55 |
}
|
| 56 |
|
| 57 |
+
function isSessionExpired(session: StoredAuthSession | null) {
|
| 58 |
+
if (!session?.expiresAt) {
|
| 59 |
+
return false;
|
| 60 |
+
}
|
| 61 |
+
const expiresAt = new Date(session.expiresAt).getTime();
|
| 62 |
+
return Number.isFinite(expiresAt) && expiresAt <= Date.now();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function buildSessionExpiresAt(durationHours: unknown) {
|
| 66 |
+
const hours = Number(durationHours);
|
| 67 |
+
if (!Number.isFinite(hours) || hours <= 0) {
|
| 68 |
+
return "";
|
| 69 |
+
}
|
| 70 |
+
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
export function getDefaultRouteForRole(role: AuthRole) {
|
| 74 |
return role === "admin" ? "/accounts" : "/image";
|
| 75 |
}
|
|
|
|
| 81 |
if (authSessionClearedInMemory) {
|
| 82 |
return "";
|
| 83 |
}
|
| 84 |
+
const [storedKey, storedSession] = await Promise.all([
|
| 85 |
+
authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY),
|
| 86 |
+
authStorage.getItem<StoredAuthSession>(AUTH_SESSION_STORAGE_KEY),
|
| 87 |
+
]);
|
| 88 |
+
const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
|
| 89 |
+
if (isSessionExpired(normalizedSession)) {
|
| 90 |
+
await clearStoredAuthSession();
|
| 91 |
+
return "";
|
| 92 |
+
}
|
| 93 |
+
return String(normalizedSession?.key || storedKey || "").trim();
|
| 94 |
}
|
| 95 |
|
| 96 |
export async function getStoredAuthSession() {
|
|
|
|
| 108 |
|
| 109 |
const normalizedSession = normalizeSession(storedSession, String(storedKey || ""));
|
| 110 |
if (normalizedSession) {
|
| 111 |
+
if (isSessionExpired(normalizedSession)) {
|
| 112 |
+
await clearStoredAuthSession();
|
| 113 |
+
return null;
|
| 114 |
+
}
|
| 115 |
if (normalizedSession.key !== String(storedKey || "").trim()) {
|
| 116 |
await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key);
|
| 117 |
}
|