mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-22 07:03:33 +00:00
feat: implement login and signup functionality with auto-create option in stackAuthApi
This commit is contained in:
parent
21299788c3
commit
ca26a7da87
6 changed files with 202 additions and 48 deletions
16
.vscode/.debug.script.mjs
vendored
16
.vscode/.debug.script.mjs
vendored
|
|
@ -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
|
||||
})(),
|
||||
},
|
||||
)
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
45
src/service/stackAuthApi.ts
Normal file
45
src/service/stackAuthApi.ts
Normal 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' })
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
44
test/unit/service/stackAuthApi.test.ts
Normal file
44
test/unit/service/stackAuthApi.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue