HouYunFei commited on
Commit
20bee0e
·
2 Parent(s): 34983511edaf69

Merge branch 'fork/Jimmy-Bots/feat-user-key-management-improvements'

Browse files
api/accounts.py CHANGED
@@ -31,6 +31,7 @@ class UserKeyCreateRequest(BaseModel):
31
  class UserKeyUpdateRequest(BaseModel):
32
  name: str | None = None
33
  enabled: bool | None = None
 
34
 
35
 
36
  class AccountCreateRequest(BaseModel):
@@ -116,21 +117,25 @@ def create_router() -> APIRouter:
116
  for key, value in {
117
  "name": body.name,
118
  "enabled": body.enabled,
 
119
  }.items()
120
  if value is not None
121
  }
122
  if not updates:
123
- raise HTTPException(status_code=400, detail={"error": "no updates provided"})
124
- item = auth_service.update_key(key_id, updates, role="user")
 
 
 
125
  if item is None:
126
- raise HTTPException(status_code=404, detail={"error": "user key not found"})
127
  return {"item": item, "items": auth_service.list_keys(role="user")}
128
 
129
  @router.delete("/api/auth/users/{key_id}")
130
  async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
131
  require_admin(authorization)
132
  if not auth_service.delete_key(key_id, role="user"):
133
- raise HTTPException(status_code=404, detail={"error": "user key not found"})
134
  return {"items": auth_service.list_keys(role="user")}
135
 
136
  @router.get("/api/accounts")
@@ -179,7 +184,7 @@ def create_router() -> APIRouter:
179
  raise HTTPException(status_code=400, detail={"error": "access_token is required"})
180
  updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
181
  if not updates:
182
- raise HTTPException(status_code=400, detail={"error": "no updates provided"})
183
  account = account_service.update_account(access_token, updates)
184
  if account is None:
185
  raise HTTPException(status_code=404, detail={"error": "account not found"})
 
31
  class UserKeyUpdateRequest(BaseModel):
32
  name: str | None = None
33
  enabled: bool | None = None
34
+ key: str | None = None
35
 
36
 
37
  class AccountCreateRequest(BaseModel):
 
117
  for key, value in {
118
  "name": body.name,
119
  "enabled": body.enabled,
120
+ "key": body.key,
121
  }.items()
122
  if value is not None
123
  }
124
  if not updates:
125
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
126
+ try:
127
+ item = auth_service.update_key(key_id, updates, role="user")
128
+ except ValueError as exc:
129
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
130
  if item is None:
131
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
132
  return {"item": item, "items": auth_service.list_keys(role="user")}
133
 
134
  @router.delete("/api/auth/users/{key_id}")
135
  async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
136
  require_admin(authorization)
137
  if not auth_service.delete_key(key_id, role="user"):
138
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
139
  return {"items": auth_service.list_keys(role="user")}
140
 
141
  @router.get("/api/accounts")
 
184
  raise HTTPException(status_code=400, detail={"error": "access_token is required"})
185
  updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
186
  if not updates:
187
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
188
  account = account_service.update_account(access_token, updates)
189
  if account is None:
190
  raise HTTPException(status_code=404, detail={"error": "account not found"})
api/support.py CHANGED
@@ -31,7 +31,7 @@ def require_identity(authorization: str | None) -> dict[str, object]:
31
  token = extract_bearer_token(authorization)
32
  identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
33
  if identity is None:
34
- raise HTTPException(status_code=401, detail={"error": "authorization is invalid"})
35
  return identity
36
 
37
 
@@ -42,7 +42,7 @@ def require_auth_key(authorization: str | None) -> None:
42
  def require_admin(authorization: str | None) -> dict[str, object]:
43
  identity = require_identity(authorization)
44
  if identity.get("role") != "admin":
45
- raise HTTPException(status_code=403, detail={"error": "admin permission required"})
46
  return identity
47
 
48
 
 
31
  token = extract_bearer_token(authorization)
32
  identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
33
  if identity is None:
34
+ raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
35
  return identity
36
 
37
 
 
42
  def require_admin(authorization: str | None) -> dict[str, object]:
43
  identity = require_identity(authorization)
44
  if identity.get("role") != "admin":
45
+ raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
46
  return identity
47
 
48
 
services/auth_service.py CHANGED
@@ -33,6 +33,10 @@ class AuthService:
33
  def _clean(value: object) -> str:
34
  return str(value or "").strip()
35
 
 
 
 
 
36
  def _normalize_item(self, raw: object) -> dict[str, object] | None:
37
  if not isinstance(raw, dict):
38
  return None
@@ -43,7 +47,7 @@ class AuthService:
43
  if not key_hash:
44
  return None
45
  item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
46
- name = self._clean(raw.get("name")) or ("管理员密钥" if role == "admin" else "普通用户")
47
  created_at = self._clean(raw.get("created_at")) or _now_iso()
48
  last_used_at = self._clean(raw.get("last_used_at")) or None
49
  return {
@@ -68,6 +72,9 @@ class AuthService:
68
  def _save(self) -> None:
69
  self.storage.save_auth_keys(self._items)
70
 
 
 
 
71
  @staticmethod
72
  def _public_item(item: dict[str, object]) -> dict[str, object]:
73
  return {
@@ -81,22 +88,85 @@ class AuthService:
81
 
82
  def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
83
  with self._lock:
 
84
  items = [item for item in self._items if role is None or item.get("role") == role]
85
  return [self._public_item(item) for item in items]
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
88
- normalized_name = self._clean(name) or ("管理员密钥" if role == "admin" else "普通用户")
89
- raw_key = f"sk-{secrets.token_urlsafe(24)}"
90
- item = {
91
- "id": uuid.uuid4().hex[:12],
92
- "name": normalized_name,
93
- "role": role,
94
- "key_hash": _hash_key(raw_key),
95
- "enabled": True,
96
- "created_at": _now_iso(),
97
- "last_used_at": None,
98
- }
99
  with self._lock:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  self._items.append(item)
101
  self._save()
102
  return self._public_item(item), raw_key
@@ -112,16 +182,24 @@ class AuthService:
112
  if not normalized_id:
113
  return None
114
  with self._lock:
 
115
  for index, item in enumerate(self._items):
116
  if item.get("id") != normalized_id:
117
  continue
118
  if role is not None and item.get("role") != role:
119
  return None
120
  next_item = dict(item)
 
121
  if "name" in updates and updates.get("name") is not None:
122
- next_item["name"] = self._clean(updates.get("name")) or next_item.get("name") or "普通用户"
 
 
 
 
123
  if "enabled" in updates and updates.get("enabled") is not None:
124
  next_item["enabled"] = bool(updates.get("enabled"))
 
 
125
  self._items[index] = next_item
126
  self._save()
127
  return self._public_item(next_item)
@@ -132,6 +210,7 @@ class AuthService:
132
  if not normalized_id:
133
  return False
134
  with self._lock:
 
135
  before = len(self._items)
136
  self._items = [
137
  item
@@ -149,6 +228,7 @@ class AuthService:
149
  return None
150
  candidate_hash = _hash_key(candidate)
151
  with self._lock:
 
152
  for index, item in enumerate(self._items):
153
  if not bool(item.get("enabled", True)):
154
  continue
 
33
  def _clean(value: object) -> str:
34
  return str(value or "").strip()
35
 
36
+ @staticmethod
37
+ def _default_name(role: object) -> str:
38
+ return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
39
+
40
  def _normalize_item(self, raw: object) -> dict[str, object] | None:
41
  if not isinstance(raw, dict):
42
  return None
 
47
  if not key_hash:
48
  return None
49
  item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
50
+ name = self._clean(raw.get("name")) or self._default_name(role)
51
  created_at = self._clean(raw.get("created_at")) or _now_iso()
52
  last_used_at = self._clean(raw.get("last_used_at")) or None
53
  return {
 
72
  def _save(self) -> None:
73
  self.storage.save_auth_keys(self._items)
74
 
75
+ def _reload_locked(self) -> None:
76
+ self._items = self._load()
77
+
78
  @staticmethod
79
  def _public_item(item: dict[str, object]) -> dict[str, object]:
80
  return {
 
88
 
89
  def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
90
  with self._lock:
91
+ self._reload_locked()
92
  items = [item for item in self._items if role is None or item.get("role") == role]
93
  return [self._public_item(item) for item in items]
94
 
95
+ def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
96
+ for item in self._items:
97
+ item_id = self._clean(item.get("id"))
98
+ if exclude_id and item_id == exclude_id:
99
+ continue
100
+ stored_hash = self._clean(item.get("key_hash"))
101
+ if stored_hash and hmac.compare_digest(stored_hash, key_hash):
102
+ return True
103
+ return False
104
+
105
+ def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
106
+ candidate = self._clean(raw_key)
107
+ if not candidate:
108
+ raise ValueError("请输入新的专用密钥")
109
+ admin_key = self._clean(config.auth_key)
110
+ if admin_key and hmac.compare_digest(candidate, admin_key):
111
+ raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥")
112
+ key_hash = _hash_key(candidate)
113
+ if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
114
+ raise ValueError("这个专用密钥已经存在,请换一个新的密钥")
115
+ return key_hash
116
+
117
+ def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool:
118
+ candidate = self._clean(name)
119
+ if not candidate:
120
+ return False
121
+ for item in self._items:
122
+ item_id = self._clean(item.get("id"))
123
+ if exclude_id and item_id == exclude_id:
124
+ continue
125
+ if role is not None and item.get("role") != role:
126
+ continue
127
+ if self._clean(item.get("name")) == candidate:
128
+ return True
129
+ return False
130
+
131
+ def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str:
132
+ base_name = self._default_name(role)
133
+ if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id):
134
+ return base_name
135
+ suffix = 2
136
+ while True:
137
+ candidate = f"{base_name} {suffix}"
138
+ if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
139
+ return candidate
140
+ suffix += 1
141
+
142
+ def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str:
143
+ candidate = self._clean(name)
144
+ if not candidate:
145
+ return self._build_default_name_locked(role, exclude_id=exclude_id)
146
+ if self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
147
+ raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧")
148
+ return candidate
149
+
150
  def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
 
 
 
 
 
 
 
 
 
 
 
151
  with self._lock:
152
+ self._reload_locked()
153
+ normalized_name = self._build_name_locked(name, role=role)
154
+ while True:
155
+ raw_key = f"sk-{secrets.token_urlsafe(24)}"
156
+ try:
157
+ key_hash = self._build_key_hash_locked(raw_key)
158
+ break
159
+ except ValueError:
160
+ continue
161
+ item = {
162
+ "id": uuid.uuid4().hex[:12],
163
+ "name": normalized_name,
164
+ "role": role,
165
+ "key_hash": key_hash,
166
+ "enabled": True,
167
+ "created_at": _now_iso(),
168
+ "last_used_at": None,
169
+ }
170
  self._items.append(item)
171
  self._save()
172
  return self._public_item(item), raw_key
 
182
  if not normalized_id:
183
  return None
184
  with self._lock:
185
+ self._reload_locked()
186
  for index, item in enumerate(self._items):
187
  if item.get("id") != normalized_id:
188
  continue
189
  if role is not None and item.get("role") != role:
190
  return None
191
  next_item = dict(item)
192
+ next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user"
193
  if "name" in updates and updates.get("name") is not None:
194
+ next_item["name"] = self._build_name_locked(
195
+ str(updates.get("name") or ""),
196
+ role=next_role,
197
+ exclude_id=normalized_id,
198
+ )
199
  if "enabled" in updates and updates.get("enabled") is not None:
200
  next_item["enabled"] = bool(updates.get("enabled"))
201
+ if "key" in updates and updates.get("key") is not None:
202
+ next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
203
  self._items[index] = next_item
204
  self._save()
205
  return self._public_item(next_item)
 
210
  if not normalized_id:
211
  return False
212
  with self._lock:
213
+ self._reload_locked()
214
  before = len(self._items)
215
  self._items = [
216
  item
 
228
  return None
229
  candidate_hash = _hash_key(candidate)
230
  with self._lock:
231
+ self._reload_locked()
232
  for index, item in enumerate(self._items):
233
  if not bool(item.get("enabled", True)):
234
  continue
test/test_account_image_capabilities.py CHANGED
@@ -119,6 +119,36 @@ class AuthServiceTests(unittest.TestCase):
119
  self.assertEqual(authed["id"], item["id"])
120
  self.assertIsNotNone(authed["last_used_at"])
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  if __name__ == "__main__":
124
  unittest.main()
 
119
  self.assertEqual(authed["id"], item["id"])
120
  self.assertIsNotNone(authed["last_used_at"])
121
 
122
+ def test_update_user_key_replaces_raw_key(self) -> None:
123
+ with tempfile.TemporaryDirectory() as tmp_dir:
124
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
125
+ item, raw_key = service.create_key(role="user", name="Alice")
126
+
127
+ updated = service.update_key(item["id"], {"key": "sk-user-custom-key"}, role="user")
128
+
129
+ self.assertIsNotNone(updated)
130
+ self.assertIsNone(service.authenticate(raw_key))
131
+
132
+ authed = service.authenticate("sk-user-custom-key")
133
+ self.assertIsNotNone(authed)
134
+ self.assertEqual(authed["id"], item["id"])
135
+
136
+ def test_user_key_name_must_be_unique(self) -> None:
137
+ with tempfile.TemporaryDirectory() as tmp_dir:
138
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
139
+ first, _ = service.create_key(role="user", name="Alice")
140
+ second, _ = service.create_key(role="user", name="Bob")
141
+
142
+ with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"):
143
+ service.create_key(role="user", name="Alice")
144
+
145
+ with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"):
146
+ service.update_key(second["id"], {"name": "Alice"}, role="user")
147
+
148
+ updated = service.update_key(first["id"], {"name": "Alice"}, role="user")
149
+ self.assertIsNotNone(updated)
150
+ self.assertEqual(updated["name"], "Alice")
151
+
152
 
153
  if __name__ == "__main__":
154
  unittest.main()
web/src/app/page.tsx CHANGED
@@ -3,7 +3,8 @@
3
  import { useEffect } from "react";
4
  import { useRouter } from "next/navigation";
5
 
6
- import { getDefaultRouteForRole, getStoredAuthSession } from "@/store/auth";
 
7
 
8
  export default function HomePage() {
9
  const router = useRouter();
@@ -12,7 +13,7 @@ export default function HomePage() {
12
  let active = true;
13
 
14
  const redirect = async () => {
15
- const session = await getStoredAuthSession();
16
  if (!active) {
17
  return;
18
  }
 
3
  import { useEffect } from "react";
4
  import { useRouter } from "next/navigation";
5
 
6
+ import { getValidatedAuthSession } from "@/lib/auth-session";
7
+ import { getDefaultRouteForRole } from "@/store/auth";
8
 
9
  export default function HomePage() {
10
  const router = useRouter();
 
13
  let active = true;
14
 
15
  const redirect = async () => {
16
+ const session = await getValidatedAuthSession();
17
  if (!active) {
18
  return;
19
  }
web/src/app/settings/components/user-keys-card.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
- import { Ban, CheckCircle2, Copy, KeyRound, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Badge } from "@/components/ui/badge";
@@ -45,6 +45,9 @@ export function UserKeysCard() {
45
  const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
46
  const [revealedKey, setRevealedKey] = useState("");
47
  const [deletingItem, setDeletingItem] = useState<UserKey | null>(null);
 
 
 
48
 
49
  const load = async () => {
50
  setIsLoading(true);
@@ -125,6 +128,40 @@ export function UserKeysCard() {
125
  }
126
  };
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  const handleCopy = async (value: string) => {
129
  try {
130
  await navigator.clipboard.writeText(value);
@@ -200,6 +237,16 @@ export function UserKeysCard() {
200
  </div>
201
 
202
  <div className="flex items-center gap-2">
 
 
 
 
 
 
 
 
 
 
203
  <Button
204
  type="button"
205
  variant="outline"
@@ -305,6 +352,71 @@ export function UserKeysCard() {
305
  </DialogFooter>
306
  </DialogContent>
307
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  </>
309
  );
310
  }
 
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
+ import { Ban, CheckCircle2, Copy, KeyRound, LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Badge } from "@/components/ui/badge";
 
45
  const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
46
  const [revealedKey, setRevealedKey] = useState("");
47
  const [deletingItem, setDeletingItem] = useState<UserKey | null>(null);
48
+ const [editingItem, setEditingItem] = useState<UserKey | null>(null);
49
+ const [editName, setEditName] = useState("");
50
+ const [editKey, setEditKey] = useState("");
51
 
52
  const load = async () => {
53
  setIsLoading(true);
 
128
  }
129
  };
130
 
131
+ const openEditDialog = (item: UserKey) => {
132
+ setEditingItem(item);
133
+ setEditName(item.name);
134
+ setEditKey("");
135
+ };
136
+
137
+ const handleEdit = async () => {
138
+ if (!editingItem) {
139
+ return;
140
+ }
141
+ const item = editingItem;
142
+ const trimmedName = editName.trim();
143
+ const trimmedKey = editKey.trim();
144
+ if (trimmedName === item.name && !trimmedKey) {
145
+ setEditingItem(null);
146
+ return;
147
+ }
148
+ setItemPending(item.id, true);
149
+ try {
150
+ const data = await updateUserKey(item.id, {
151
+ ...(trimmedName !== item.name ? { name: trimmedName } : {}),
152
+ ...(trimmedKey ? { key: trimmedKey } : {}),
153
+ });
154
+ setItems(data.items);
155
+ setEditingItem(null);
156
+ setEditKey("");
157
+ toast.success(trimmedKey ? "用户密钥已更新" : "用户名称已更新");
158
+ } catch (error) {
159
+ toast.error(error instanceof Error ? error.message : "更新用户密钥失败");
160
+ } finally {
161
+ setItemPending(item.id, false);
162
+ }
163
+ };
164
+
165
  const handleCopy = async (value: string) => {
166
  try {
167
  await navigator.clipboard.writeText(value);
 
237
  </div>
238
 
239
  <div className="flex items-center gap-2">
240
+ <Button
241
+ type="button"
242
+ variant="outline"
243
+ className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
244
+ onClick={() => openEditDialog(item)}
245
+ disabled={isPending}
246
+ >
247
+ {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Pencil className="size-4" />}
248
+ 编辑
249
+ </Button>
250
  <Button
251
  type="button"
252
  variant="outline"
 
352
  </DialogFooter>
353
  </DialogContent>
354
  </Dialog>
355
+
356
+ <Dialog
357
+ open={Boolean(editingItem)}
358
+ onOpenChange={(open) => {
359
+ if (!open) {
360
+ setEditingItem(null);
361
+ setEditKey("");
362
+ }
363
+ }}
364
+ >
365
+ <DialogContent className="rounded-2xl p-6">
366
+ <DialogHeader className="gap-2">
367
+ <DialogTitle>编辑用户密钥</DialogTitle>
368
+ <DialogDescription className="text-sm leading-6">
369
+ 可以修改备注名称;如需更换专用密钥,直接填写新的原始密钥即可。留空则保持当前密钥不变。
370
+ </DialogDescription>
371
+ </DialogHeader>
372
+ <div className="space-y-4">
373
+ <div className="space-y-2">
374
+ <label className="text-sm font-medium text-stone-700">名称</label>
375
+ <Input
376
+ value={editName}
377
+ onChange={(event) => setEditName(event.target.value)}
378
+ placeholder="例如:设计同学 A、运营临时账号"
379
+ className="h-11 rounded-xl border-stone-200 bg-white"
380
+ />
381
+ </div>
382
+ <div className="space-y-2">
383
+ <label className="text-sm font-medium text-stone-700">新的专用密钥(可选)</label>
384
+ <Input
385
+ value={editKey}
386
+ onChange={(event) => setEditKey(event.target.value)}
387
+ placeholder="例如:sk-your-custom-user-key"
388
+ className="h-11 rounded-xl border-stone-200 bg-white font-mono"
389
+ />
390
+ <p className="text-xs leading-5 text-stone-500">
391
+ 保存后旧密钥会立即失效,新密钥生效。系统仍只保存哈希,不会回显当前密钥。
392
+ </p>
393
+ </div>
394
+ </div>
395
+ <DialogFooter>
396
+ <Button
397
+ type="button"
398
+ variant="secondary"
399
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
400
+ onClick={() => {
401
+ setEditingItem(null);
402
+ setEditKey("");
403
+ }}
404
+ disabled={editingItem ? pendingIds.has(editingItem.id) : false}
405
+ >
406
+ 取消
407
+ </Button>
408
+ <Button
409
+ type="button"
410
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
411
+ onClick={() => void handleEdit()}
412
+ disabled={editingItem ? pendingIds.has(editingItem.id) : false}
413
+ >
414
+ {editingItem && pendingIds.has(editingItem.id) ? <LoaderCircle className="size-4 animate-spin" /> : <Pencil className="size-4" />}
415
+ 保存
416
+ </Button>
417
+ </DialogFooter>
418
+ </DialogContent>
419
+ </Dialog>
420
  </>
421
  );
422
  }
web/src/components/top-nav.tsx CHANGED
@@ -6,8 +6,9 @@ import { Github } from "lucide-react";
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
- import { clearStoredAuthSession, getStoredAuthSession, type StoredAuthSession } from "@/store/auth";
10
  import { cn } from "@/lib/utils";
 
11
 
12
  const adminNavItems = [
13
  { href: "/image", label: "画图" },
@@ -37,7 +38,7 @@ export function TopNav() {
37
  return;
38
  }
39
 
40
- const storedSession = await getStoredAuthSession();
41
  if (!active) {
42
  return;
43
  }
@@ -61,6 +62,7 @@ export function TopNav() {
61
 
62
  const navItems = session.role === "admin" ? adminNavItems : userNavItems;
63
  const roleLabel = session.role === "admin" ? "管理员" : "普通用户";
 
64
 
65
  return (
66
  <header className="border-b border-stone-100/50">
@@ -112,7 +114,7 @@ export function TopNav() {
112
  </nav>
113
  <div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
114
  <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
115
- {roleLabel}
116
  </span>
117
  <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
118
  v{webConfig.appVersion}
 
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
+ import { getValidatedAuthSession } from "@/lib/auth-session";
10
  import { cn } from "@/lib/utils";
11
+ import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
12
 
13
  const adminNavItems = [
14
  { href: "/image", label: "画图" },
 
38
  return;
39
  }
40
 
41
+ const storedSession = await getValidatedAuthSession();
42
  if (!active) {
43
  return;
44
  }
 
62
 
63
  const navItems = session.role === "admin" ? adminNavItems : userNavItems;
64
  const roleLabel = session.role === "admin" ? "管理员" : "普通用户";
65
+ const displayName = session.name.trim() || roleLabel;
66
 
67
  return (
68
  <header className="border-b border-stone-100/50">
 
114
  </nav>
115
  <div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
116
  <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
117
+ {roleLabel} · {displayName}
118
  </span>
119
  <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
120
  v{webConfig.appVersion}
web/src/lib/api.ts CHANGED
@@ -344,7 +344,7 @@ export async function createUserKey(name: string) {
344
  });
345
  }
346
 
347
- export async function updateUserKey(keyId: string, updates: { enabled?: boolean; name?: string }) {
348
  return httpRequest<{ item: UserKey; items: UserKey[] }>(`/api/auth/users/${keyId}`, {
349
  method: "POST",
350
  body: updates,
 
344
  });
345
  }
346
 
347
+ export async function updateUserKey(keyId: string, updates: { enabled?: boolean; name?: string; key?: string }) {
348
  return httpRequest<{ item: UserKey; items: UserKey[] }>(`/api/auth/users/${keyId}`, {
349
  method: "POST",
350
  body: updates,
web/src/lib/auth-session.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { login } from "@/lib/api";
4
+ import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
5
+
6
+ export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
7
+ const storedSession = await getStoredAuthSession();
8
+ if (!storedSession) {
9
+ return null;
10
+ }
11
+
12
+ try {
13
+ const data = await login(storedSession.key);
14
+ const nextSession: StoredAuthSession = {
15
+ key: storedSession.key,
16
+ role: data.role,
17
+ subjectId: data.subject_id,
18
+ name: data.name,
19
+ };
20
+ await setStoredAuthSession(nextSession);
21
+ return nextSession;
22
+ } catch {
23
+ await clearStoredAuthSession();
24
+ return null;
25
+ }
26
+ }
web/src/lib/use-auth-guard.ts CHANGED
@@ -3,9 +3,9 @@
3
  import { useEffect, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
 
 
6
  import {
7
  getDefaultRouteForRole,
8
- getStoredAuthSession,
9
  type AuthRole,
10
  type StoredAuthSession,
11
  } from "@/store/auth";
@@ -26,7 +26,7 @@ export function useAuthGuard(allowedRoles?: AuthRole[]): UseAuthGuardResult {
26
 
27
  const load = async () => {
28
  const roleList = allowedRolesKey ? (allowedRolesKey.split(",") as AuthRole[]) : [];
29
- const storedSession = await getStoredAuthSession();
30
  if (!active) {
31
  return;
32
  }
@@ -66,7 +66,7 @@ export function useRedirectIfAuthenticated() {
66
  let active = true;
67
 
68
  const load = async () => {
69
- const storedSession = await getStoredAuthSession();
70
  if (!active) {
71
  return;
72
  }
 
3
  import { useEffect, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
 
6
+ import { getValidatedAuthSession } from "@/lib/auth-session";
7
  import {
8
  getDefaultRouteForRole,
 
9
  type AuthRole,
10
  type StoredAuthSession,
11
  } from "@/store/auth";
 
26
 
27
  const load = async () => {
28
  const roleList = allowedRolesKey ? (allowedRolesKey.split(",") as AuthRole[]) : [];
29
+ const storedSession = await getValidatedAuthSession();
30
  if (!active) {
31
  return;
32
  }
 
66
  let active = true;
67
 
68
  const load = async () => {
69
+ const storedSession = await getValidatedAuthSession();
70
  if (!active) {
71
  return;
72
  }