348 lines
13 KiB
TypeScript
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'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>
|
|
)
|
|
}
|