All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA combobox + listbox pattern for language selector, role=switch on glossary toggle, role=status live region on notifications, aria-labels on password toggles, visible-on-focus close buttons, RTL logical positioning (start-*). Trust: third-party Memento promo removed from translate page, sidebar and all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity bars) replaced with real measurements incl. API estimated remaining time; silent download failure now surfaces an error notification. Honesty: fake 100-byte file injections removed (format chips are now informational); cancel-that-doesn't renamed 'Back to start' with hint; Enterprise contact placeholder replaced with contact@wordly.art. i18n: t() no longer returns raw keys (empty string + defaultValue support, ~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/ teams namespaces, glossaries context tab, translate monitor, settings, services, pricing, landing, fileUploader; split-key italic titles replace lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing. Flow: active job persisted across refresh with polling resume (24h TTL); client-side recent-jobs history with review links; review page linked from complete state; settings/services added to dashboard nav; Business/ Enterprise regain glossary access (tier gate unified). Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30 opacity to /45-/55, uppercase tracking reduced, trust footer legible, country flags removed from language switcher, localized dates. Cleanup: 5 orphaned translate components, dead site header/footer, fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles, two-step confirm for cache clear, dead landing footer links. Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD (no regression, -3 warnings), detector 4 -> 3 findings.
210 lines
7.4 KiB
TypeScript
210 lines
7.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import Link from 'next/link';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
|
|
import { GoogleLogin } from '@react-oauth/google';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { useNotification } from '@/components/ui/notification';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { apiClient } from '@/lib/apiClient';
|
|
import { useLogin } from './useLogin';
|
|
import type { GoogleAuthResponse } from './types';
|
|
import { useGoogleConfig } from '@/providers/ClientGoogleProvider';
|
|
|
|
export function LoginForm() {
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [googleLoading, setGoogleLoading] = useState(false);
|
|
|
|
const loginMutation = useLogin();
|
|
const { notify } = useNotification();
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
|
|
|
const { clientId: googleClientId, enabled: googleEnabled } = useGoogleConfig();
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
if (loginMutation.isError && loginMutation.error) {
|
|
notify({
|
|
title: t('login.errorTitle'),
|
|
description: loginMutation.error.message,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}, [loginMutation.isError, loginMutation.error, notify, t]);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
loginMutation.mutate({ email, password });
|
|
};
|
|
|
|
const handleGoogleSuccess = useCallback(async (credentialResponse: { credential?: string }) => {
|
|
if (!credentialResponse.credential) return;
|
|
setGoogleLoading(true);
|
|
try {
|
|
const response = await apiClient.post<{ data: GoogleAuthResponse }>(
|
|
'/api/v1/auth/google',
|
|
{ credential: credentialResponse.credential },
|
|
);
|
|
const { access_token, refresh_token } = response.data;
|
|
queryClient.clear();
|
|
localStorage.setItem('token', access_token);
|
|
localStorage.setItem('refresh_token', refresh_token);
|
|
router.push(redirect);
|
|
} catch {
|
|
notify({
|
|
title: t('login.google.errorGeneric'),
|
|
description: t('login.google.errorFailed'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setGoogleLoading(false);
|
|
}
|
|
}, [redirect, router, notify, t, queryClient]);
|
|
|
|
const handleGoogleError = useCallback(() => {
|
|
notify({
|
|
title: t('login.google.errorGeneric'),
|
|
description: t('login.google.errorFailed'),
|
|
variant: 'destructive',
|
|
});
|
|
}, [notify, t]);
|
|
|
|
return (
|
|
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
|
<CardHeader className="text-center pb-6">
|
|
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg">
|
|
<Languages className="h-6 w-6" />
|
|
</div>
|
|
<span className="text-2xl font-semibold text-foreground">
|
|
{t('auth.brandName')}
|
|
</span>
|
|
</Link>
|
|
|
|
<CardTitle className="text-2xl font-bold">
|
|
{t('login.welcomeBack')}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t('login.signInToContinue')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-6">
|
|
{googleEnabled && googleClientId && (
|
|
<>
|
|
<div className="flex justify-center">
|
|
{googleLoading ? (
|
|
<Button variant="outline" className="w-full" disabled>
|
|
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
|
{t('login.google.connecting')}
|
|
</Button>
|
|
) : (
|
|
<GoogleLogin
|
|
onSuccess={handleGoogleSuccess}
|
|
onError={handleGoogleError}
|
|
text="continue_with"
|
|
shape="rectangular"
|
|
size="large"
|
|
width={380}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<span className="w-full border-t" />
|
|
</div>
|
|
<div className="relative flex justify-center text-xs uppercase">
|
|
<span className="bg-card px-2 text-muted-foreground">
|
|
{t('login.orContinueWith')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t('login.email')}</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder={t('login.emailPlaceholder')}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
leftIcon={<Mail className="h-4 w-4" />}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label htmlFor="password">{t('login.password')}</Label>
|
|
<Link href="/auth/forgot-password" className="text-sm text-primary hover:underline">
|
|
{t('login.forgotPassword')}
|
|
</Link>
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id="password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
placeholder={t('login.passwordPlaceholder')}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
leftIcon={<Lock className="h-4 w-4" />}
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
aria-label={showPassword ? t('register.password.hide') : t('register.password.show')}
|
|
aria-pressed={showPassword}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={loginMutation.isPending || !email || !password}
|
|
className="w-full"
|
|
>
|
|
{loginMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
|
{t('login.signingIn')}
|
|
</>
|
|
) : (
|
|
<>
|
|
{t('login.signIn')}
|
|
<ArrowRight className="ms-2 h-4 w-4" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</form>
|
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
{t('login.noAccount')}{' '}
|
|
<Link href="/auth/register" className="text-primary hover:underline">
|
|
{t('login.signUpFree')}
|
|
</Link>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|