feat(web): add client-side auth with session storage and route protection
- Add lib/auth.ts for cookie-based session storage - Add AuthGuard component for route protection - Update request.ts to include sid in Authorization header - Update login-form.tsx to save session and redirect if authenticated - Wrap ChatPage with AuthGuard Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
683182e3ac
commit
ae86bba599
5 changed files with 121 additions and 2 deletions
|
|
@ -1,13 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSearchParams, useRouter } 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 { saveSession, isAuthenticated } from '@/lib/auth'
|
||||
import { userLogin } from '@/service/user'
|
||||
|
||||
type LoginStep = 'email' | 'code'
|
||||
|
|
@ -37,8 +38,16 @@ function GoogleIcon() {
|
|||
|
||||
export function LoginForm() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const next = searchParams.get('next')
|
||||
|
||||
// Redirect if already authenticated (and not desktop flow)
|
||||
useEffect(() => {
|
||||
if (!next && isAuthenticated()) {
|
||||
router.replace('/')
|
||||
}
|
||||
}, [next, router])
|
||||
|
||||
// Form state
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
|
|
@ -95,6 +104,9 @@ export function LoginForm() {
|
|||
|
||||
// Handle login success
|
||||
const handleLoginSuccess = (sid: string, user: UserInfo) => {
|
||||
// Save session to cookie for web app
|
||||
saveSession(sid, user)
|
||||
|
||||
if (next) {
|
||||
// Desktop flow - parse next URL to get port, then redirect directly to callback
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import ChatPage from "@/components/pages/chat-page";
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
|
||||
export default function Page() {
|
||||
return <ChatPage />;
|
||||
return (
|
||||
<AuthGuard>
|
||||
<ChatPage />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
38
apps/web/components/auth-guard.tsx
Normal file
38
apps/web/components/auth-guard.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
const [isAuthed, setIsAuthed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
setIsAuthed(true)
|
||||
} else {
|
||||
router.replace('/login')
|
||||
}
|
||||
setIsChecking(false)
|
||||
}, [router])
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
60
apps/web/lib/auth.ts
Normal file
60
apps/web/lib/auth.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Client-side auth utilities
|
||||
* Stores session in cookie for API authentication
|
||||
*/
|
||||
|
||||
import type { UserInfo } from './interface'
|
||||
|
||||
const SID_COOKIE_NAME = 'multica_sid'
|
||||
const USER_COOKIE_NAME = 'multica_user'
|
||||
|
||||
// Cookie helpers
|
||||
function setCookie(name: string, value: string, days = 30) {
|
||||
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
|
||||
return match ? decodeURIComponent(match[2]) : null
|
||||
}
|
||||
|
||||
function deleteCookie(name: string) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
|
||||
}
|
||||
|
||||
// Auth functions
|
||||
export function saveSession(sid: string, user: UserInfo) {
|
||||
setCookie(SID_COOKIE_NAME, sid)
|
||||
setCookie(USER_COOKIE_NAME, JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function getSession(): { sid: string; user: UserInfo } | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const sid = getCookie(SID_COOKIE_NAME)
|
||||
const userJson = getCookie(USER_COOKIE_NAME)
|
||||
|
||||
if (!sid || !userJson) return null
|
||||
|
||||
try {
|
||||
const user = JSON.parse(userJson) as UserInfo
|
||||
return { sid, user }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getSid(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return getCookie(SID_COOKIE_NAME)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
deleteCookie(SID_COOKIE_NAME)
|
||||
deleteCookie(USER_COOKIE_NAME)
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getSid()
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
import { API_HOST } from '@/lib/constant';
|
||||
import { generateDeviceIdHeader, getOrCreateDeviceId } from '@/lib/device';
|
||||
import { getSid } from '@/lib/auth';
|
||||
|
||||
// Fetch request wrapper
|
||||
export async function request<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
// Get or generate Device ID
|
||||
let deviceIdHeader = '';
|
||||
let sid: string | null = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
deviceIdHeader = await generateDeviceIdHeader(deviceId);
|
||||
sid = getSid();
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
|
|
@ -16,6 +19,7 @@ export async function request<T = unknown>(url: string, options: RequestInit = {
|
|||
'Content-Type': 'application/json',
|
||||
'os-type': '3',
|
||||
...(deviceIdHeader && { 'Device-Id': deviceIdHeader }),
|
||||
...(sid && { 'Authorization': `Bearer ${sid}` }),
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue