Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -149,8 +149,15 @@
|
|||||||
|
|
||||||
- 不主动附带 `Authorization`
|
- 不主动附带 `Authorization`
|
||||||
- 不因本地 access token 失效去触发 `/api/auth/refresh`
|
- 不因本地 access token 失效去触发 `/api/auth/refresh`
|
||||||
|
- 若当前请求本身没有携带 access token,也不允许因为返回 `401` 就额外触发 `/api/auth/refresh`
|
||||||
- refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态
|
- refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态
|
||||||
|
|
||||||
|
受保护工作区恢复补充约束:
|
||||||
|
|
||||||
|
- 若 URL 或 `sessionStorage` 中残留 `customWorldSessionId`、`customWorldOperationId` 等共创工作区恢复标记,未登录态下不能直接请求对应的 Agent 会话或操作接口
|
||||||
|
- 这类“恢复共创工作区”场景要先弹出登录弹窗,登录成功后再恢复原本的工作区或操作上下文
|
||||||
|
- 用户未登录且关闭登录弹窗时,前端保持平台页可浏览状态,不允许持续轮询受保护接口
|
||||||
|
|
||||||
未登录时不读取:
|
未登录时不读取:
|
||||||
|
|
||||||
- 自定义世界库
|
- 自定义世界库
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ beforeEach(() => {
|
|||||||
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
|
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<TestWrapper />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
const createButtons = await screen.findAllByRole('button', {
|
const createButtons = await screen.findAllByRole('button', {
|
||||||
@@ -522,10 +522,37 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
|||||||
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
|
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
||||||
|
const openLoginModal = vi.fn();
|
||||||
|
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?customWorldSessionId=custom-world-agent-session-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper
|
||||||
|
authValue={createAuthValue({
|
||||||
|
user: null,
|
||||||
|
openLoginModal,
|
||||||
|
requireAuth: vi.fn(),
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
expect(getCustomWorldAgentSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<TestWrapper />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openNewRpgCreation(user);
|
await openNewRpgCreation(user);
|
||||||
|
|
||||||
@@ -657,7 +684,7 @@ test('existing draft sessions enter the agent preview layout without opening leg
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<TestWrapper />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openNewRpgCreation(user);
|
await openNewRpgCreation(user);
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export function PreGameSelectionFlow({
|
|||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||||
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
||||||
|
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
|
||||||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||||||
useState<CustomWorldProfile | null>(null);
|
useState<CustomWorldProfile | null>(null);
|
||||||
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
||||||
@@ -400,16 +401,29 @@ export function PreGameSelectionFlow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasAppliedInitialAgentWorkspaceRef.current) {
|
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
|
||||||
|
|
||||||
|
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlatformTab('create');
|
||||||
|
|
||||||
|
// URL 或 sessionStorage 中残留的共创工作区属于受保护入口,
|
||||||
|
// 未登录时只允许先唤起登录弹窗,不能直接恢复会话请求。
|
||||||
|
if (!authUi?.user) {
|
||||||
|
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
|
||||||
|
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
|
||||||
|
authUi?.openLoginModal?.(() => {
|
||||||
|
setSelectionStage('agent-workspace');
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAppliedInitialAgentWorkspaceRef.current = true;
|
hasAppliedInitialAgentWorkspaceRef.current = true;
|
||||||
if (initialAgentUiStateRef.current.activeSessionId) {
|
setSelectionStage('agent-workspace');
|
||||||
setPlatformTab('create');
|
}, [authUi?.openLoginModal, authUi?.user, setSelectionStage]);
|
||||||
setSelectionStage('agent-workspace');
|
|
||||||
}
|
|
||||||
}, [setSelectionStage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDetailEntry) {
|
if (!selectedDetailEntry) {
|
||||||
@@ -602,6 +616,16 @@ export function PreGameSelectionFlow({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeAgentSessionId) {
|
if (!activeAgentSessionId) {
|
||||||
setAgentSession(null);
|
setAgentSession(null);
|
||||||
|
setAgentOperation(null);
|
||||||
|
setIsLoadingAgentSession(false);
|
||||||
|
setStreamingAgentReplyText('');
|
||||||
|
setIsStreamingAgentReply(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authUi?.user) {
|
||||||
|
setAgentSession(null);
|
||||||
|
setAgentOperation(null);
|
||||||
setIsLoadingAgentSession(false);
|
setIsLoadingAgentSession(false);
|
||||||
setStreamingAgentReplyText('');
|
setStreamingAgentReplyText('');
|
||||||
setIsStreamingAgentReply(false);
|
setIsStreamingAgentReply(false);
|
||||||
@@ -644,13 +668,14 @@ export function PreGameSelectionFlow({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeAgentSessionId,
|
activeAgentSessionId,
|
||||||
|
authUi?.user,
|
||||||
persistAgentUiState,
|
persistAgentUiState,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
syncAgentSessionSnapshot,
|
syncAgentSessionSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeAgentSessionId || !activeAgentOperationId) {
|
if (!activeAgentSessionId || !activeAgentOperationId || !authUi?.user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +735,7 @@ export function PreGameSelectionFlow({
|
|||||||
}, [
|
}, [
|
||||||
activeAgentOperationId,
|
activeAgentOperationId,
|
||||||
activeAgentSessionId,
|
activeAgentSessionId,
|
||||||
|
authUi?.user,
|
||||||
persistAgentUiState,
|
persistAgentUiState,
|
||||||
syncAgentSessionSnapshot,
|
syncAgentSessionSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -157,6 +157,24 @@ describe('apiClient', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not refresh or emit auth changes for 401 responses without auth context', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||||
|
|
||||||
|
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/runtime/protected',
|
||||||
|
expect.objectContaining({
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('retries transient get requests before unwrapping the response envelope', async () => {
|
it('retries transient get requests before unwrapping the response envelope', async () => {
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockRejectedValueOnce(new TypeError('network unavailable'))
|
.mockRejectedValueOnce(new TypeError('network unavailable'))
|
||||||
|
|||||||
@@ -349,12 +349,15 @@ export function setStoredAccessToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextToken = token.trim();
|
const nextToken = token.trim();
|
||||||
|
const previousToken = getStoredAccessToken();
|
||||||
if (nextToken) {
|
if (nextToken) {
|
||||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
|
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
if (options.emit !== false) {
|
|
||||||
|
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
|
||||||
|
if (options.emit !== false && previousToken !== nextToken) {
|
||||||
emitAuthStateChange();
|
emitAuthStateChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,8 +371,11 @@ export function clearStoredAccessToken(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousToken = getStoredAccessToken();
|
||||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
if (options.emit !== false) {
|
|
||||||
|
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
|
||||||
|
if (options.emit !== false && previousToken) {
|
||||||
emitAuthStateChange();
|
emitAuthStateChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,14 +451,20 @@ export async function fetchWithApiAuth(
|
|||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
|
const requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||||
|
const hasAuthHeader = Boolean(
|
||||||
|
requestHeaders.Authorization?.trim() ||
|
||||||
|
requestHeaders.authorization?.trim(),
|
||||||
|
);
|
||||||
const response = await fetch(input, {
|
const response = await fetch(input, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
...init,
|
...init,
|
||||||
headers: withAuthorizationHeaders(init.headers, options),
|
headers: requestHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.status === 401 &&
|
response.status === 401 &&
|
||||||
|
hasAuthHeader &&
|
||||||
!options.skipAuth &&
|
!options.skipAuth &&
|
||||||
!options.skipRefresh &&
|
!options.skipRefresh &&
|
||||||
!refreshAttempted
|
!refreshAttempted
|
||||||
|
|||||||
Reference in New Issue
Block a user