diff --git a/apps/web/app/api/desktop/session/route.ts b/apps/web/app/api/desktop/session/route.ts new file mode 100644 index 00000000..339d97d4 --- /dev/null +++ b/apps/web/app/api/desktop/session/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Desktop Session API + * + * 参考:Cap/apps/web/app/api/desktop/[...route]/session.ts + * + * 流程: + * 1. Desktop 打开这个 URL(带 port 或 platform 参数) + * 2. 直接重定向到 /login?next=当前URL + * 3. 用户登录后,login 页面会重定向到 Desktop 回调 + * + * 注意:Web 端不做任何 SID 缓存,每次都要重新登录 + */ +export async function GET(request: NextRequest) { + // Build current URL for next parameter + const currentUrl = request.nextUrl.toString(); + + // Always redirect to login page - no caching, always require fresh login + const loginUrl = new URL('/login', request.nextUrl.origin); + loginUrl.searchParams.set('next', currentUrl); + return NextResponse.redirect(loginUrl); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 660b3dbc..d7704893 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,6 +4,7 @@ import "@multica/ui/globals.css"; import { ThemeProvider } from "@multica/ui/components/theme-provider"; import { Toaster } from "@multica/ui/components/ui/sonner"; import { ServiceWorkerRegister } from "./sw-register"; +import { Providers } from "./providers"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -41,14 +42,16 @@ export default function RootLayout({ - -
{children}
-
+ + +
{children}
+
+
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 00000000..4df72a22 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,319 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import { useGoogleLogin } from '@react-oauth/google' +import { Button } from '@multica/ui/components/ui/button' +import { Input } from '@multica/ui/components/ui/input' +import { Label } from '@multica/ui/components/ui/label' +import { MulticaIcon } from '@multica/ui/components/multica-icon' +import { LoginAuthType, UserInfo } from '@/lib/interface' +import { userLogin } from '@/service/user' + +type LoginStep = 'email' | 'code' + +function GoogleIcon() { + return ( + + + + + + + ) +} + +export default function LoginPage() { + const searchParams = useSearchParams() + const next = searchParams.get('next') + + // Form state + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [step, setStep] = useState('email') + + // Loading state + const [isSendingCode, setIsSendingCode] = useState(false) + const [isLoggingIn, setIsLoggingIn] = useState(false) + const [error, setError] = useState(null) + + // Countdown state + const [countdown, setCountdown] = useState(0) + + // Google login + const googleLogin = useGoogleLogin({ + onSuccess: async (tokenResponse) => { + setError(null) + setIsLoggingIn(true) + try { + const res = await userLogin({ + authType: LoginAuthType.Google, + googleToken: tokenResponse.access_token, + }) + + const { sid, user, account } = res + + if (!sid) { + throw new Error('No session ID returned') + } + + handleLoginSuccess(sid, { + ...user, + email: account?.email, + }) + } catch (err) { + setError('Google login failed. Please try again.') + console.error(err) + } finally { + setIsLoggingIn(false) + } + }, + onError: () => { + setError('Google login failed. Please try again.') + }, + }) + + // Countdown timer + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000) + return () => clearTimeout(timer) + } + }, [countdown]) + + // Handle login success + const handleLoginSuccess = (sid: string, user: UserInfo) => { + if (next) { + // Desktop flow - parse next URL to get port, then redirect directly to callback + try { + const nextUrl = new URL(next, window.location.origin) + const port = nextUrl.searchParams.get('port') + const platform = nextUrl.searchParams.get('platform') || 'web' + + const params = new URLSearchParams({ + sid, + user: JSON.stringify(user), + }) + + if (platform === 'web' && port) { + // Dev mode: redirect to local server + window.location.href = `http://127.0.0.1:${port}/callback?${params}` + } else { + // Production mode: redirect to deep link + window.location.href = `multica://auth?${params}` + } + } catch { + // Fallback: just go to next URL + window.location.href = next + } + } else { + // No next parameter - normal web login, go to home + window.location.href = '/' + } + } + + // Send verification code + const handleSendCode = async () => { + if (!email || !email.includes('@')) { + setError('Please enter a valid email address') + return + } + + setError(null) + setIsSendingCode(true) + + try { + await userLogin({ + authType: LoginAuthType.SendCode, + email, + }) + setStep('code') + setCountdown(60) + } catch (err) { + setError('Failed to send verification code') + console.error(err) + } finally { + setIsSendingCode(false) + } + } + + // Verify code and login + const handleLogin = async () => { + if (!code || code.length < 4) { + setError('Please enter the verification code') + return + } + + setError(null) + setIsLoggingIn(true) + + try { + const res = await userLogin({ + authType: LoginAuthType.VerifyCode, + email, + verificationCode: code, + }) + + const { sid, user, account } = res + + if (!sid) { + throw new Error('No session ID returned') + } + + handleLoginSuccess(sid, { + ...user, + email: account?.email || email, + }) + } catch (err) { + setError('Invalid or expired verification code') + console.error(err) + } finally { + setIsLoggingIn(false) + } + } + + return ( +
+
+ {/* Logo and Header */} +
+
+ + Multica +
+
+

Sign in

+

+ Enter your email to continue +

+
+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Login form */} +
+ {/* Email input */} +
+ + setEmail(e.target.value)} + disabled={step === 'code'} + onKeyDown={(e) => { + if (e.key === 'Enter' && step === 'email') { + handleSendCode() + } + }} + /> +
+ + {/* Verification code input (shown in step 2) */} + {step === 'code' && ( +
+ +
+ setCode(e.target.value)} + maxLength={6} + className="font-mono tracking-widest" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleLogin() + } + }} + /> + +
+
+ )} + + {/* Action buttons */} + {step === 'email' ? ( + + ) : ( +
+ + +
+ )} +
+ + {/* Divider */} +
+
+ +
+
+ or +
+
+ + {/* Google Login */} + +
+
+ ) +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 00000000..c8a9530b --- /dev/null +++ b/apps/web/app/providers.tsx @@ -0,0 +1,15 @@ +'use client' + +import { GoogleOAuthProvider } from '@react-oauth/google' + +// Google OAuth Client ID +// TODO: Move to environment variable +const GOOGLE_CLIENT_ID = '69015293368-pg96qdahu57g8nb0oi1abv2j3qbqrshq.apps.googleusercontent.com' + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/lib/constant.ts b/apps/web/lib/constant.ts new file mode 100644 index 00000000..692db158 --- /dev/null +++ b/apps/web/lib/constant.ts @@ -0,0 +1,2 @@ +// API Host +export const API_HOST = process.env.NEXT_PUBLIC_API_HOST || ''; diff --git a/apps/web/lib/device.ts b/apps/web/lib/device.ts new file mode 100644 index 00000000..a19ceabc --- /dev/null +++ b/apps/web/lib/device.ts @@ -0,0 +1,36 @@ +const DEVICE_ID = 'MULTICA_DEVICE_ID'; + +// SHA-256 hash function (using Web Crypto API) +async function sha256(text: string): Promise { + const buffer = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +// Generate Device-Id header +export async function generateDeviceIdHeader(deviceId: string): Promise { + // First hash, take first 32 chars + const hash1 = await sha256(deviceId); + const hashedDeviceId = hash1.slice(0, 32); + + // Second hash, take first 8 chars + const hash2 = await sha256(hashedDeviceId); + const finalDeviceId = hash2.slice(0, 8) + hashedDeviceId; + + return finalDeviceId; +} + +// Get or create Device ID +export function getOrCreateDeviceId(): string { + if (typeof window === 'undefined') return ''; + + let deviceId = localStorage.getItem(DEVICE_ID); + + if (!deviceId) { + deviceId = crypto.randomUUID(); + localStorage.setItem(DEVICE_ID, deviceId); + } + + return deviceId; +} diff --git a/apps/web/lib/interface.ts b/apps/web/lib/interface.ts new file mode 100644 index 00000000..4dd42ded --- /dev/null +++ b/apps/web/lib/interface.ts @@ -0,0 +1,37 @@ +// User info type +export interface UserInfo { + uid: string; + name: string; + email?: string; + icon: string; + vip: number; +} + +// Login auth type (from SceneSpeak) +export enum LoginAuthType { + SendCode = 1, // 发送验证码 + Google = 3, // Google AccessToken + VerifyCode = 8, // 验证码登录 +} + +// Login response type +export interface LoginResponse { + authType: number; + sid: string; + pToken: string; + message: string; + account: { + uid: string; + type: number; + email: string; + googleId?: string; + createdTime: number; + newAccount: boolean; + }; + user: UserInfo & { + status: number; + extra?: unknown; + createdTime: number; + customerId?: string; + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 1141dec3..9a10a8b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3001 --experimental-https", + "dev": "next dev --port 3000", "build": "next build", "start": "next start", "lint": "eslint" @@ -13,6 +13,7 @@ "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", + "@react-oauth/google": "^0.12.1", "next": "16.1.6", "next-themes": "^0.4.6", "react": "catalog:", diff --git a/apps/web/service/request.ts b/apps/web/service/request.ts new file mode 100644 index 00000000..4a2ef598 --- /dev/null +++ b/apps/web/service/request.ts @@ -0,0 +1,70 @@ +import { API_HOST } from '@/lib/constant'; +import { generateDeviceIdHeader, getOrCreateDeviceId } from '@/lib/device'; + +// Fetch request wrapper +export async function request(url: string, options: RequestInit = {}): Promise { + // Get or generate Device ID + let deviceIdHeader = ''; + if (typeof window !== 'undefined') { + const deviceId = getOrCreateDeviceId(); + deviceIdHeader = await generateDeviceIdHeader(deviceId); + } + + const config: RequestInit = { + ...options, + headers: { + 'Content-Type': 'application/json', + 'os-type': '3', + ...(deviceIdHeader && { 'Device-Id': deviceIdHeader }), + ...options.headers, + }, + }; + + const response = await fetch(`${API_HOST}${url}`, config); + + let data: T; + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + data = await response.json(); + } else { + const text = await response.text(); + data = { message: text || response.statusText } as T; + } + + if (!response.ok) { + console.error('API Error:', { + status: response.status, + url, + data, + }); + throw new Error( + (data as { errMsg?: string; message?: string })?.errMsg || + (data as { message?: string })?.message || + `Request failed with status ${response.status}` + ); + } + + return data; +} + +// GET request +export function get(url: string, params?: Record) { + const filteredParams = params + ? Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined && v !== null) + ) + : undefined; + const queryString = + filteredParams && Object.keys(filteredParams).length > 0 + ? `?${new URLSearchParams(filteredParams as Record).toString()}` + : ''; + return request(url + queryString, { method: 'GET' }); +} + +// POST request +export function post(url: string, data?: unknown) { + return request(url, { + method: 'POST', + body: JSON.stringify(data), + }); +} diff --git a/apps/web/service/user.ts b/apps/web/service/user.ts new file mode 100644 index 00000000..c028dbd9 --- /dev/null +++ b/apps/web/service/user.ts @@ -0,0 +1,12 @@ +import type { LoginResponse } from '@/lib/interface'; +import { post } from './request'; + +// User login +export const userLogin = async (params: { + authType: number; + googleToken?: string; + email?: string; + verificationCode?: string; +}) => { + return post('/api/v1/auth/login', params); +};