diff --git a/.vscode/.debug.script.mjs b/.vscode/.debug.script.mjs index 9ca93363c..ecdda576d 100644 --- a/.vscode/.debug.script.mjs +++ b/.vscode/.debug.script.mjs @@ -14,10 +14,22 @@ fs.writeFileSync(path.join(__dirname, '.debug.env'), envContent.join('\n')) // bootstrap spawn( // TODO: terminate `npm run dev` when Debug exits. - process.platform === 'win32' ? 'npm.cmd' : 'npm', + 'npm', ['run', 'dev'], { + shell: true, stdio: 'inherit', - env: Object.assign(process.env, { VSCODE_DEBUG: 'true' }), + // On Windows, Node's spawn can throw EINVAL if the env object contains + // special keys that start with '=' (e.g. '=C:'). Filter those out. + env: (() => { + const env = {} + for (const [key, val] of Object.entries(process.env)) { + if (!key || key.startsWith('=') || key.includes('\0')) continue + if (typeof val !== 'string' || val.includes('\0')) continue + env[key] = val + } + env.VSCODE_DEBUG = 'true' + return env + })(), }, ) \ No newline at end of file diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index a6bfb57c0..9d348e776 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -15,6 +15,7 @@ import { proxyFetchPost } from '@/api/http'; import { hasStackKeys } from '@/lib'; import { useTranslation } from 'react-i18next'; import WindowControls from '@/components/WindowControls'; +import { loginByStackWithAutoCreate } from '@/service/stackAuthApi'; const HAS_STACK_KEYS = hasStackKeys(); let lock = false; @@ -135,7 +136,19 @@ export default function Login() { return; } - setAuth({ email: formData.email, ...data }); + const token = (data as any)?.token as string | undefined; + const email = ((data as any)?.email as string | undefined) ?? formData.email; + if (!token) { + setGeneralError(t('layout.login-failed-please-try-again')); + return; + } + + setAuth({ + token, + email, + username: (data as any)?.username ?? null, + user_id: (data as any)?.user_id ?? null, + }); setModelType('cloud'); // Record VITE_USE_LOCAL_PROXY value at login const localProxyValue = import.meta.env.VITE_USE_LOCAL_PROXY || null; @@ -153,9 +166,9 @@ export default function Login() { const handleLoginByStack = async (token: string) => { try { - const data = await proxyFetchPost('/api/login-by_stack?token=' + token, { - token: token, - }); + // 1) Try normal login (existing profile) + // 2) If not found, auto-create profile via signup and continue + const data = await loginByStackWithAutoCreate(token); const errorMessage = getLoginErrorMessage(data); if (errorMessage) { @@ -164,7 +177,20 @@ export default function Login() { } console.log('data', data); setModelType('cloud'); - setAuth({ email: formData.email, ...data }); + + const authToken = (data as any)?.token as string | undefined; + const email = ((data as any)?.email as string | undefined) ?? formData.email; + if (!authToken) { + setGeneralError(t('layout.login-failed-please-try-again')); + return; + } + + setAuth({ + token: authToken, + email, + username: (data as any)?.username ?? null, + user_id: (data as any)?.user_id ?? null, + }); // Record VITE_USE_LOCAL_PROXY value at login const localProxyValue = import.meta.env.VITE_USE_LOCAL_PROXY || null; setLocalProxyValue(localProxyValue); @@ -181,6 +207,11 @@ export default function Login() { const handleReloadBtn = async (type: string) => { if (!app) { + // Keep the buttons visible so users discover the option, but make it + // clear when Stack OAuth isn't configured for local builds. + setGeneralError( + 'Social sign-in is not configured for this build. Set VITE_STACK_PROJECT_ID, VITE_STACK_PUBLISHABLE_CLIENT_KEY, and VITE_STACK_SECRET_SERVER_KEY.' + ); console.error('Stack app not initialized'); return; } @@ -245,7 +276,12 @@ export default function Login() { lock = true; setIsLoading(true); - let accessToken = await handleGetToken(code); + const accessToken = await handleGetToken(code); + if (!accessToken) { + setGeneralError(t('layout.login-failed-please-try-again')); + setIsLoading(false); + return; + } handleLoginByStack(accessToken); setTimeout(() => { lock = false; @@ -360,39 +396,31 @@ export default function Login() { {t('layout.sign-up')} - {HAS_STACK_KEYS && ( -
- - -
- )} - {HAS_STACK_KEYS && ( -
- {t('layout.or')} -
- )} +
+ + +
+
+ {t('layout.or')} +
{generalError && (

diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx index 0f589a631..0fb4b25df 100644 --- a/src/pages/SignUp.tsx +++ b/src/pages/SignUp.tsx @@ -14,6 +14,7 @@ import eyeOff from "@/assets/eye-off.svg"; import { proxyFetchPost } from "@/api/http"; import { hasStackKeys } from "@/lib"; import { useTranslation } from "react-i18next"; +import { loginByStackToken } from "@/service/stackAuthApi"; const HAS_STACK_KEYS = hasStackKeys(); let lock = false; @@ -129,9 +130,15 @@ export default function SignUp() { const handleLoginByStack = async (token: string) => { try { - const data = await proxyFetchPost("/api/login-by_stack?token=" + token, { - token: token, - invite_code: localStorage.getItem("invite_code") || "", + if (!token) { + setGeneralError(t("layout.login-failed-please-try-again")); + return; + } + const inviteCode = localStorage.getItem("invite_code") || ""; + const data = await loginByStackToken({ + token, + type: "signup", + inviteCode: inviteCode || undefined, }); if (data.code === 10) { @@ -139,7 +146,20 @@ export default function SignUp() { return; } console.log("data", data); - setAuth({ email: formData.email, ...data }); + + const authToken = (data as any)?.token as string | undefined; + const email = ((data as any)?.email as string | undefined) ?? formData.email; + if (!authToken) { + setGeneralError(t("layout.login-failed-please-try-again")); + return; + } + + setAuth({ + token: authToken, + email, + username: (data as any)?.username ?? null, + user_id: (data as any)?.user_id ?? null, + }); navigate("/"); } catch (error: any) { console.error("Login failed:", error); @@ -213,6 +233,11 @@ export default function SignUp() { lock = true; setIsLoading(true); const accessToken = await handleGetToken(code); + if (!accessToken) { + setGeneralError(t("layout.login-failed-please-try-again")); + setIsLoading(false); + return; + } await handleLoginByStack(accessToken); setTimeout(() => { lock = false; diff --git a/src/service/stackAuthApi.ts b/src/service/stackAuthApi.ts new file mode 100644 index 000000000..3f40b57f1 --- /dev/null +++ b/src/service/stackAuthApi.ts @@ -0,0 +1,45 @@ +import { proxyFetchPost } from '@/api/http' + +export type StackAuthFlowType = 'login' | 'signup' + +type StackLoginResponse = { + code?: number + text?: string + [key: string]: any +} + +function isUserNotFoundResponse(res: StackLoginResponse | null | undefined): boolean { + if (!res || typeof res !== 'object') return false + if (res.code !== 1) return false + const text = String(res.text ?? '').toLowerCase() + return text.includes('user not found') +} + +export async function loginByStackToken(params: { + token: string + type: StackAuthFlowType + inviteCode?: string +}): Promise { + const searchParams = new URLSearchParams() + searchParams.set('token', params.token) + searchParams.set('type', params.type) + if (params.inviteCode) { + searchParams.set('invite_code', params.inviteCode) + } + + // Endpoint is defined as POST, but consumes query params. + return proxyFetchPost(`/api/login-by_stack?${searchParams.toString()}`, { + token: params.token, + invite_code: params.inviteCode ?? '', + }) +} + +/** + * Attempts a passwordless SSO login first, and auto-creates the user if not found. + * This matches the UX request: “check existing profile; if missing, create like signup”. + */ +export async function loginByStackWithAutoCreate(token: string): Promise { + const loginRes = await loginByStackToken({ token, type: 'login' }) + if (!isUserNotFoundResponse(loginRes)) return loginRes + return loginByStackToken({ token, type: 'signup' }) +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 9e3aa6297..4bc55b44d 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -9,9 +9,9 @@ type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-3-p // auth info interface interface AuthInfo { token: string; - username: string; + username?: string | null; email: string; - user_id: number; + user_id?: number | null; } // auth state interface @@ -84,7 +84,7 @@ const authStore = create()( // auth related methods setAuth: ({ token, username, email, user_id }) => - set({ token, username, email, user_id }), + set({ token, email, username: username ?? null, user_id: user_id ?? null }), logout: () => set({ diff --git a/test/unit/service/stackAuthApi.test.ts b/test/unit/service/stackAuthApi.test.ts new file mode 100644 index 000000000..6402a53e4 --- /dev/null +++ b/test/unit/service/stackAuthApi.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { loginByStackWithAutoCreate, loginByStackToken } from '../../../src/service/stackAuthApi' + +vi.mock('@/api/http', () => ({ + proxyFetchPost: vi.fn(), +})) + +describe('stackAuthApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back to signup when login returns user not found', async () => { + const { proxyFetchPost } = await import('@/api/http') + + vi.mocked(proxyFetchPost) + .mockResolvedValueOnce({ code: 1, text: 'User not found' }) + .mockResolvedValueOnce({ code: 0, token: 't', email: 'e@example.com' }) + + const res = await loginByStackWithAutoCreate('stack-token') + + expect(res.code).toBe(0) + expect(vi.mocked(proxyFetchPost)).toHaveBeenCalledTimes(2) + + const firstUrl = vi.mocked(proxyFetchPost).mock.calls[0][0] as string + const secondUrl = vi.mocked(proxyFetchPost).mock.calls[1][0] as string + + expect(firstUrl).toContain('/api/login-by_stack?') + expect(firstUrl).toContain('type=login') + expect(secondUrl).toContain('type=signup') + }) + + it('includes invite_code in query when provided', async () => { + const { proxyFetchPost } = await import('@/api/http') + + vi.mocked(proxyFetchPost).mockResolvedValueOnce({ code: 0 }) + + await loginByStackToken({ token: 'stack-token', type: 'signup', inviteCode: 'INV123' }) + + const url = vi.mocked(proxyFetchPost).mock.calls[0][0] as string + expect(url).toContain('invite_code=INV123') + }) +})