diff --git a/app/locales/en.json b/app/locales/en.json index 8627f92..e93792a 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -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": " *" } \ No newline at end of file diff --git a/app/locales/zh.json b/app/locales/zh.json index f19b667..e19c233 100644 --- a/app/locales/zh.json +++ b/app/locales/zh.json @@ -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": " *" } \ No newline at end of file diff --git a/app/models.py b/app/models.py index 92204bd..ac0e5f3 100644 --- a/app/models.py +++ b/app/models.py @@ -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], +} diff --git a/app/routers/api.py b/app/routers/api.py index 0d7a704..6e8d5c8 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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} diff --git a/app/routers/web.py b/app/routers/web.py index d553838..321b84c 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -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) diff --git a/app/static/style.css b/app/static/style.css index c3a61ff..49d01a1 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; } diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..ae40c5a --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}{{ 'admin.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %} +{% block content %} +
{{ 'invites.empty'|t(user.language) }}
+{{ 'api_docs.permissions_desc'|t(user.language) }}
+| {{ 'api_docs.scope'|t(user.language) }} | {{ 'api_docs.scope_desc'|t(user.language) }} | {{ 'api_docs.scope_endpoints'|t(user.language) }} |
|---|---|---|
profiles:read | {{ 'apikey.perm.profiles_read'|t(user.language) }} | GET /profiles, GET /profiles/{id} |
profiles:write | {{ 'apikey.perm.profiles_write'|t(user.language) }} | POST /profiles, PUT /profiles/{id}, DELETE /profiles/{id} |
postcards:read | {{ 'apikey.perm.postcards_read'|t(user.language) }} | GET /profiles/{id}/postcards, GET /profiles/{id}/postcards/{id} |
postcards:write | {{ 'apikey.perm.postcards_write'|t(user.language) }} | POST /profiles/{id}/postcards, PUT /profiles/{id}/postcards/{id}, DELETE /profiles/{id}/postcards/{id} |
images:upload | {{ 'apikey.perm.images_upload'|t(user.language) }} | POST /profiles/{id}/postcards/{id}/upload |
| {{ 'api_docs.permissions_keys_note'|t(user.language) }} | ||
{{ 'api_docs.permissions_note'|t(user.language) }}
+