fix(auth): 访客工作台 - 欢迎页关闭后展示创作界面 (#697)
Co-authored-by: a0 <a0@Mac-mini.local>
This commit is contained in:
@@ -25,7 +25,7 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) {
|
|||||||
durationMs: performance.now() - startedAt,
|
durationMs: performance.now() - startedAt,
|
||||||
});
|
});
|
||||||
if (!payload.user) {
|
if (!payload.user) {
|
||||||
applyAnonymousSession(payload);
|
await applyAnonymousSession(payload);
|
||||||
recordDiagnosticEvent({
|
recordDiagnosticEvent({
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
level: "info",
|
level: "info",
|
||||||
@@ -48,9 +48,9 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
preloadWorkspaceRoute(window.location.pathname);
|
preloadWorkspaceRoute(window.location.pathname);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(async () => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
applyAnonymousSession({ user: null });
|
await applyAnonymousSession({ user: null });
|
||||||
recordDiagnosticEvent({
|
recordDiagnosticEvent({
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
level: "warning",
|
level: "warning",
|
||||||
@@ -68,11 +68,7 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) {
|
|||||||
return hydrated ? children : <FullScreenLoader />;
|
return hydrated ? children : <FullScreenLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAnonymousSession(payload: AuthSessionPayload) {
|
async function applyAnonymousSession(payload: AuthSessionPayload) {
|
||||||
const store = useUserStore.getState();
|
const { applyUserSession } = await import("@/lib/user-session");
|
||||||
store.clearSession();
|
await applyUserSession({ ...payload, user: null });
|
||||||
store.setRuntimeLimits(payload.runtimeLimits);
|
|
||||||
store.setDrawingEngine(payload.drawingEngine);
|
|
||||||
store.setFeatures(payload.features);
|
|
||||||
store.setHydrated(true);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { Navigate, useLocation } from "react-router";
|
|||||||
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
|
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
export function RequireAuth({ children, allowAnonymous = false }: { children: ReactNode; allowAnonymous?: boolean }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hydrated = useUserStore((state) => state.hydrated);
|
const hydrated = useUserStore((state) => state.hydrated);
|
||||||
const user = useUserStore((state) => state.user);
|
const user = useUserStore((state) => state.user);
|
||||||
|
|
||||||
if (!hydrated) return <FullScreenLoader />;
|
if (!hydrated) return <FullScreenLoader />;
|
||||||
if (!user) return <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
if (!user && !allowAnonymous) return <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Popover } from "antd";
|
import { Popover } from "antd";
|
||||||
import { Switch } from "@/components/ui/base/switch";
|
import { Switch } from "@/components/ui/base/switch";
|
||||||
import { LogIn, Moon, Sun } from "lucide-react";
|
import { CircleUserRound, Moon, Sun } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
|
||||||
|
|
||||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||||
@@ -51,9 +50,5 @@ export function WorkspaceAccountMenu() {
|
|||||||
<UserAvatar user={user} className="size-6" />
|
<UserAvatar user={user} className="size-6" />
|
||||||
</button>
|
</button>
|
||||||
</Popover></>
|
</Popover></>
|
||||||
) : (
|
) : <span className="inline-flex items-center gap-1.5 px-1.5 text-xs text-foreground/55" role="img" aria-label="游客模式" title="游客模式"><CircleUserRound className="size-5" /><span className="hidden sm:inline">游客</span></span>;
|
||||||
<Link to="/login" className="app-workspace-topbar-icon-button" aria-label="登录" title="登录">
|
|
||||||
<LogIn />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user
|
|||||||
useEffect(() => setFailed(false), [avatarUrl]);
|
useEffect(() => setFailed(false), [avatarUrl]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><CircleUserRound className="size-5" /><span>登录</span></Link>;
|
return <div className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} role="img" aria-label="游客模式" title="游客模式"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
|
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
|
||||||
|
|||||||
3
web/src/lib/guest-workspace.ts
Normal file
3
web/src/lib/guest-workspace.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isGuestWorkspacePath(pathname: string) {
|
||||||
|
return pathname === "/" || pathname === "/create";
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function WelcomeSetting() {
|
|||||||
<div className="admin-appearance-logo-frame-option">
|
<div className="admin-appearance-logo-frame-option">
|
||||||
<div className="admin-appearance-logo-frame-copy">
|
<div className="admin-appearance-logo-frame-copy">
|
||||||
<strong>启用欢迎页</strong>
|
<strong>启用欢迎页</strong>
|
||||||
<p>切换后立即保存。关闭后访问 /welcome 将跳转到首页。</p>
|
<p>切换后立即保存。关闭后访问 /welcome 将进入创作工作台;游客可浏览,生成时需登录。</p>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
73
web/src/pages/create/creation-login-dialog.tsx
Normal file
73
web/src/pages/create/creation-login-dialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { App, Button, Input } from "antd";
|
||||||
|
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { AppModal } from "@/components/ui/product/app-modal";
|
||||||
|
import { getAuthSession, login } from "@/services/api/auth";
|
||||||
|
|
||||||
|
export function CreationLoginDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login({ username: username.trim(), password });
|
||||||
|
const { applyUserSession } = await import("@/lib/user-session");
|
||||||
|
await applyUserSession(await getAuthSession());
|
||||||
|
message.success("登录成功,请再次点击生成");
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : "登录失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppModal open={open} onCancel={onClose} title="登录后开始创作" footer={null} centered width="min(440px, calc(100vw - 32px))">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-foreground/60">你的创作内容会保留。登录成功后,请再次点击生成。</p>
|
||||||
|
<form className="space-y-3" onSubmit={(event) => void submit(event)}>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
aria-label="用户名或邮箱"
|
||||||
|
prefix={<UserRound className="size-4 text-foreground/40" />}
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="用户名或邮箱"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
aria-label="密码"
|
||||||
|
prefix={<LockKeyhole className="size-4 text-foreground/40" />}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||||
|
登录并继续
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-xs text-foreground/55">还没有账号? <Link className="text-primary hover:underline" to="/register?next=%2F" onClick={onClose}>注册</Link></p>
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, Creation
|
|||||||
import { CreationAgentEntry } from "./creation-agent-entry";
|
import { CreationAgentEntry } from "./creation-agent-entry";
|
||||||
import { createCreationSubmitGate } from "./creation-submit-gate";
|
import { createCreationSubmitGate } from "./creation-submit-gate";
|
||||||
import { creationVideoConfig } from "./creation-generation-config";
|
import { creationVideoConfig } from "./creation-generation-config";
|
||||||
|
import { CreationLoginDialog } from "./creation-login-dialog";
|
||||||
|
|
||||||
const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal })));
|
const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal })));
|
||||||
const loadCreationRuntime = () => import("./creation-runtime");
|
const loadCreationRuntime = () => import("./creation-runtime");
|
||||||
@@ -97,6 +98,9 @@ export default function CreatePage() {
|
|||||||
const [activeId, setActiveId] = useState("");
|
const [activeId, setActiveId] = useState("");
|
||||||
const activeIdRef = useRef("");
|
const activeIdRef = useRef("");
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const userId = useUserStore((state) => state.user?.id || null);
|
||||||
|
const userSessionHydrated = useUserStore((state) => state.hydrated);
|
||||||
|
const [conversationScope, setConversationScope] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<CreationMode>(() => initialComposerPreferences.mode || defaultCreationMode);
|
const [mode, setMode] = useState<CreationMode>(() => initialComposerPreferences.mode || defaultCreationMode);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
|
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
|
||||||
@@ -115,6 +119,7 @@ export default function CreatePage() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [referenceReplacementBusy, setReferenceReplacementBusy] = useState(false);
|
const [referenceReplacementBusy, setReferenceReplacementBusy] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
const externalAssetSources = useExternalAssetSources(libraryOpen);
|
const externalAssetSources = useExternalAssetSources(libraryOpen);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@@ -256,20 +261,25 @@ export default function CreatePage() {
|
|||||||
}, [attachments, maxReferences, mentionReferences, mode, videoReferenceLimits]);
|
}, [attachments, maxReferences, mentionReferences, mode, videoReferenceLimits]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userSessionHydrated) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const scope = getActiveUserScope();
|
||||||
|
setHydrated(false);
|
||||||
|
setConversationScope(null);
|
||||||
void loadCreationConversations<CreationConversation>().then((stored) => {
|
void loadCreationConversations<CreationConversation>().then((stored) => {
|
||||||
if (cancelled) return;
|
if (cancelled || getActiveUserScope() !== scope || (useUserStore.getState().user?.id || null) !== userId) return;
|
||||||
const next = stored?.length ? stored : [newConversation()];
|
const next = stored?.length ? stored : [newConversation()];
|
||||||
conversationsRef.current = next;
|
conversationsRef.current = next;
|
||||||
setConversations(next);
|
setConversations(next);
|
||||||
setActiveId(next[0].id);
|
setActiveId(next[0].id);
|
||||||
|
setConversationScope(scope);
|
||||||
setHydrated(true);
|
setHydrated(true);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
// 页面卸载只停止当前页面的状态更新,后台任务由任务中心继续执行,返回页面后再恢复状态。
|
// 页面卸载只停止当前页面的状态更新,后台任务由任务中心继续执行,返回页面后再恢复状态。
|
||||||
};
|
};
|
||||||
}, []);
|
}, [userId, userSessionHydrated]);
|
||||||
|
|
||||||
useEffect(() => () => abortRef.current?.abort(), []);
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
@@ -279,8 +289,8 @@ export default function CreatePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
conversationsRef.current = conversations;
|
conversationsRef.current = conversations;
|
||||||
if (hydrated) void saveCreationConversations(conversations);
|
if (hydrated && userSessionHydrated && conversationScope === getActiveUserScope()) void saveCreationConversations(conversations);
|
||||||
}, [conversations, hydrated]);
|
}, [conversations, conversationScope, hydrated, userSessionHydrated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated || !recoveryTaskKey || !pendingTaskIds.length) return;
|
if (!hydrated || !recoveryTaskKey || !pendingTaskIds.length) return;
|
||||||
@@ -570,6 +580,12 @@ export default function CreatePage() {
|
|||||||
releaseSubmitGate();
|
releaseSubmitGate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!useUserStore.getState().user) {
|
||||||
|
setLoginDialogOpen(true);
|
||||||
|
releaseRetryLock();
|
||||||
|
releaseSubmitGate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
||||||
releaseRetryLock();
|
releaseRetryLock();
|
||||||
@@ -1066,6 +1082,7 @@ export default function CreatePage() {
|
|||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onNew={startNewConversation} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} onRename={renameConversationTitle} />
|
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onNew={startNewConversation} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} onRename={renameConversationTitle} />
|
||||||
|
<CreationLoginDialog open={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} />
|
||||||
{libraryOpen ? <Suspense fallback={null}><AssetLibraryPickerModal
|
{libraryOpen ? <Suspense fallback={null}><AssetLibraryPickerModal
|
||||||
remoteLibrary
|
remoteLibrary
|
||||||
open={libraryOpen}
|
open={libraryOpen}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage,
|
|||||||
import { CanvasRefreshShell } from "@/pages/canvas/canvas-refresh-shell";
|
import { CanvasRefreshShell } from "@/pages/canvas/canvas-refresh-shell";
|
||||||
import { AuthScene } from "@/pages/auth/auth-scene";
|
import { AuthScene } from "@/pages/auth/auth-scene";
|
||||||
import RouteErrorPage from "@/pages/route-error";
|
import RouteErrorPage from "@/pages/route-error";
|
||||||
|
import { isGuestWorkspacePath } from "@/lib/guest-workspace";
|
||||||
|
|
||||||
const AdminPage = lazy(() => import("@/pages/admin"));
|
const AdminPage = lazy(() => import("@/pages/admin"));
|
||||||
const AnalyticsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnalyticsPage })));
|
const AnalyticsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnalyticsPage })));
|
||||||
@@ -68,7 +69,7 @@ function AuthenticatedWorkspaceLayout() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const isCanvasProjectRoute = pathname.startsWith("/canvas/");
|
const isCanvasProjectRoute = pathname.startsWith("/canvas/");
|
||||||
const fallback = isCanvasProjectRoute ? <CanvasRefreshShell /> : <FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />;
|
const fallback = isCanvasProjectRoute ? <CanvasRefreshShell /> : <FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />;
|
||||||
return <RequireAuth><Suspense fallback={fallback}><UserLayout><Outlet /></UserLayout></Suspense></RequireAuth>;
|
return <RequireAuth allowAnonymous={isGuestWorkspacePath(pathname)}><Suspense fallback={fallback}><UserLayout><Outlet /></UserLayout></Suspense></RequireAuth>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +105,8 @@ export const router = createBrowserRouter([
|
|||||||
element: <AuthenticatedWorkspaceLayout />,
|
element: <AuthenticatedWorkspaceLayout />,
|
||||||
errorElement: <RouteErrorPage />,
|
errorElement: <RouteErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{ path: "/", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
|
{ path: "/", element: deferred(<CreatePage />) },
|
||||||
{ path: "/create", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
|
{ path: "/create", element: deferred(<CreatePage />) },
|
||||||
{
|
{
|
||||||
path: "/tasks",
|
path: "/tasks",
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
13
web/test/guest-workspace-access.test.ts
Normal file
13
web/test/guest-workspace-access.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { isGuestWorkspacePath } from "../src/lib/guest-workspace";
|
||||||
|
|
||||||
|
describe("guest workspace route access", () => {
|
||||||
|
test("allows only the public creation workspace routes", () => {
|
||||||
|
expect(isGuestWorkspacePath("/")).toBe(true);
|
||||||
|
expect(isGuestWorkspacePath("/create")).toBe(true);
|
||||||
|
expect(isGuestWorkspacePath("/assets")).toBe(false);
|
||||||
|
expect(isGuestWorkspacePath("/admin")).toBe(false);
|
||||||
|
expect(isGuestWorkspacePath("/create/anything")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user