feat(web): add login page for desktop auth callback

- Add email/code login with verification
- Add Google OAuth login
- Add desktop callback API route
- Add request service with device ID handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-13 12:25:03 +08:00
parent 2577475ba6
commit 108cf7ffb0
10 changed files with 527 additions and 9 deletions

View file

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

View file

@ -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({
<body
className={`${geistMono.variable} ${playfair.variable} font-sans antialiased h-dvh flex flex-col`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="h-dvh overflow-hidden">{children}</div>
</ThemeProvider>
<Providers>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="h-dvh overflow-hidden">{children}</div>
</ThemeProvider>
</Providers>
<Toaster />
<ServiceWorkerRegister />
</body>

319
apps/web/app/login/page.tsx Normal file
View file

@ -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 (
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)
}
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<LoginStep>('email')
// Loading state
const [isSendingCode, setIsSendingCode] = useState(false)
const [isLoggingIn, setIsLoggingIn] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-8">
<div className="w-full max-w-sm space-y-6">
{/* Logo and Header */}
<div className="flex flex-col items-center text-center space-y-4">
<div className="flex items-center gap-2">
<MulticaIcon bordered noSpin size="md" />
<span className="text-base font-brand">Multica</span>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="mt-1.5 text-sm text-muted-foreground">
Enter your email to continue
</p>
</div>
</div>
{/* Error message */}
{error && (
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Login form */}
<div className="space-y-4">
{/* Email input */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={step === 'code'}
onKeyDown={(e) => {
if (e.key === 'Enter' && step === 'email') {
handleSendCode()
}
}}
/>
</div>
{/* Verification code input (shown in step 2) */}
{step === 'code' && (
<div className="space-y-2">
<Label htmlFor="code">Verification Code</Label>
<div className="flex gap-2">
<Input
id="code"
type="text"
placeholder="Enter code"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
className="font-mono tracking-widest"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleLogin()
}
}}
/>
<Button
variant="outline"
onClick={handleSendCode}
disabled={countdown > 0 || isSendingCode}
className="shrink-0 tabular-nums"
>
{countdown > 0 ? `${countdown}s` : 'Resend'}
</Button>
</div>
</div>
)}
{/* Action buttons */}
{step === 'email' ? (
<Button
onClick={handleSendCode}
disabled={isSendingCode || !email}
className="w-full"
>
{isSendingCode ? 'Sending...' : 'Continue'}
</Button>
) : (
<div className="flex flex-col gap-2">
<Button
onClick={handleLogin}
disabled={isLoggingIn || !code}
className="w-full"
>
{isLoggingIn ? 'Signing in...' : 'Sign In'}
</Button>
<Button
variant="ghost"
onClick={() => {
setStep('email')
setCode('')
setError(null)
}}
className="w-full text-muted-foreground"
size="sm"
>
Use a different email
</Button>
</div>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
{/* Google Login */}
<Button
onClick={() => googleLogin()}
variant="outline"
className="w-full"
disabled={isLoggingIn}
>
<GoogleIcon />
Continue with Google
</Button>
</div>
</div>
)
}

View file

@ -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 (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
{children}
</GoogleOAuthProvider>
)
}

2
apps/web/lib/constant.ts Normal file
View file

@ -0,0 +1,2 @@
// API Host
export const API_HOST = process.env.NEXT_PUBLIC_API_HOST || '';

36
apps/web/lib/device.ts Normal file
View file

@ -0,0 +1,36 @@
const DEVICE_ID = 'MULTICA_DEVICE_ID';
// SHA-256 hash function (using Web Crypto API)
async function sha256(text: string): Promise<string> {
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<string> {
// 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;
}

37
apps/web/lib/interface.ts Normal file
View file

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

View file

@ -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:",

View file

@ -0,0 +1,70 @@
import { API_HOST } from '@/lib/constant';
import { generateDeviceIdHeader, getOrCreateDeviceId } from '@/lib/device';
// Fetch request wrapper
export async function request<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
// 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<T = unknown>(url: string, params?: Record<string, string | number | boolean>) {
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<string, string>).toString()}`
: '';
return request<T>(url + queryString, { method: 'GET' });
}
// POST request
export function post<T = unknown>(url: string, data?: unknown) {
return request<T>(url, {
method: 'POST',
body: JSON.stringify(data),
});
}

12
apps/web/service/user.ts Normal file
View file

@ -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<LoginResponse>('/api/v1/auth/login', params);
};