This commit is contained in:
2026-05-09 19:56:03 +08:00
parent 052dbc248b
commit 7c8aa1e124
12 changed files with 483 additions and 59 deletions

View File

@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
getStoredAccessToken: vi.fn(),
refreshStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
@@ -29,6 +30,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
getStoredAccessToken: authMocks.getStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
}));
@@ -96,6 +98,7 @@ beforeEach(() => {
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
@@ -231,10 +234,37 @@ test('auth gate waits for refresh cookie rotation before exposing restored user
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: true,
});
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => {
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockRejectedValue(
new Error('refresh cookie 失效'),
);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: false,
});
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],

View File

@@ -10,6 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
getStoredAccessToken,
refreshStoredAccessToken,
} from '../../services/apiClient';
import {
@@ -18,6 +19,7 @@ import {
authEntry,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSnapshot,
type AuthSessionSummary,
type AuthUser,
bindWechatPhone,
@@ -81,6 +83,18 @@ function normalizeAvailableLoginMethods(
: FALLBACK_LOGIN_METHODS;
}
type AuthHydrateSessionResult =
| {
kind: 'authenticated';
session: AuthSessionSnapshot & {
user: AuthUser;
};
}
| {
kind: 'guest';
session: AuthSessionSnapshot | null;
};
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
@@ -163,6 +177,56 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
}, []);
const restoreAuthSession = useCallback(async () => {
const hadLocalAccessToken = Boolean(getStoredAccessToken());
if (hadLocalAccessToken) {
try {
const session = await getCurrentAuthUser();
if (session.user) {
const confirmedUser = session.user;
// 中文注释:已有 access token 能确认当前账号时refresh 只作为续期和每日登录埋点补强。
// refresh cookie 临时失效或代理抖动不能反向抹掉这次已确认的登录态。
void refreshStoredAccessToken({ clearOnFailure: false }).catch(
() => undefined,
);
return {
kind: 'authenticated',
session: {
...session,
user: confirmedUser,
},
} satisfies AuthHydrateSessionResult;
}
return {
kind: 'guest',
session,
} satisfies AuthHydrateSessionResult;
} catch {
// 本地 token 可能已过期或被吊销,再尝试通过 refresh cookie 补票。
}
}
await refreshStoredAccessToken({ clearOnFailure: true });
const session = await getCurrentAuthUser();
if (session.user) {
const confirmedUser = session.user;
return {
kind: 'authenticated',
session: {
...session,
user: confirmedUser,
},
} satisfies AuthHydrateSessionResult;
}
return {
kind: 'guest',
session,
} satisfies AuthHydrateSessionResult;
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
try {
@@ -316,26 +380,21 @@ export function AuthGate({ children }: AuthGateProps) {
}
try {
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
await refreshStoredAccessToken();
const restoredSession = await restoreAuthSession();
if (!isCurrentHydrate()) {
return;
}
const nextSession = await getCurrentAuthUser();
if (!isCurrentHydrate()) {
return;
}
if (!nextSession.user) {
if (restoredSession.kind === 'guest') {
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
normalizeAvailableLoginMethods(
restoredSession.session?.availableLoginMethods,
),
);
await resolveGuestFallback();
return;
}
const nextSession = restoredSession.session;
setUser(nextSession.user);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
@@ -368,7 +427,7 @@ export function AuthGate({ children }: AuthGateProps) {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, [activateReadyUser]);
}, [restoreAuthSession]);
useEffect(() => {
if (!readyUser) {

View File

@@ -1558,6 +1558,7 @@ export function PlatformEntryFlowShellImpl({
useState<string | null>(null);
const [isStartingRecommendEntry, setIsStartingRecommendEntry] =
useState(false);
const recommendRuntimeStartRequestRef = useRef(0);
const [, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
null,
);
@@ -5651,38 +5652,36 @@ export function PlatformEntryFlowShellImpl({
],
);
const openRecommendGalleryDetail = useCallback(
const openPublicGalleryDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
runProtectedAction(() => {
if (isBigFishGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isBigFishGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
if (isMatch3DGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isMatch3DGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isSquareHoleGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isSquareHoleGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isVisualNovelGalleryEntry(entry)) {
void openVisualNovelPublicWorkDetail(entry.profileId);
return;
}
if (isVisualNovelGalleryEntry(entry)) {
void openVisualNovelPublicWorkDetail(entry.profileId);
return;
}
void openRpgPublicWorkDetail(entry);
});
void openRpgPublicWorkDetail(entry);
},
[
openPuzzlePublicWorkDetail,
@@ -5690,9 +5689,17 @@ export function PlatformEntryFlowShellImpl({
openRpgPublicWorkDetail,
openVisualNovelPublicWorkDetail,
platformBootstrap.platformTab,
runProtectedAction,
],
);
const openRecommendGalleryDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
runProtectedAction(() => {
openPublicGalleryDetail(entry);
});
},
[openPublicGalleryDetail, runProtectedAction],
);
const openPuzzleDetail = useCallback(
async (
profileId: string,
@@ -6112,8 +6119,15 @@ export function PlatformEntryFlowShellImpl({
async (entry: PlatformPublicGalleryCard) => {
const entryKey = getPlatformPublicGalleryEntryKey(entry);
const runtimeKind = getPlatformRecommendRuntimeKind(entry);
const startRequestId = recommendRuntimeStartRequestRef.current + 1;
recommendRuntimeStartRequestRef.current = startRequestId;
const isCurrentStartRequest = () =>
recommendRuntimeStartRequestRef.current === startRequestId;
if (entryKey !== activeRecommendEntryKey) {
await saveAndExitRecommendPuzzleRuntime();
if (!isCurrentStartRequest()) {
return;
}
}
setActiveRecommendEntryKey(entryKey);
setActiveRecommendRuntimeKind(runtimeKind);
@@ -6182,9 +6196,28 @@ export function PlatformEntryFlowShellImpl({
started = true;
}
setActiveRecommendRuntimeKind(started ? runtimeKind : null);
if (!isCurrentStartRequest()) {
return;
}
if (started) {
setActiveRecommendRuntimeKind(runtimeKind);
setActiveRecommendRuntimeError(null);
} else {
setActiveRecommendRuntimeKind(null);
setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。');
}
} catch (error) {
if (!isCurrentStartRequest()) {
return;
}
setActiveRecommendRuntimeKind(null);
setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。');
} finally {
setIsStartingRecommendEntry(false);
if (isCurrentStartRequest()) {
setIsStartingRecommendEntry(false);
}
}
},
[
@@ -7549,7 +7582,8 @@ export function PlatformEntryFlowShellImpl({
}}
onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={openRecommendGalleryDetail}
onOpenGalleryDetail={openPublicGalleryDetail}
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
recommendRuntimeContent={recommendRuntimeContent}
activeRecommendEntryKey={activeRecommendEntryKey}
isStartingRecommendEntry={

View File

@@ -2810,6 +2810,7 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
@@ -3066,6 +3067,44 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
});
});
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-1',
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockRejectedValueOnce(
new Error('启动拼图玩法失败'),
);
render(<TestWrapper withAuth />);
expect(
await screen.findByText('作品暂时无法进入,请稍后再试。'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
});
test('published big fish works stay hidden from platform home and game category channel', async () => {
const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = {

View File

@@ -515,6 +515,7 @@ function renderLoggedOutHomeView(
| 'featuredEntries'
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onOpenRecommendGalleryDetail'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
@@ -568,6 +569,7 @@ function renderLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime"></div>
@@ -592,6 +594,7 @@ function renderStatefulLoggedOutHomeView(
| 'featuredEntries'
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onOpenRecommendGalleryDetail'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
@@ -650,6 +653,7 @@ function renderStatefulLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime" />
@@ -1171,7 +1175,93 @@ test('logged out recommend cover opens login modal again', async () => {
await user.click(screen.getByRole('button', { name: / /u }));
expect(openLoginModal).toHaveBeenCalledTimes(2);
expect(openLoginModal).toHaveBeenLastCalledWith(expect.any(Function));
expect(openLoginModal).toHaveBeenLastCalledWith();
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('logged out desktop recommend page renders cover only', () => {
mockDesktopLayout();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
expect(screen.queryByText('今日游戏')).toBeNull();
expect(screen.queryByText('作品分类')).toBeNull();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
});
test('logged in recommend page uses gated recommend detail callback', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
const onOpenRecommendGalleryDetail = vi.fn();
render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[puzzlePublicEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={onOpenGalleryDetail}
onOpenRecommendGalleryDetail={onOpenRecommendGalleryDetail}
recommendRuntimeError="作品暂时无法进入,请稍后再试。"
activeRecommendEntryKey="puzzle:user-2:puzzle-profile-public-1"
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
await user.click(screen.getByText('作品暂时无法进入,请稍后再试。'));
expect(onOpenRecommendGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
@@ -1435,7 +1525,7 @@ test('mobile today channel only shows newly published works from today', async (
expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull();
});
test('desktop home syncs mobile home modules without square or latest labels', () => {
test('desktop logged in home syncs mobile home modules without square or latest labels', () => {
mockDesktopLayout();
const todayPublishedAt = new Date().toISOString();
const todayEntry = {
@@ -1448,9 +1538,64 @@ test('desktop home syncs mobile home modules without square or latest labels', (
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, todayEntry],
});
render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[puzzlePublicEntry, todayEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getAllByText('推荐').length).toBeGreaterThan(0);

View File

@@ -118,6 +118,7 @@ export interface RpgEntryHomeViewProps {
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean;
@@ -2898,6 +2899,7 @@ export function RpgEntryHomeView({
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenRecommendGalleryDetail,
recommendRuntimeContent,
activeRecommendEntryKey = null,
isStartingRecommendEntry = false,
@@ -3007,6 +3009,8 @@ export function RpgEntryHomeView({
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout();
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6),
[featuredEntries],
@@ -3771,12 +3775,17 @@ export function RpgEntryHomeView({
}
if (!isAuthenticated) {
authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry));
authUi?.openLoginModal();
return;
}
onOpenGalleryDetail(activeRecommendEntry);
}, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]);
openRecommendGalleryDetail(activeRecommendEntry);
}, [
activeRecommendEntry,
authUi,
isAuthenticated,
openRecommendGalleryDetail,
]);
const selectNextRecommendEntry = useCallback(() => {
onSelectNextRecommendEntry?.();
}, [onSelectNextRecommendEntry]);
@@ -3786,7 +3795,7 @@ export function RpgEntryHomeView({
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
onOpenGalleryDetail(leadPublicEntry);
openRecommendGalleryDetail(leadPublicEntry);
return;
}
@@ -3870,7 +3879,7 @@ export function RpgEntryHomeView({
type="button"
onClick={() =>
activeRecommendEntry
? onOpenGalleryDetail(activeRecommendEntry)
? openRecommendGalleryDetail(activeRecommendEntry)
: undefined
}
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
@@ -4463,7 +4472,7 @@ export function RpgEntryHomeView({
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
entry={entry}
rank={index + 1}
onClick={() => onOpenGalleryDetail(entry)}
onClick={() => openRecommendGalleryDetail(entry)}
/>
))}
</div>
@@ -4486,7 +4495,7 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
@@ -4552,7 +4561,7 @@ export function RpgEntryHomeView({
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
onOpenGalleryDetail({
openRecommendGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: null,
@@ -4621,7 +4630,7 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
@@ -4638,7 +4647,10 @@ export function RpgEntryHomeView({
);
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
home:
!isAuthenticated || !isDesktopLayout
? mobileRecommendContent
: desktopHomeContent,
category: categoryContent,
create: createContent,
saves: savesContent,

View File

@@ -8,6 +8,7 @@ import {
fetchWithApiAuth,
getStoredAccessToken,
isTimeoutError,
refreshStoredAccessToken,
requestJson,
setStoredAccessToken,
} from './apiClient';
@@ -312,6 +313,27 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('still-valid-token');
});
it('keeps local token when explicit refresh opts out of clearing on failure', async () => {
setStoredAccessToken('usable-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
await expect(
refreshStoredAccessToken({ clearOnFailure: false }),
).rejects.toMatchObject({
status: 401,
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock

View File

@@ -548,11 +548,17 @@ export async function ensureStoredAccessToken() {
return refreshAccessToken();
}
export async function refreshStoredAccessToken() {
export async function refreshStoredAccessToken(
options: {
clearOnFailure?: boolean;
} = {},
) {
try {
return await refreshAccessToken();
} catch (error) {
clearStoredAccessToken({ emit: false });
if (options.clearOnFailure !== false) {
clearStoredAccessToken({ emit: false });
}
throw error;
}
}