mission-control/src/app/login/page.tsx

348 lines
13 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState, FormEvent } from 'react'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
interface GoogleCredentialResponse {
credential?: string
}
interface GoogleAccountsIdApi {
initialize(config: {
client_id: string
callback: (response: GoogleCredentialResponse) => void
}): void
prompt(): void
}
interface GoogleApi {
accounts: {
id: GoogleAccountsIdApi
}
}
type LoginRequestBody =
| { username: string; password: string }
| { credential?: string }
type LoginErrorPayload = {
code?: string
error?: string
hint?: string
}
function readLoginErrorPayload(value: unknown): LoginErrorPayload {
if (!value || typeof value !== 'object') return {}
const record = value as Record<string, unknown>
return {
code: typeof record.code === 'string' ? record.code : undefined,
error: typeof record.error === 'string' ? record.error : undefined,
hint: typeof record.hint === 'string' ? record.hint : undefined,
}
}
declare global {
interface Window {
google?: GoogleApi
}
}
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" 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.18A10.96 10.96 0 001 12c0 1.77.42 3.45 1.18 4.93l3.66-2.84z" 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 [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [pendingApproval, setPendingApproval] = useState(false)
const [needsSetup, setNeedsSetup] = useState(false)
const [loading, setLoading] = useState(false)
const [googleLoading, setGoogleLoading] = useState(false)
const [googleReady, setGoogleReady] = useState(false)
const googleCallbackRef = useRef<((response: GoogleCredentialResponse) => void) | null>(null)
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
// Check if first-time setup is needed on page load — auto-redirect to /setup
useEffect(() => {
fetch('/api/setup')
.then((res) => res.json())
.then((data) => {
if (data.needsSetup) {
window.location.href = '/setup'
}
})
.catch(() => {
// Ignore — setup check is best-effort
})
}, [])
const completeLogin = useCallback(async (path: string, body: LoginRequestBody) => {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = readLoginErrorPayload(await res.json().catch(() => null))
if (data.code === 'PENDING_APPROVAL') {
setPendingApproval(true)
setNeedsSetup(false)
setError('')
setLoading(false)
setGoogleLoading(false)
return false
}
if (data.code === 'NO_USERS') {
setNeedsSetup(true)
setError('')
setLoading(false)
setGoogleLoading(false)
return false
}
setError(data.error || 'Login failed')
setPendingApproval(false)
setNeedsSetup(false)
setLoading(false)
setGoogleLoading(false)
return false
}
// Full reload ensures the session cookie is sent on all subsequent requests.
// router.push() + refresh() can race and use stale RSC payloads.
window.location.href = '/'
return true
}, [])
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
// Read DOM values directly to handle browser autofill (which doesn't fire onChange)
const form = e.target as HTMLFormElement
const formUsername = (form.elements.namedItem('username') as HTMLInputElement)?.value || username
const formPassword = (form.elements.namedItem('password') as HTMLInputElement)?.value || password
try {
await completeLogin('/api/auth/login', { username: formUsername, password: formPassword })
} catch {
setError('Network error')
setLoading(false)
}
}
// Initialize Google Sign-In SDK (hidden prompt mode)
useEffect(() => {
if (!googleClientId) return
const onScriptLoad = () => {
if (!window.google) return
googleCallbackRef.current = async (response: GoogleCredentialResponse) => {
setError('')
setGoogleLoading(true)
try {
const ok = await completeLogin('/api/auth/google', { credential: response?.credential })
if (!ok) return
} catch {
setError('Google sign-in failed')
setGoogleLoading(false)
}
}
window.google.accounts.id.initialize({
client_id: googleClientId,
callback: (response: GoogleCredentialResponse) => googleCallbackRef.current?.(response),
})
setGoogleReady(true)
}
const existing = document.querySelector('script[data-google-gsi="1"]') as HTMLScriptElement | null
if (existing) {
if (window.google) onScriptLoad()
return
}
const script = document.createElement('script')
script.src = 'https://accounts.google.com/gsi/client'
script.async = true
script.defer = true
script.setAttribute('data-google-gsi', '1')
script.onload = onScriptLoad
script.onerror = () => setError('Failed to load Google Sign-In')
document.head.appendChild(script)
}, [googleClientId, completeLogin])
const handleGoogleSignIn = () => {
if (!window.google || !googleReady) return
window.google.accounts.id.prompt()
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-lg overflow-hidden bg-background border border-border/50 flex items-center justify-center mb-3">
<Image
src="/brand/mc-logo-128.png"
alt="Mission Control logo"
width={48}
height={48}
className="h-full w-full object-cover"
priority
/>
</div>
<h1 className="text-xl font-semibold text-foreground">Mission Control</h1>
<p className="text-sm text-muted-foreground mt-1">Sign in to continue</p>
</div>
{pendingApproval && (
<div className="mb-4 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 text-center">
<div className="flex justify-center mb-2">
<svg className="w-8 h-8 text-amber-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" />
</svg>
</div>
<div className="text-sm font-medium text-amber-200">Access Request Submitted</div>
<p className="text-xs text-muted-foreground mt-1">
Your request has been sent to an administrator for review. You&apos;ll be able to sign in once approved.
</p>
<Button
onClick={() => { setPendingApproval(false); setError(''); setGoogleLoading(false) }}
variant="ghost"
size="sm"
className="mt-3 text-xs"
>
Try again
</Button>
</div>
)}
{needsSetup && (
<div className="mb-4 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20 text-center">
<div className="flex justify-center mb-2">
<svg className="w-8 h-8 text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div className="text-sm font-medium text-blue-200">No admin account created yet</div>
<p className="text-xs text-muted-foreground mt-1">
Set up your admin account to get started with Mission Control.
</p>
<Button
onClick={() => { window.location.href = '/setup' }}
size="sm"
className="mt-3"
>
Create Admin Account
</Button>
</div>
)}
{error && (
<div role="alert" className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error}
</div>
)}
{/* Google Sign-In button — shown only when client ID is configured */}
{googleClientId && (
<div className={pendingApproval ? 'opacity-50 pointer-events-none' : ''}>
<button
type="button"
onClick={handleGoogleSignIn}
disabled={!googleReady || googleLoading || loading}
className="w-full h-10 flex items-center justify-center gap-3 rounded-lg border border-border bg-white text-[#3c4043] text-sm font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{googleLoading ? (
<>
<div className="w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
Signing in...
</>
) : (
<>
<GoogleIcon className="w-[18px] h-[18px]" />
Sign in with Google
</>
)}
</button>
{!googleReady && (
<p className="text-center text-xs text-muted-foreground mt-2">Loading Google Sign-In...</p>
)}
{/* Divider */}
<div className="my-4 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border" />
</div>
</div>
)}
<form onSubmit={handleSubmit} className={`space-y-4 ${pendingApproval ? 'opacity-50 pointer-events-none' : ''}`}>
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth"
placeholder="Enter username"
autoComplete="username"
autoFocus
required
aria-required="true"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth"
placeholder="Enter password"
autoComplete="current-password"
required
aria-required="true"
/>
</div>
<Button
type="submit"
disabled={loading}
size="lg"
className="w-full rounded-lg"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
Signing in...
</>
) : (
'Sign in'
)}
</Button>
</form>
<p className="text-center text-xs text-muted-foreground mt-6">OpenClaw Agent Orchestration</p>
</div>
</div>
)
}