chore: stage changes before history rewrite
This commit is contained in:
@@ -8,6 +8,15 @@
|
||||
"nav.invites": "Invites",
|
||||
"nav.logout": "Logout",
|
||||
"nav.api_docs": "API Docs",
|
||||
"nav.showcase": "Showcase",
|
||||
"nav.admin": "Admin",
|
||||
"admin.title": "System Admin",
|
||||
"admin.tab_invites": "Invite Codes",
|
||||
"admin.tab_config": "Settings",
|
||||
"admin.config_title": "System Settings",
|
||||
"admin.copyright_label": "Copyright Text",
|
||||
"admin.copyright_hint": "Displayed at the bottom of all pages. Leave empty to hide.",
|
||||
"admin.save_config": "Save",
|
||||
"login.title": "Login",
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
@@ -245,5 +254,23 @@
|
||||
"api_docs.ep.key_name_hint": "string (optional) — a label for the key",
|
||||
"api_docs.ep.country_hint": "two uppercase letters, e.g. JP",
|
||||
"api_docs.ep.date_hint": "format: YYYY-MM-DD or YYYY-MM-DDTHH:MM",
|
||||
"apikey.permissions": "Permissions",
|
||||
"apikey.perm.profiles_read": "Read Profiles",
|
||||
"apikey.perm.profiles_write": "Write Profiles",
|
||||
"apikey.perm.postcards_read": "Read Postcards",
|
||||
"apikey.perm.postcards_write": "Write Postcards",
|
||||
"apikey.perm.images_upload": "Upload Images",
|
||||
"apikey.perm.hint": "No selection = full access (backward-compatible). Once checked, the key only has the selected permissions.",
|
||||
"apikey.perm_full_access": "Full Access",
|
||||
"apikey.edit": "Edit",
|
||||
"apikey.save": "Save",
|
||||
"apikey.cancel": "Cancel",
|
||||
"api_docs.permissions": "Permission System",
|
||||
"api_docs.permissions_desc": "Each API key can be assigned fine-grained permission scopes. Keys without any scope (legacy) have full access. Once scopes are set, the key can only access the corresponding endpoints.",
|
||||
"api_docs.scope": "Scope",
|
||||
"api_docs.scope_desc": "Description",
|
||||
"api_docs.scope_endpoints": "Endpoints",
|
||||
"api_docs.permissions_note": "A 403 response means the key is missing a required permission.",
|
||||
"api_docs.permissions_keys_note": "API Key management endpoints (GET/POST/DELETE /keys) only require a valid API key for authentication, no specific permission scope needed.",
|
||||
"misc.required": " *"
|
||||
}
|
||||
@@ -8,6 +8,15 @@
|
||||
"nav.invites": "邀请码管理",
|
||||
"nav.logout": "退出",
|
||||
"nav.api_docs": "API 文档",
|
||||
"nav.showcase": "展示页",
|
||||
"nav.admin": "系统管理",
|
||||
"admin.title": "系统管理",
|
||||
"admin.tab_invites": "邀请码管理",
|
||||
"admin.tab_config": "系统设置",
|
||||
"admin.config_title": "系统设置",
|
||||
"admin.copyright_label": "Copyright 文本",
|
||||
"admin.copyright_hint": "将显示在所有页面底部,留空则不显示。",
|
||||
"admin.save_config": "保存设置",
|
||||
"login.title": "登录",
|
||||
"login.username": "用户名",
|
||||
"login.password": "密码",
|
||||
@@ -245,5 +254,23 @@
|
||||
"api_docs.ep.key_name_hint": "string(可选)— Key 的备注名称",
|
||||
"api_docs.ep.country_hint": "两个大写字母,如 JP",
|
||||
"api_docs.ep.date_hint": "格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM",
|
||||
"apikey.permissions": "权限范围",
|
||||
"apikey.perm.profiles_read": "读取 Profile",
|
||||
"apikey.perm.profiles_write": "写入 Profile",
|
||||
"apikey.perm.postcards_read": "读取明信片",
|
||||
"apikey.perm.postcards_write": "写入明信片",
|
||||
"apikey.perm.images_upload": "上传图片",
|
||||
"apikey.perm.hint": "不勾选任何权限 = 完全访问(兼容旧 Key)。勾选后该 Key 仅拥有所选权限。",
|
||||
"apikey.perm_full_access": "完全访问",
|
||||
"apikey.edit": "编辑",
|
||||
"apikey.save": "保存",
|
||||
"apikey.cancel": "取消",
|
||||
"api_docs.permissions": "权限系统",
|
||||
"api_docs.permissions_desc": "每个 API Key 可以配置细粒度的权限范围。不配置权限时(旧 Key),默认完全访问。配置后仅拥有所选权限。",
|
||||
"api_docs.scope": "权限 Scope",
|
||||
"api_docs.scope_desc": "说明",
|
||||
"api_docs.scope_endpoints": "对应端点",
|
||||
"api_docs.permissions_note": "403 响应表示缺少所需权限。",
|
||||
"api_docs.permissions_keys_note": "API Key 管理端点(GET/POST/DELETE /keys)仅需有效 Key 鉴权,不需要特定权限。",
|
||||
"misc.required": " *"
|
||||
}
|
||||
@@ -109,7 +109,41 @@ class ApiKey(Base):
|
||||
name = Column(String(64), nullable=False, default="")
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
permissions = Column(Text, nullable=False, default="[]")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_config"
|
||||
|
||||
key = Column(String(64), primary_key=True)
|
||||
value = Column(Text, nullable=False, default="")
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
# ─── API Key Permission System ───────────────────────────────────
|
||||
|
||||
# Scopes: "resource:action"
|
||||
# Empty list [] means full access (backward compatible).
|
||||
PERM_PROFILES_READ = "profiles:read"
|
||||
PERM_PROFILES_WRITE = "profiles:write"
|
||||
PERM_POSTCARDS_READ = "postcards:read"
|
||||
PERM_POSTCARDS_WRITE = "postcards:write"
|
||||
PERM_IMAGES_UPLOAD = "images:upload"
|
||||
|
||||
ALL_PERMISSIONS = [
|
||||
PERM_PROFILES_READ,
|
||||
PERM_PROFILES_WRITE,
|
||||
PERM_POSTCARDS_READ,
|
||||
PERM_POSTCARDS_WRITE,
|
||||
PERM_IMAGES_UPLOAD,
|
||||
]
|
||||
|
||||
PERMISSION_GROUPS: dict[str, list[str]] = {
|
||||
"profiles": [PERM_PROFILES_READ, PERM_PROFILES_WRITE],
|
||||
"postcards": [PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE],
|
||||
"images": [PERM_IMAGES_UPLOAD],
|
||||
}
|
||||
|
||||
@@ -7,26 +7,63 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey, Profile, Postcard, User
|
||||
from app.models import (
|
||||
ApiKey, Profile, Postcard, User,
|
||||
PERM_PROFILES_READ, PERM_PROFILES_WRITE,
|
||||
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
|
||||
PERM_IMAGES_UPLOAD,
|
||||
)
|
||||
from app.config import UPLOAD_DIR
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["api"])
|
||||
|
||||
|
||||
# ─── Auth Dependency ──────────────────────────────────────────────
|
||||
# ─── Auth Dependencies ────────────────────────────────────────────
|
||||
|
||||
def get_api_user(
|
||||
def _parse_permissions(raw: str) -> list[str]:
|
||||
"""Parse JSON permissions string from DB, return list of scope strings."""
|
||||
import json
|
||||
try:
|
||||
val = json.loads(raw)
|
||||
return val if isinstance(val, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def get_api_key(
|
||||
x_api_key: str = Header(..., alias="X-API-Key"),
|
||||
db: DbSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Validate X-API-Key header and return the associated user."""
|
||||
api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
|
||||
if not api_key or not api_key.is_active:
|
||||
) -> ApiKey:
|
||||
"""Validate X-API-Key header and return the ApiKey object (with permissions)."""
|
||||
ak = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
|
||||
if not ak or not ak.is_active:
|
||||
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
|
||||
# Update last_used_at
|
||||
api_key.last_used_at = datetime.now(timezone.utc)
|
||||
ak.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return api_key.user
|
||||
return ak
|
||||
|
||||
|
||||
def get_api_user(ak: ApiKey = Depends(get_api_key)) -> User:
|
||||
"""Return the User associated with a valid API key (no permission check)."""
|
||||
return ak.user
|
||||
|
||||
|
||||
def require_scopes(*scopes: str):
|
||||
"""Return a dependency that checks the API key has ALL given scopes.
|
||||
|
||||
Keys with empty permissions list (legacy) are treated as full-access.
|
||||
"""
|
||||
def _dep(ak: ApiKey = Depends(get_api_key)) -> ApiKey:
|
||||
perms = _parse_permissions(ak.permissions)
|
||||
if perms:
|
||||
missing = [s for s in scopes if s not in perms]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Missing required permissions: {', '.join(missing)}",
|
||||
)
|
||||
return ak
|
||||
return _dep
|
||||
|
||||
|
||||
def get_api_user_optional(
|
||||
@@ -36,7 +73,8 @@ def get_api_user_optional(
|
||||
"""Like get_api_user but returns None if no key provided (for public endpoints)."""
|
||||
if not x_api_key:
|
||||
return None
|
||||
return get_api_user(x_api_key, db)
|
||||
ak = get_api_key(x_api_key, db)
|
||||
return ak.user
|
||||
|
||||
|
||||
# ─── Pydantic Schemas ────────────────────────────────────────────
|
||||
@@ -102,12 +140,14 @@ class PostcardOut(BaseModel):
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = ""
|
||||
permissions: list[str] = []
|
||||
|
||||
class ApiKeyOut(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
name: str
|
||||
is_active: bool
|
||||
permissions: list[str]
|
||||
created_at: datetime
|
||||
last_used_at: datetime | None
|
||||
|
||||
@@ -150,14 +190,22 @@ def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSessio
|
||||
# Profiles
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=list[ProfileOut])
|
||||
def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all()
|
||||
def list_profiles(
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)),
|
||||
):
|
||||
return db.query(Profile).filter(Profile.user_id == ak.user_id).order_by(Profile.id).all()
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileOut, status_code=201)
|
||||
def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
p = Profile(nickname=body.nickname.strip(), user_id=user.id)
|
||||
def create_profile(
|
||||
body: ProfileCreate,
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
||||
):
|
||||
p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
@@ -165,13 +213,22 @@ def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db:
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=ProfileOut)
|
||||
def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
return _profile_or_404(user, profile_id, db)
|
||||
def get_profile(
|
||||
profile_id: int,
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)),
|
||||
):
|
||||
return _profile_or_404(ak.user, profile_id, db)
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
|
||||
def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
p = _profile_or_404(user, profile_id, db)
|
||||
def update_profile(
|
||||
profile_id: int,
|
||||
body: ProfileUpdate,
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
||||
):
|
||||
p = _profile_or_404(ak.user, profile_id, db)
|
||||
if body.nickname is not None:
|
||||
p.nickname = body.nickname.strip()
|
||||
db.commit()
|
||||
@@ -180,8 +237,12 @@ def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(ge
|
||||
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
p = _profile_or_404(user, profile_id, db)
|
||||
def delete_profile(
|
||||
profile_id: int,
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)),
|
||||
):
|
||||
p = _profile_or_404(ak.user, profile_id, db)
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -195,10 +256,10 @@ def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSe
|
||||
def list_postcards(
|
||||
profile_id: int,
|
||||
status: str | None = Query(None),
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
|
||||
):
|
||||
_profile_or_404(user, profile_id, db)
|
||||
_profile_or_404(ak.user, profile_id, db)
|
||||
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
|
||||
if status:
|
||||
q = q.filter(Postcard.status == status)
|
||||
@@ -209,10 +270,10 @@ def list_postcards(
|
||||
def create_postcard(
|
||||
profile_id: int,
|
||||
body: PostcardCreate,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
|
||||
):
|
||||
_profile_or_404(user, profile_id, db)
|
||||
_profile_or_404(ak.user, profile_id, db)
|
||||
cn = body.card_number.strip()
|
||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||
@@ -238,10 +299,10 @@ def create_postcard(
|
||||
def get_postcard(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)),
|
||||
):
|
||||
return _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
return _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
|
||||
@@ -249,10 +310,10 @@ def update_postcard(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
body: PostcardUpdate,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
|
||||
):
|
||||
pc = _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
||||
for field in ("status", "recipient_name", "sender_name", "notes"):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
@@ -278,10 +339,10 @@ def update_postcard(
|
||||
def delete_postcard(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)),
|
||||
):
|
||||
pc = _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
||||
db.delete(pc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -297,10 +358,10 @@ async def upload_image(
|
||||
postcard_id: int,
|
||||
side: str = Query("front", pattern="^(front|back)$"),
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
|
||||
):
|
||||
pc = _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
|
||||
ext = Path(file.filename).suffix if file.filename else ".jpg"
|
||||
save_name = f"pc_{pc.id}_{side}{ext}"
|
||||
save_path = UPLOAD_DIR / save_name
|
||||
@@ -320,26 +381,65 @@ async def upload_image(
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/keys", response_model=list[ApiKeyOut])
|
||||
def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
def list_api_keys(
|
||||
db: DbSession = Depends(get_db),
|
||||
user: User = Depends(get_api_user),
|
||||
):
|
||||
return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/keys", response_model=ApiKeyOut, status_code=201)
|
||||
def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
def create_api_key(
|
||||
body: ApiKeyCreate,
|
||||
db: DbSession = Depends(get_db),
|
||||
user: User = Depends(get_api_user),
|
||||
):
|
||||
key = f"mv_{token_hex(24)}"
|
||||
ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id)
|
||||
db.add(ak)
|
||||
import json
|
||||
perms = json.dumps(body.permissions) if body.permissions else "[]"
|
||||
new_ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id, permissions=perms)
|
||||
db.add(new_ak)
|
||||
db.commit()
|
||||
db.refresh(new_ak)
|
||||
return new_ak
|
||||
|
||||
|
||||
class ApiKeyUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
permissions: list[str] | None = None
|
||||
|
||||
|
||||
@router.put("/keys/{key_id}", response_model=ApiKeyOut)
|
||||
def update_api_key(
|
||||
key_id: int,
|
||||
body: ApiKeyUpdate,
|
||||
db: DbSession = Depends(get_db),
|
||||
user: User = Depends(get_api_user),
|
||||
):
|
||||
import json
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if not ak:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
if body.name is not None:
|
||||
ak.name = body.name.strip()
|
||||
if body.permissions is not None:
|
||||
valid = [p for p in body.permissions if p in ALL_PERMISSIONS]
|
||||
ak.permissions = json.dumps(valid) if valid else "[]"
|
||||
db.commit()
|
||||
db.refresh(ak)
|
||||
return ak
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if not ak:
|
||||
def delete_api_key(
|
||||
key_id: int,
|
||||
db: DbSession = Depends(get_db),
|
||||
user: User = Depends(get_api_user),
|
||||
):
|
||||
target = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
db.delete(ak)
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -67,6 +67,19 @@ def _strftime(dt: datetime, fmt: str) -> str:
|
||||
templates.env.filters["strftime"] = _strftime
|
||||
|
||||
|
||||
import json as _json
|
||||
|
||||
def _json_loads(raw: str) -> list:
|
||||
"""Parse JSON string for Jinja2 templates."""
|
||||
try:
|
||||
return _json.loads(raw) if raw else []
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
templates.env.filters["json_loads"] = _json_loads
|
||||
|
||||
|
||||
def _translate_filter(key: str, lang: str = "zh") -> str:
|
||||
return _t(key, lang)
|
||||
|
||||
@@ -77,6 +90,21 @@ from app.i18n import get_languages as _get_languages
|
||||
templates.env.globals["available_languages"] = _get_languages()
|
||||
|
||||
|
||||
def _get_copyright() -> str:
|
||||
from app.models import SystemConfig as _SC
|
||||
from app.database import SessionLocal
|
||||
try:
|
||||
db = SessionLocal()
|
||||
cfg = db.query(_SC).filter(_SC.key == "copyright_text").first()
|
||||
db.close()
|
||||
return cfg.value if cfg else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
templates.env.globals["copyright_text"] = _get_copyright()
|
||||
|
||||
|
||||
def _redirect(url: str) -> RedirectResponse:
|
||||
return RedirectResponse(url, status_code=303)
|
||||
|
||||
@@ -188,6 +216,32 @@ def _require_admin(user: User) -> bool:
|
||||
return user.is_admin
|
||||
|
||||
|
||||
def _admin_context(user, db, active_tab="invites", **extra):
|
||||
"""Build context for admin.html with invite codes and system config."""
|
||||
from app.models import SystemConfig
|
||||
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
|
||||
config = {r.key: r.value for r in db.query(SystemConfig).all()}
|
||||
ctx = {"request": extra.get("request"), "user": user, "codes": codes, "active_tab": active_tab, "config": config}
|
||||
ctx.update({k: v for k, v in extra.items() if k != "request"})
|
||||
return ctx
|
||||
|
||||
|
||||
def _admin_render(request, user, db, active_tab="invites", **extra):
|
||||
return templates.TemplateResponse("admin.html", _admin_context(user, db, active_tab, request=request, **extra))
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(
|
||||
request: Request,
|
||||
active_tab: str = Query("invites", alias="tab"),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not _require_admin(user):
|
||||
return _redirect("/dashboard")
|
||||
return _admin_render(request, user, db, active_tab)
|
||||
|
||||
|
||||
@router.get("/admin/invites", response_class=HTMLResponse)
|
||||
def admin_invites_page(
|
||||
request: Request,
|
||||
@@ -196,10 +250,27 @@ def admin_invites_page(
|
||||
):
|
||||
if not _require_admin(user):
|
||||
return _redirect("/dashboard")
|
||||
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
|
||||
return templates.TemplateResponse("admin_invites.html", {
|
||||
"request": request, "user": user, "codes": codes,
|
||||
})
|
||||
return _admin_render(request, user, db, "invites")
|
||||
|
||||
|
||||
@router.post("/admin/config")
|
||||
def admin_save_config(
|
||||
request: Request,
|
||||
copyright_text: str = Form(""),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not _require_admin(user):
|
||||
return _redirect("/dashboard")
|
||||
from app.models import SystemConfig
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == "copyright_text").first()
|
||||
if cfg:
|
||||
cfg.value = copyright_text.strip()
|
||||
else:
|
||||
cfg = SystemConfig(key="copyright_text", value=copyright_text.strip())
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
return _admin_render(request, user, db, "config", success="Saved")
|
||||
|
||||
|
||||
@router.post("/admin/invites/add")
|
||||
@@ -220,17 +291,9 @@ def admin_add_invite(
|
||||
else:
|
||||
code = code.strip()
|
||||
if len(code) != 8:
|
||||
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
|
||||
return templates.TemplateResponse("admin_invites.html", {
|
||||
"request": request, "user": user, "codes": codes,
|
||||
"error": "邀请码必须为8个字符",
|
||||
})
|
||||
return _admin_render(request, user, db, "invites", error="邀请码必须为8个字符")
|
||||
if db.query(InviteCode).filter(InviteCode.code == code).first():
|
||||
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
|
||||
return templates.TemplateResponse("admin_invites.html", {
|
||||
"request": request, "user": user, "codes": codes,
|
||||
"error": "邀请码已存在",
|
||||
})
|
||||
return _admin_render(request, user, db, "invites", error="邀请码已存在")
|
||||
# Parse limits
|
||||
m_uses = None
|
||||
exp_at = None
|
||||
@@ -244,11 +307,7 @@ def admin_add_invite(
|
||||
)
|
||||
db.add(new_code)
|
||||
db.commit()
|
||||
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
|
||||
return templates.TemplateResponse("admin_invites.html", {
|
||||
"request": request, "user": user, "codes": codes,
|
||||
"success": f"邀请码 {code} 已创建",
|
||||
})
|
||||
return _admin_render(request, user, db, "invites", success=f"邀请码 {code} 已创建")
|
||||
|
||||
|
||||
@router.post("/admin/invites/{code_id}/edit")
|
||||
@@ -276,11 +335,7 @@ def admin_edit_invite(
|
||||
code_obj.max_uses = None
|
||||
code_obj.expires_at = None
|
||||
db.commit()
|
||||
codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all()
|
||||
return templates.TemplateResponse("admin_invites.html", {
|
||||
"request": request, "user": user, "codes": codes,
|
||||
"success": f"邀请码 {code_obj.code} 已更新",
|
||||
})
|
||||
return _admin_render(request, user, db, "invites", success=f"邀请码 {code_obj.code} 已更新")
|
||||
|
||||
|
||||
@router.post("/admin/invites/{code_id}/delete")
|
||||
@@ -296,7 +351,7 @@ def admin_delete_invite(
|
||||
if code_obj:
|
||||
db.delete(code_obj)
|
||||
db.commit()
|
||||
return _redirect("/admin/invites")
|
||||
return _redirect("/admin?tab=invites")
|
||||
|
||||
|
||||
# ---------- Settings ----------
|
||||
@@ -862,17 +917,21 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
|
||||
def apikey_create(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
permissions: list[str] = Form([]),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models import ApiKey
|
||||
from app.models import ApiKey, ALL_PERMISSIONS
|
||||
from secrets import token_hex
|
||||
import json
|
||||
key = f"mv_{token_hex(24)}"
|
||||
ak = ApiKey(key=key, name=name.strip(), user_id=user.id)
|
||||
# Filter to valid scopes only
|
||||
valid = [p for p in permissions if p in ALL_PERMISSIONS]
|
||||
perms_json = json.dumps(valid) if valid else "[]"
|
||||
ak = ApiKey(key=key, name=name.strip(), user_id=user.id, permissions=perms_json)
|
||||
db.add(ak)
|
||||
db.commit()
|
||||
db.refresh(ak)
|
||||
# Re-render settings with apikeys tab active and the new key shown
|
||||
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request, "user": user, "active_tab": "apikeys",
|
||||
@@ -894,6 +953,27 @@ def apikey_delete(
|
||||
return _redirect("/settings?tab=apikeys")
|
||||
|
||||
|
||||
@router.post("/settings/apikeys/{key_id}/edit")
|
||||
def apikey_edit(
|
||||
key_id: int,
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
permissions: list[str] = Form([]),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models import ApiKey, ALL_PERMISSIONS
|
||||
import json
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if not ak:
|
||||
return _redirect("/settings?tab=apikeys")
|
||||
ak.name = name.strip()
|
||||
valid = [p for p in permissions if p in ALL_PERMISSIONS]
|
||||
ak.permissions = json.dumps(valid) if valid else "[]"
|
||||
db.commit()
|
||||
return _redirect("/settings?tab=apikeys")
|
||||
|
||||
|
||||
# ---------- API Docs ----------
|
||||
|
||||
@router.get("/api/docs", response_class=HTMLResponse)
|
||||
|
||||
@@ -40,6 +40,29 @@ a:hover { text-decoration: underline; }
|
||||
.lang-option:hover { background: var(--bg); color: var(--primary); }
|
||||
.lang-option.active { color: var(--primary); font-weight: 600; }
|
||||
.showcase-lang-bar { position: fixed; top: 1rem; right: 1.5rem; z-index: 100; }
|
||||
.nav-hamburger { display: none; background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: .25rem .5rem; color: var(--text); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-hamburger { display: block; }
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.08);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: .5rem 1.5rem;
|
||||
gap: .75rem;
|
||||
z-index: 50;
|
||||
}
|
||||
.nav-links.open { display: flex; }
|
||||
.lang-switcher { position: static; }
|
||||
.lang-dropdown { position: static; box-shadow: none; border: none; }
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
|
||||
|
||||
141
app/templates/admin.html
Normal file
141
app/templates/admin.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'admin.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<h1>{{ 'admin.title'|t(user.language) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab {% if active_tab != 'config' %}active{% endif %}" onclick="switchTab('invites')">{{ 'admin.tab_invites'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab == 'config' %}active{% endif %}" onclick="switchTab('config')">{{ 'admin.tab_config'|t(user.language) }}</button>
|
||||
</div>
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ═══ Tab: Invites ═══ -->
|
||||
<div id="tab-invites" class="tab-panel" {% if active_tab == 'config' %}style="display:none"{% endif %}>
|
||||
<div class="form-card" style="margin-bottom:1.5rem">
|
||||
<h3 style="margin-bottom:.75rem">{{ 'invites.create'|t(user.language) }}</h3>
|
||||
<form method="post" action="/admin/invites/add" style="display:flex;flex-direction:column;gap:.85rem">
|
||||
<div>
|
||||
<label style="margin-top:0">{{ 'invites.code_label'|t(user.language) }}</label>
|
||||
<input type="text" name="code" maxlength="8" placeholder="{{ 'invites.code_ph'|t(user.language) }}" style="text-transform:uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label style="margin-top:0">{{ 'invites.limit_type'|t(user.language) }}</label>
|
||||
<select name="limit_type" id="limit_type" onchange="toggleLimitFields()">
|
||||
<option value="unlimited">{{ 'invites.unlimited'|t(user.language) }}</option>
|
||||
<option value="uses">{{ 'invites.uses'|t(user.language) }}</option>
|
||||
<option value="expires">{{ 'invites.expires'|t(user.language) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="max_uses_field" style="display:none;align-items:center;gap:.3rem">
|
||||
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
|
||||
<input type="number" name="max_uses" min="1" value="1" class="aif-input aif-short">
|
||||
</div>
|
||||
<div id="expires_field" style="display:none;align-items:center;gap:.3rem">
|
||||
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
|
||||
<input type="datetime-local" name="expires_at" class="aif-input aif-date">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'invites.create_btn'|t(user.language) }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if codes %}
|
||||
<div class="postcard-list">
|
||||
{% for c in codes %}
|
||||
<div class="postcard-row" style="flex-direction:column;align-items:stretch;gap:.75rem">
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||
<strong style="font-family:monospace;font-size:1.1rem">{{ c.code }}</strong>
|
||||
<span style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.creator'|t(user.language) }}: {{ c.creator.username if c.creator else '-' }}</span>
|
||||
<span style="font-size:.8rem;color:var(--text-muted)">{{ c.created_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
{% if c.max_uses is not none %}
|
||||
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / {{ c.max_uses }}</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / ∞</span>
|
||||
{% endif %}
|
||||
{% if c.expires_at %}
|
||||
<span class="badge">{{ 'invites.expires_at'|t(user.language) }}: {{ c.expires_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ 'invites.no_expire'|t(user.language) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if c.registered_users %}
|
||||
<div style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.registered_users'|t(user.language) }}: {{ c.registered_users|map(attribute='username')|join(', ') }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/invites/{{ c.id }}/edit" class="admin-invite-form" id="edit-form-{{ c.id }}">
|
||||
<div class="aif-field">
|
||||
<select name="limit_type" class="aif-select" onchange="toggleEditFields({{ c.id }}, this.value)">
|
||||
<option value="unlimited"{% if c.max_uses is none and not c.expires_at %} selected{% endif %}>{{ 'invites.unlimited'|t(user.language) }}</option>
|
||||
<option value="uses"{% if c.max_uses is not none %} selected{% endif %}>{{ 'invites.uses'|t(user.language) }}</option>
|
||||
<option value="expires"{% if c.expires_at %} selected{% endif %}>{{ 'invites.expires'|t(user.language) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:{% if c.max_uses is not none %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
|
||||
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
|
||||
<input type="number" name="max_uses" value="{{ c.max_uses or 1 }}" min="1" class="aif-input aif-short">
|
||||
</div>
|
||||
<div style="display:{% if c.expires_at %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
|
||||
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
|
||||
<input type="datetime-local" name="expires_at" value="{{ c.expires_at|to_local|strftime('%Y-%m-%dT%H:%M') if c.expires_at else '' }}" class="aif-input aif-date">
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;align-self:flex-end">
|
||||
<button type="submit" class="btn btn-sm">{{ 'invites.save'|t(user.language) }}</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="if(confirm('{{ 'invites.confirm_delete'|t(user.language) }}'))this.closest('form').nextElementSibling.submit()">{{ 'invites.delete'|t(user.language) }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/invites/{{ c.id }}/delete" style="display:none"></form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔑</div>
|
||||
<p>{{ 'invites.empty'|t(user.language) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ═══ Tab: System Config ═══ -->
|
||||
<div id="tab-config" class="tab-panel" {% if active_tab != 'config' %}style="display:none"{% endif %}>
|
||||
<div class="form-card">
|
||||
<h3 style="margin-bottom:.75rem">{{ 'admin.config_title'|t(user.language) }}</h3>
|
||||
<form method="post" action="/admin/config">
|
||||
<label>{{ 'admin.copyright_label'|t(user.language) }}</label>
|
||||
<textarea name="copyright_text" rows="3" style="width:100%;resize:vertical">{{ config.get('copyright_text', '') }}</textarea>
|
||||
<p style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">{{ 'admin.copyright_hint'|t(user.language) }}</p>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'admin.save_config'|t(user.language) }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
|
||||
event.currentTarget.classList.add('active');
|
||||
document.getElementById('tab-' + name).style.display = '';
|
||||
}
|
||||
function toggleLimitFields() {
|
||||
var t = document.getElementById('limit_type').value;
|
||||
document.getElementById('max_uses_field').style.display = t === 'uses' ? 'inline-flex' : 'none';
|
||||
document.getElementById('expires_field').style.display = t === 'expires' ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleEditFields(id, val) {
|
||||
var form = document.getElementById('edit-form-' + id);
|
||||
var num = form.querySelector('[name="max_uses"]').parentElement;
|
||||
var date = form.querySelector('[name="expires_at"]').parentElement;
|
||||
num.style.display = val === 'uses' ? 'inline-flex' : 'none';
|
||||
date.style.display = val === 'expires' ? 'inline-flex' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -52,6 +52,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.permissions'|t(user.language) }}</h2>
|
||||
<p class="doc-desc">{{ 'api_docs.permissions_desc'|t(user.language) }}</p>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.scope'|t(user.language) }}</th><th>{{ 'api_docs.scope_desc'|t(user.language) }}</th><th>{{ 'api_docs.scope_endpoints'|t(user.language) }}</th></tr>
|
||||
<tr><td><code>profiles:read</code></td><td>{{ 'apikey.perm.profiles_read'|t(user.language) }}</td><td>GET /profiles, GET /profiles/{id}</td></tr>
|
||||
<tr><td><code>profiles:write</code></td><td>{{ 'apikey.perm.profiles_write'|t(user.language) }}</td><td>POST /profiles, PUT /profiles/{id}, DELETE /profiles/{id}</td></tr>
|
||||
<tr><td><code>postcards:read</code></td><td>{{ 'apikey.perm.postcards_read'|t(user.language) }}</td><td>GET /profiles/{id}/postcards, GET /profiles/{id}/postcards/{id}</td></tr>
|
||||
<tr><td><code>postcards:write</code></td><td>{{ 'apikey.perm.postcards_write'|t(user.language) }}</td><td>POST /profiles/{id}/postcards, PUT /profiles/{id}/postcards/{id}, DELETE /profiles/{id}/postcards/{id}</td></tr>
|
||||
<tr><td><code>images:upload</code></td><td>{{ 'apikey.perm.images_upload'|t(user.language) }}</td><td>POST /profiles/{id}/postcards/{id}/upload</td></tr>
|
||||
<tr><td colspan="3" style="color:var(--text-muted);font-style:italic">{{ 'api_docs.permissions_keys_note'|t(user.language) }}</td></tr>
|
||||
</table>
|
||||
<p class="doc-note" style="margin-top:.5rem">{{ 'api_docs.permissions_note'|t(user.language) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Profiles ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
{% block nav %}
|
||||
<nav class="navbar">
|
||||
<a href="/dashboard" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
|
||||
{% if user is defined and user %}
|
||||
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')">☰</button>
|
||||
<div class="nav-links">
|
||||
{% if user is defined and user %}
|
||||
<a href="/profiles">{{ 'nav.profiles'|t(user.language) }}</a>
|
||||
<a href="/u/{{ user.username }}" target="_blank">{{ 'nav.showcase'|t(user.language) }}</a>
|
||||
<a href="/settings">{{ 'nav.settings'|t(user.language) }}</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a>
|
||||
<a href="/admin">{{ 'nav.admin'|t(user.language) }}</a>
|
||||
{% endif %}
|
||||
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
|
||||
<div class="lang-switcher" id="langSwitcher">
|
||||
@@ -30,13 +32,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<a href="/logout" class="nav-logout">{{ 'nav.logout'|t(user.language) }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endblock %}
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% if copyright_text %}
|
||||
<footer style="text-align:center;padding:2rem 1rem;color:var(--text-muted);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem">
|
||||
{{ copyright_text }}
|
||||
</footer>
|
||||
{% endif %}
|
||||
</body>
|
||||
<script>
|
||||
// Auto-trim whitespace from all text inputs and textareas on form submit
|
||||
|
||||
@@ -170,31 +170,63 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/apikeys/create" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
|
||||
<form method="post" action="/settings/apikeys/create" style="margin-bottom:1rem">
|
||||
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:.75rem">
|
||||
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
|
||||
</div>
|
||||
<details>
|
||||
<summary style="font-size:.85rem;color:var(--text-muted);cursor:pointer;margin-bottom:.5rem">{{ 'apikey.permissions'|t(user.language) }}</summary>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.75rem 1.5rem">
|
||||
{% for scope, label in [
|
||||
('profiles:read', 'apikey.perm.profiles_read'),
|
||||
('profiles:write', 'apikey.perm.profiles_write'),
|
||||
('postcards:read', 'apikey.perm.postcards_read'),
|
||||
('postcards:write', 'apikey.perm.postcards_write'),
|
||||
('images:upload', 'apikey.perm.images_upload')
|
||||
] %}
|
||||
<label style="display:flex;align-items:center;gap:.3rem;font-size:.85rem;white-space:nowrap">
|
||||
<input type="checkbox" name="permissions" value="{{ scope }}">
|
||||
{{ label|t(user.language) }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p style="font-size:.78rem;color:var(--text-muted);margin-top:.5rem">{{ 'apikey.perm.hint'|t(user.language) }}</p>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
{% if api_keys %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch">
|
||||
<table class="table" style="min-width:700px;white-space:nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'apikey.name'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.key'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.permissions'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.created_at'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.last_used'|t(user.language) }}</th>
|
||||
<th></th>
|
||||
<th style="white-space:nowrap"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ak in api_keys %}
|
||||
{% set perms = ak.permissions|default('[]', true)|json_loads %}
|
||||
<tr>
|
||||
<td>{{ ak.name or '—' }}</td>
|
||||
<td><code style="font-size:.8rem">{{ ak.key[:8] }}****</code></td>
|
||||
<td>{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}</td>
|
||||
<td>{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
<td style="white-space:nowrap"><strong>{{ ak.name or '—' }}</strong></td>
|
||||
<td><code style="font-size:.78rem">{{ ak.key[:8] }}****</code></td>
|
||||
<td style="white-space:nowrap">
|
||||
{% if not perms %}
|
||||
<span class="badge" style="background:var(--sent);font-size:.75rem">{{ 'apikey.perm_full_access'|t(user.language) }}</span>
|
||||
{% else %}
|
||||
{% for p in perms %}
|
||||
<span class="badge" style="font-size:.7rem;margin:1px">{{ p }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="white-space:nowrap;font-size:.82rem">{{ ak.created_at|to_local|strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td style="white-space:nowrap;font-size:.82rem">{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d %H:%M:%S') }}{% else %}—{% endif %}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn btn-sm" onclick="toggleAkEdit({{ ak.id }})">{{ 'apikey.edit'|t(user.language) }}</button>
|
||||
<form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button>
|
||||
</form>
|
||||
@@ -204,6 +236,38 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% for ak in api_keys %}
|
||||
{% set perms = ak.permissions|default('[]', true)|json_loads %}
|
||||
<div id="ak-edit-{{ ak.id }}" style="display:none;margin-top:.5rem;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:1rem">
|
||||
<h4 style="font-size:.85rem;margin-bottom:.75rem;color:var(--text-muted)">{{ 'apikey.edit'|t(user.language) }}: {{ ak.name or '—' }}</h4>
|
||||
<form method="post" action="/settings/apikeys/{{ ak.id }}/edit" style="display:flex;flex-direction:column;gap:.5rem">
|
||||
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||
<label style="font-size:.85rem;white-space:nowrap">{{ 'apikey.name'|t(user.language) }}</label>
|
||||
<input type="text" name="name" value="{{ ak.name }}" style="max-width:200px">
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.6rem 1.25rem">
|
||||
{% for scope, label in [
|
||||
('profiles:read', 'apikey.perm.profiles_read'),
|
||||
('profiles:write', 'apikey.perm.profiles_write'),
|
||||
('postcards:read', 'apikey.perm.postcards_read'),
|
||||
('postcards:write', 'apikey.perm.postcards_write'),
|
||||
('images:upload', 'apikey.perm.images_upload')
|
||||
] %}
|
||||
<label style="display:flex;align-items:center;gap:.3rem;font-size:.85rem;white-space:nowrap">
|
||||
<input type="checkbox" name="permissions" value="{{ scope }}" {% if scope in perms %}checked{% endif %}>
|
||||
{{ label|t(user.language) }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;margin-top:.25rem">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.save'|t(user.language) }}</button>
|
||||
<button type="button" class="btn btn-sm" onclick="toggleAkEdit({{ ak.id }})">{{ 'apikey.cancel'|t(user.language) }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>{{ 'apikey.empty'|t(user.language) }}</p>
|
||||
@@ -219,5 +283,9 @@ function switchTab(name) {
|
||||
event.currentTarget.classList.add('active');
|
||||
document.getElementById('tab-' + name).style.display = '';
|
||||
}
|
||||
function toggleAkEdit(id) {
|
||||
var el = document.getElementById('ak-edit-' + id);
|
||||
if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user