diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py index 756e712..98c3682 100644 --- a/core/backend/apps/accounts/tests.py +++ b/core/backend/apps/accounts/tests.py @@ -4,7 +4,7 @@ from django.conf import settings from django.test import TestCase from rest_framework.test import APIClient -from apps.accounts.models import Invitation, Team, TeamMember, User +from apps.accounts.models import Invitation, LoginSession, Team, TeamMember, User from apps.billing.models import CreditAccount, CreditLedger @@ -65,6 +65,45 @@ class AuthApiTests(TestCase): self.assertEqual(genesis.count(), 0) +class SingleDeviceLoginTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="single-device", password="strong-password") + + def test_new_login_invalidates_previous_device_and_session(self): + first = APIClient() + first_login = first.post( + "/api/auth/login/", + {"username": self.user.username, "password": "strong-password"}, + format="json", + HTTP_USER_AGENT="Device A", + REMOTE_ADDR="10.0.0.1", + ) + self.assertEqual(first_login.status_code, 200) + first_token = first_login.data["token"] + + second = APIClient() + second_login = second.post( + "/api/auth/login/", + {"username": self.user.username, "password": "strong-password"}, + format="json", + HTTP_USER_AGENT="Device B", + REMOTE_ADDR="10.0.0.2", + ) + self.assertEqual(second_login.status_code, 200) + second_token = second_login.data["token"] + self.assertNotEqual(second_token, first_token) + + first.credentials(HTTP_AUTHORIZATION=f"Token {first_token}") + self.assertEqual(first.get("/api/auth/me/").status_code, 401) + + second.credentials(HTTP_AUTHORIZATION=f"Token {second_token}") + self.assertEqual(second.get("/api/auth/me/").status_code, 200) + + active = LoginSession.objects.filter(user=self.user, revoked_at__isnull=True) + self.assertEqual(active.count(), 1) + self.assertEqual(active.get().user_agent, "Device B") + + class InvitationFlowTests(TestCase): def _register(self, client, username, **extra): return client.post( diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index 08ed292..d53018b 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -4,6 +4,7 @@ from pathlib import Path from django.contrib.auth import authenticate from django.db import transaction +from django.utils import timezone from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, parser_classes, permission_classes @@ -67,8 +68,7 @@ def _client_ip(request): def record_login_session(request, user): - """登录成功后记录设备会话(UA / IP)。去重:同一台电脑(UA)+ 同一 IP 视为同一台设备, - 已存在未下线的同设备会话则只刷新 last_seen_at,不再新增一行(避免「在用设备」列表里同设备重复堆叠)。""" + """记录当前登录设备(UA / IP)。同一设备重复写入时只刷新最近活跃时间。""" try: user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:400] ip_address = _client_ip(request) @@ -87,14 +87,29 @@ def record_login_session(request, user): pass +def issue_single_device_token(request, user): + """签发账号唯一有效 Token。 + + 锁住用户行后再旋转 Token,确保两个设备同时登录时严格由最后完成的登录获胜; + 旧设备持有的 Token 会立即在下一次请求时得到 401。LoginSession 仅保留当前 + 设备为 active,供设置页展示和审计。 + """ + with transaction.atomic(): + locked_user = User.objects.select_for_update().get(pk=user.pk) + LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now()) + Token.objects.filter(user=locked_user).delete() + token = Token.objects.create(user=locked_user) + record_login_session(request, locked_user) + return token + + @api_view(["POST"]) @permission_classes([]) def register(request): serializer = RegisterSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.save() - token, _ = Token.objects.get_or_create(user=data["user"]) - record_login_session(request, data["user"]) + token = issue_single_device_token(request, data["user"]) return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED) @@ -142,15 +157,17 @@ def login(request): # 团队被平台超管停用:其成员一律不准登录(平台超管无团队,不受影响) if team is not None and team.status == Team.Status.DISABLED: return Response({"detail": "团队已停用,请联系平台管理员"}, status=status.HTTP_400_BAD_REQUEST) - token, _ = Token.objects.get_or_create(user=user) - record_login_session(request, user) + token = issue_single_device_token(request, user) return Response(auth_payload(user, team, token)) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def logout(request): - Token.objects.filter(user=request.user).delete() + with transaction.atomic(): + locked_user = User.objects.select_for_update().get(pk=request.user.pk) + LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now()) + Token.objects.filter(user=locked_user).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -189,8 +206,7 @@ def change_password(request): return Response({"new_password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST) user.set_password(new_password) user.save(update_fields=["password"]) - Token.objects.filter(user=user).delete() - token, _ = Token.objects.get_or_create(user=user) + token = issue_single_device_token(request, user) return Response({"token": token.key}) @@ -508,8 +524,6 @@ def revoke_login_session(request, session_id): 只吊销某一台,唯一能真正下线的做法 = 旋转 token(删旧 + 发新),令所有端旧 token 立即失效,再把新 token 发回当前设备(同 revoke_other_sessions 的思路)。 被下线的目标设备下次请求即 401,真正离线。""" - from django.utils import timezone - target = LoginSession.objects.filter(user=request.user, id=session_id).first() if not target: return Response({"revoked": 0}) @@ -520,10 +534,7 @@ def revoke_login_session(request, session_id): revoked_at__isnull=True, ).update(revoked_at=timezone.now()) # 真正吊销:旋转用户 token(令被下线设备旧 token 立即失效),再发新 token 给当前设备。 - Token.objects.filter(user=request.user).delete() - token, _ = Token.objects.get_or_create(user=request.user) - # 刷新当前设备这条会话(旋转后当前设备等价于「重新登录」),避免它被自己下线后又冒出来 - record_login_session(request, request.user) + token = issue_single_device_token(request, request.user) return Response({"revoked": updated, "token": token.key}) @@ -531,10 +542,5 @@ def revoke_login_session(request, session_id): @permission_classes([IsAuthenticated]) def revoke_other_sessions(request): """下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。""" - from django.utils import timezone - - LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now()) - Token.objects.filter(user=request.user).delete() - token, _ = Token.objects.get_or_create(user=request.user) - record_login_session(request, request.user) + token = issue_single_device_token(request, request.user) return Response({"token": token.key}) diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 27e1b62..f5be1e2 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1373,6 +1373,8 @@ class QuickCreateCoordinatorTests(TestCase): + + diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 57d304c..dea07af 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { api, ApiError, getToken, setToken } from "./api"; +import { MonitorOff } from "lucide-react"; +import { api, ApiError, AUTH_INVALIDATED_EVENT, getToken, setToken } from "./api"; import { IconKitSvg } from "./components/IconKitSvg"; import type { AITask, @@ -24,6 +25,7 @@ import { generationErrorText } from "./generation-error"; import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock"; import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell"; import { SystemLoading } from "./components/loading"; +import { ConfirmModal } from "./components/overlays"; import { AccountPage, AssetFactoryPage, @@ -110,6 +112,7 @@ export function App() { const [authMode, setAuthMode] = useState(route.authMode); const [authed, setAuthed] = useState(() => Boolean(getToken())); const [booting, setBooting] = useState(() => Boolean(getToken())); + const [sessionInvalidated, setSessionInvalidated] = useState(false); const [user, setUser] = useState(null); const [team, setTeam] = useState(null); @@ -157,6 +160,13 @@ export function App() { return () => clearTimeout(timer); }, [notice]); + // 页面自己的轮询即使吞掉接口错误,也不能吞掉“账号已在其他设备登录”的全局提示。 + useEffect(() => { + const onInvalidated = () => setSessionInvalidated(true); + window.addEventListener(AUTH_INVALIDATED_EVENT, onInvalidated); + return () => window.removeEventListener(AUTH_INVALIDATED_EVENT, onInvalidated); + }, []); + const activeProduct = useMemo( () => products.find((product) => product.id === activeProductId) || products[0], [products, activeProductId] @@ -292,6 +302,12 @@ export function App() { } catch (bootError) { // 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处 console.error("[boot] identity failed:", bootError); + const status = bootError instanceof ApiError ? bootError.status : 0; + if (status === 401) { + if (!cancelled) setSessionInvalidated(true); + if (!cancelled) setBooting(false); + return; + } setToken(null); if (!cancelled) setAuthed(false); if (!cancelled) setBooting(false); @@ -867,6 +883,7 @@ export function App() { async function onAuthed(payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) { setToken(payload.token, payload.remember ?? true); + setSessionInvalidated(false); setUser(payload.user); setTeam(payload.team); setRole(payload.role || ""); @@ -901,42 +918,79 @@ export function App() { window.history.replaceState(null, "", "/login"); } + function returnToLoginAfterInvalidation() { + setToken(null); + setSessionInvalidated(false); + setAuthed(false); + setBooting(false); + setUser(null); + setTeam(null); + setRole(""); + setAuthMode("login"); + window.history.replaceState(null, "", "/login"); + } + + const sessionInvalidatedModal = ( + } + detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。" + confirmText="确认并返回登录" + dismissable={false} + showCancel={false} + priority + onCancel={() => undefined} + onConfirm={returnToLoginAfterInvalidation} + /> + ); + // ---- Auth gate ---- if (!authed) { return ( - { - setAuthMode(next); - window.history.pushState(null, "", next === "register" ? "/register" : "/login"); - }} - onAuthed={onAuthed} - /> + <> + { + setAuthMode(next); + window.history.pushState(null, "", next === "register" ? "/register" : "/login"); + }} + onAuthed={onAuthed} + /> + {sessionInvalidatedModal} + ); } // 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。 if (!booting && user && route.admin !== undefined && user.is_platform_admin) { return ( - + <> + + {sessionInvalidatedModal} + ); } if (booting || !user || !team) { return ( - + <> + + {sessionInvalidatedModal} + ); } @@ -1303,50 +1357,53 @@ export function App() { ) : null; return ( -
- navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} /> -
- {page === "omniSession" ? ( -
- ) : ( - - )} -
- {page !== "omniSession" && ( - + <> +
+ navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} /> +
+ {page === "omniSession" ? ( +
+ ) : ( + )} - navigate("account")}> - - 余额 {money(billing?.account.balance)} - - -
-
-
- -
- - {notice && } - {pipelinePage || renderPage()} -
-
- setAccountAnchor(null)} - navigate={navigate} - logout={logout} - user={currentUser} - team={currentTeam} - canManageBilling={isOwner} - /> -
+
+ {page !== "omniSession" && ( + + )} + navigate("account")}> + + 余额 {money(billing?.account.balance)} + + +
+
+
+ +
+ + {notice && } + {pipelinePage || renderPage()} +
+
+ setAccountAnchor(null)} + navigate={navigate} + logout={logout} + user={currentUser} + team={currentTeam} + canManageBilling={isOwner} + /> +
+ {sessionInvalidatedModal} + ); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index de85532..8167ea1 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -57,6 +57,8 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; const TOKEN_KEY = "airshelf_token"; const REMEMBER_KEY = "airshelf_remember"; // { username, expireAt } · 勾选「记住我 7 天」时写 const REMEMBER_DAYS = 7; +export const AUTH_INVALIDATED_EVENT = "airshelf:auth-invalidated"; +let authInvalidationNotified = false; const AUTH_MISSING_MESSAGES = new Set([ "身份认证信息未提供。", "Authentication credentials were not provided." @@ -159,10 +161,17 @@ export function setToken(token: string | null, remember = true) { localStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY); if (!token) return; + authInvalidationNotified = false; if (remember) localStorage.setItem(TOKEN_KEY, token); else sessionStorage.setItem(TOKEN_KEY, token); } +function notifyAuthInvalidated() { + if (authInvalidationNotified || typeof window === "undefined") return; + authInvalidationNotified = true; + window.dispatchEvent(new CustomEvent(AUTH_INVALIDATED_EVENT)); +} + async function request(path: string, options: RequestInit = {}): Promise { const token = getToken(); const headers = new Headers(options.headers); @@ -173,6 +182,9 @@ async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (!response.ok) { + // 已携带凭证却收到 401,说明 Token 已被服务端吊销。先通知应用壳弹出 + // 单设备登录提示,再保留原始错误给具体调用方处理。 + if (response.status === 401 && token && path !== "/api/auth/logout/") notifyAuthInvalidated(); const text = await response.text(); // DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上 let message = text || `${response.status} ${response.statusText}`; diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx index b5d4419..4b35bad 100644 --- a/core/frontend/src/components/overlays.tsx +++ b/core/frontend/src/components/overlays.tsx @@ -134,7 +134,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f ); } -export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true }: { +export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true, showCancel = true, priority = false }: { open: boolean; title: string; detail: ReactNode; @@ -145,18 +145,22 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", onConfirm: () => void | Promise; /** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */ dismissable?: boolean; + /** 强制确认场景可隐藏取消按钮;默认显示,保持现有弹窗行为。 */ + showCancel?: boolean; + /** 系统级强制提示置于灯箱、抽屉和普通确认框之上。 */ + priority?: boolean; }) { useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, onCancel); if (!mounted) return null; // 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏 return createPortal( -
-
event.stopPropagation()}> +
+
event.stopPropagation()}> ++
{icon ?? }
{title}{subtitle ? {subtitle} : null}
{detail}
-
+
{showCancel ? : null}
, document.body, diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index e85fd9e..3f03a30 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -2598,6 +2598,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); } transition: opacity .2s ease, visibility .2s ease; } .modal-bg.show { opacity: 1; visibility: visible; pointer-events: auto; } +.modal-bg.modal-priority { z-index: 11000; } .modal { background: var(--surface); border: 1px solid var(--border-faint); diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index d1466cd..00b9cdd 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -397,6 +397,12 @@ margin: 0; } +/* 卖点确认与剧情方向共用追问容器,不能在容器内再次计算宽度和左缩进。 */ +.omni-elicit-slot .omni-selling-point-card { + width: 100%; + margin: 0; +} + .omni-strategy-card { overflow: hidden; border-top: 3px solid var(--klein); @@ -1471,8 +1477,8 @@ /* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */ .omni-direction-card { box-sizing: border-box; - width: min(760px, calc(100% - 44px)); - margin: 0 0 24px 44px; + width: 100%; + margin: 0; overflow: hidden; border-radius: 8px; background: var(--surface);