MotorSignal-App/website/components/auth-form.tsx

200 lines
8.9 KiB
TypeScript

'use client';
import { FormEvent, useState } from 'react';
import { signInWithPopup } from 'firebase/auth';
import { firebaseAuth, googleProvider } from '../lib/firebase-client';
type AuthMode = 'login' | 'register';
export function AuthForm({ mode }: { mode: AuthMode }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [deviceName, setDeviceName] = useState('Web browser');
const [message, setMessage] = useState('');
const [googleCanceled, setGoogleCanceled] = useState(false);
const [busy, setBusy] = useState(false);
const [googleBusy, setGoogleBusy] = useState(false);
async function handleGoogleSignIn() {
setGoogleBusy(true);
setMessage('');
setGoogleCanceled(false);
try {
const credential = await signInWithPopup(firebaseAuth, googleProvider);
const idToken = await credential.user.getIdToken();
// Exchange firebase idToken with backend to set server session & ops cookies
let response: Response | undefined;
try {
response = await fetch('/api/auth/firebase-login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id_token: idToken }),
});
} catch {
// Fallback to legacy link if available
const legacyResponse = await fetch('/api/account/firebase-link', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id_token: idToken }),
});
if (!legacyResponse.ok) {
throw new Error('Unable to establish a secure session. Please try again.');
}
}
if (response && !response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail ?? 'Unable to establish a secure session. Please try again.');
}
const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account';
window.location.assign(destination);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
if (code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request') {
setGoogleCanceled(true);
setMessage('Google sign-in was canceled. You can try again.');
return;
}
console.error('Google Sign-In Error:', error);
setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.');
} finally {
setGoogleBusy(false);
}
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setMessage('');
setGoogleCanceled(false);
try {
const response = await fetch(`/api/auth/${mode}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password, device_name: deviceName }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.detail ?? 'Authentication failed.');
const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account';
window.location.assign(destination);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Authentication failed.');
} finally {
setBusy(false);
}
}
return (
<div className="w-full space-y-6">
{/* Google Sign-In / Registration Button */}
<button
type="button"
disabled={googleBusy || busy}
onClick={() => void handleGoogleSignIn()}
className="w-full h-12 px-4 rounded-xl bg-surface-container-low hover:bg-surface-container-high border border-outline-variant/40 hover:border-primary-container/60 text-on-surface font-semibold text-sm flex items-center justify-center gap-3 transition-all shadow-sm hover:shadow-[0_0_20px_rgba(0,242,254,0.15)] cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
>
{googleBusy ? (
<div className="w-5 h-5 border-2 border-primary-container border-t-transparent rounded-full animate-spin"></div>
) : (
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24">
<path
fill="#4285F4"
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"
/>
<path
fill="#34A853"
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"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="#EA4335"
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.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
)}
<span>{googleBusy ? 'Connecting to Google...' : mode === 'login' ? 'Sign in with Google' : 'Register with Google'}</span>
</button>
{/* Divider */}
<div className="flex items-center gap-3 my-1">
<div className="flex-1 border-t border-outline-variant/30"></div>
<span className="text-[10px] font-mono tracking-wide text-slate-400 shrink-0">
or continue with email
</span>
<div className="flex-1 border-t border-outline-variant/30"></div>
</div>
{/* Email / Password Form */}
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-1.5 text-left">
<label className="block text-xs font-mono tracking-wide text-on-surface-variant">
Email address
</label>
<input
required
type="email"
autoComplete="email"
placeholder="rider@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-surface-container-low border border-outline-variant/40 focus:border-primary-container focus:ring-1 focus:ring-primary-container text-white text-sm outline-none transition placeholder:text-slate-500"
/>
</div>
<div className="space-y-1.5 text-left">
<label className="block text-xs font-mono tracking-wide text-on-surface-variant">
Password
</label>
<input
required
minLength={12}
type="password"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
placeholder="Min. 12 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-surface-container-low border border-outline-variant/40 focus:border-primary-container focus:ring-1 focus:ring-primary-container text-white text-sm outline-none transition placeholder:text-slate-500"
/>
</div>
{mode === 'register' && (
<div className="space-y-1.5 text-left">
<label className="block text-xs font-mono tracking-wide text-on-surface-variant">
Device Name <span className="text-[10px] text-slate-500 font-normal">(optional)</span>
</label>
<input
maxLength={255}
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-surface-container-low border border-outline-variant/40 focus:border-primary-container focus:ring-1 focus:ring-primary-container text-white text-sm outline-none transition placeholder:text-slate-500"
/>
</div>
)}
{message && (
<div className={`p-3 rounded-xl border text-xs font-mono ${googleCanceled ? 'bg-surface-container-low border-outline-variant/40 text-on-surface-variant' : 'bg-error-container/20 border-error/30 text-error'}`} role={googleCanceled ? 'status' : 'alert'}>
{message}
</div>
)}
<button
disabled={busy || googleBusy}
type="submit"
className="w-full h-11 px-4 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed hover:opacity-95 text-on-primary-container font-headline-sm font-bold text-sm tracking-wide shadow-[0_0_20px_rgba(0,242,254,0.3)] transition-all cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{busy ? (
<>
<div className="w-4 h-4 border-2 border-on-primary-container border-t-transparent rounded-full animate-spin"></div>
<span>Authenticating...</span>
</>
) : (
<span>{mode === 'login' ? 'Sign In' : 'Create Account'}</span>
)}
</button>
</form>
</div>
);
}