Fix first-login username flow
这个提交包含在:
@@ -10,23 +10,41 @@ import { Target, Loader2 } from "lucide-react";
|
|||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const loginMutation = trpc.auth.loginWithUsername.useMutation({
|
const utils = trpc.useUtils();
|
||||||
onSuccess: (data) => {
|
const loginMutation = trpc.auth.loginWithUsername.useMutation();
|
||||||
toast.success(data.isNew ? `欢迎加入,${data.user.name}!` : `欢迎回来,${data.user.name}!`);
|
|
||||||
setLocation("/dashboard");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error("登录失败: " + err.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogin = (e: React.FormEvent) => {
|
const syncAuthenticatedUser = async (fallbackUser: Awaited<ReturnType<typeof loginMutation.mutateAsync>>["user"]) => {
|
||||||
|
// Seed the cache immediately so protected routes do not bounce back to /login.
|
||||||
|
utils.auth.me.setData(undefined, fallbackUser);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const user = await utils.auth.me.fetch();
|
||||||
|
if (user) {
|
||||||
|
utils.auth.me.setData(undefined, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => window.setTimeout(resolve, 120 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username.trim()) {
|
if (!username.trim()) {
|
||||||
toast.error("请输入用户名");
|
toast.error("请输入用户名");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loginMutation.mutate({ username: username.trim() });
|
|
||||||
|
try {
|
||||||
|
const data = await loginMutation.mutateAsync({ username: username.trim() });
|
||||||
|
const user = await syncAuthenticatedUser(data.user);
|
||||||
|
toast.success(data.isNew ? `欢迎加入,${user.name}!` : `欢迎回来,${user.name}!`);
|
||||||
|
setLocation("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "未知错误";
|
||||||
|
toast.error("登录失败: " + message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { installAppMocks } from "./helpers/mockApp";
|
import { installAppMocks } from "./helpers/mockApp";
|
||||||
|
|
||||||
test("login redirects into dashboard with mocked auth", async ({ page }) => {
|
test("login redirects into dashboard with mocked auth", async ({ page }) => {
|
||||||
await installAppMocks(page, { authenticated: false });
|
await installAppMocks(page, {
|
||||||
|
authenticated: false,
|
||||||
|
authMeNullResponsesAfterLogin: 1,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByTestId("login-title")).toBeVisible();
|
await expect(page.getByTestId("login-title")).toBeVisible();
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type MockAppState = {
|
|||||||
analyses: any[];
|
analyses: any[];
|
||||||
mediaSession: MockMediaSession | null;
|
mediaSession: MockMediaSession | null;
|
||||||
nextVideoId: number;
|
nextVideoId: number;
|
||||||
|
authMeNullResponsesAfterLogin: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function trpcResult(json: unknown) {
|
function trpcResult(json: unknown) {
|
||||||
@@ -154,6 +155,10 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
|||||||
const results = operations.map((operation) => {
|
const results = operations.map((operation) => {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
case "auth.me":
|
case "auth.me":
|
||||||
|
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
|
||||||
|
state.authMeNullResponsesAfterLogin -= 1;
|
||||||
|
return trpcResult(null);
|
||||||
|
}
|
||||||
return trpcResult(state.authenticated ? state.user : null);
|
return trpcResult(state.authenticated ? state.user : null);
|
||||||
case "auth.loginWithUsername":
|
case "auth.loginWithUsername":
|
||||||
state.authenticated = true;
|
state.authenticated = true;
|
||||||
@@ -282,6 +287,7 @@ export async function installAppMocks(
|
|||||||
videos?: any[];
|
videos?: any[];
|
||||||
analyses?: any[];
|
analyses?: any[];
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
authMeNullResponsesAfterLogin?: number;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const state: MockAppState = {
|
const state: MockAppState = {
|
||||||
@@ -312,6 +318,7 @@ export async function installAppMocks(
|
|||||||
],
|
],
|
||||||
mediaSession: null,
|
mediaSession: null,
|
||||||
nextVideoId: 100,
|
nextVideoId: 100,
|
||||||
|
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户