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:
parent
2577475ba6
commit
108cf7ffb0
10 changed files with 527 additions and 9 deletions
23
apps/web/app/api/desktop/session/route.ts
Normal file
23
apps/web/app/api/desktop/session/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
319
apps/web/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
apps/web/app/providers.tsx
Normal file
15
apps/web/app/providers.tsx
Normal 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
2
apps/web/lib/constant.ts
Normal 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
36
apps/web/lib/device.ts
Normal 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
37
apps/web/lib/interface.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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:",
|
||||
|
|
|
|||
70
apps/web/service/request.ts
Normal file
70
apps/web/service/request.ts
Normal 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
12
apps/web/service/user.ts
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue