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 */}
+
+
+ {/* 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);
+};