feat: implement login and signup functionality with auto-create option in stackAuthApi

This commit is contained in:
Zahid Ullah Khan 2026-01-21 16:41:02 +05:00
parent 21299788c3
commit ca26a7da87
6 changed files with 202 additions and 48 deletions

View file

@ -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
})(),
},
)

View file

@ -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')}
</Button>
</div>
{HAS_STACK_KEYS && (
<div className="w-full pt-6">
<Button
variant="primary"
size="lg"
onClick={() => handleReloadBtn('google')}
className="w-full rounded-[24px] mb-4 transition-all duration-300 ease-in-out text-[#F5F5F5] text-center font-inter text-[15px] font-bold leading-[22px] justify-center"
disabled={isLoading}
>
<img src={google} className="w-5 h-5" />
<span className="ml-2">
{t('layout.continue-with-google-login')}
</span>
</Button>
<Button
variant="primary"
size="lg"
onClick={() => handleReloadBtn('github')}
className="w-full rounded-[24px] mb-4 transition-all duration-300 ease-in-out text-[#F5F5F5] text-center font-inter text-[15px] font-bold leading-[22px] justify-center"
disabled={isLoading}
>
<img src={github2} className="w-5 h-5" />
<span className="ml-2">
{t('layout.continue-with-github-login')}
</span>
</Button>
</div>
)}
{HAS_STACK_KEYS && (
<div className="mt-2 w-full text-[#222] text-center font-inter text-[15px] font-medium leading-[22px] mb-6">
{t('layout.or')}
</div>
)}
<div className="w-full pt-6">
<Button
variant="primary"
size="lg"
onClick={() => handleReloadBtn('google')}
className="w-full rounded-[24px] mb-4 transition-all duration-300 ease-in-out text-[#F5F5F5] text-center font-inter text-[15px] font-bold leading-[22px] justify-center"
disabled={isLoading || !HAS_STACK_KEYS}
>
<img src={google} className="w-5 h-5" />
<span className="ml-2">{t('layout.continue-with-google-login')}</span>
</Button>
<Button
variant="primary"
size="lg"
onClick={() => handleReloadBtn('github')}
className="w-full rounded-[24px] mb-4 transition-all duration-300 ease-in-out text-[#F5F5F5] text-center font-inter text-[15px] font-bold leading-[22px] justify-center"
disabled={isLoading || !HAS_STACK_KEYS}
>
<img src={github2} className="w-5 h-5" />
<span className="ml-2">{t('layout.continue-with-github-login')}</span>
</Button>
</div>
<div className="mt-2 w-full text-[#222] text-center font-inter text-[15px] font-medium leading-[22px] mb-6">
{t('layout.or')}
</div>
<div className="flex flex-col gap-4 w-full">
{generalError && (
<p className="text-text-cuation text-label-md mt-1 mb-4">

View file

@ -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;

View file

@ -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<StackLoginResponse> {
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<StackLoginResponse> {
const loginRes = await loginByStackToken({ token, type: 'login' })
if (!isUserNotFoundResponse(loginRes)) return loginRes
return loginByStackToken({ token, type: 'signup' })
}

View file

@ -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<AuthState>()(
// 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({

View file

@ -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')
})
})