200 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|