fix: login/registo funcional — signIn NextAuth, SessionProvider, NEXTAUTH_SECRET real, error handling

This commit is contained in:
2026-05-27 11:18:16 +01:00
parent 7be9d5131f
commit 2571137bec
4 changed files with 215 additions and 173 deletions

View File

@@ -6,6 +6,7 @@ import { validateAge } from '@/lib/auth/age-validation';
import { sendEmail, buildWelcomeHtml } from '@/lib/email'; import { sendEmail, buildWelcomeHtml } from '@/lib/email';
export async function POST(request: Request) { export async function POST(request: Request) {
try {
const body = await request.json(); const body = await request.json();
const parsed = registerSchema.safeParse(body); const parsed = registerSchema.safeParse(body);
@@ -42,7 +43,7 @@ export async function POST(request: Request) {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
}); });
// Email de boas-vindas (não bloqueia) // Email de boas-vindas (não bloqueia a resposta)
sendEmail({ sendEmail({
to: user.email, to: user.email,
subject: 'Bem-vindo à PetLink 🐾', subject: 'Bem-vindo à PetLink 🐾',
@@ -53,4 +54,18 @@ export async function POST(request: Request) {
{ message: 'Conta criada com sucesso.', userId: user.id }, { message: 'Conta criada com sucesso.', userId: user.id },
{ status: 201 } { status: 201 }
); );
} catch (err: unknown) {
console.error('[POST /api/auth/register]', err);
// Erro de ligação à base de dados — mensagem clara ao utilizador
const msg = err instanceof Error ? err.message : '';
if (msg.includes('P1001') || msg.includes('connect') || msg.includes('ECONNREFUSED')) {
return NextResponse.json(
{ error: 'Base de dados temporariamente indisponível. Tenta mais tarde.' },
{ status: 503 }
);
}
return NextResponse.json({ error: 'Erro interno. Tenta de novo.' }, { status: 500 });
}
} }

View File

@@ -2,20 +2,65 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PawPrint, Eye, EyeOff, Mail, Lock } from 'lucide-react'; import { useRouter } from 'next/navigation';
import type { Metadata } from 'next'; import { signIn } from 'next-auth/react';
import { PawPrint, Eye, EyeOff, Mail, Lock, AlertCircle } from 'lucide-react';
export default function LoginPage() { export default function LoginPage() {
const router = useRouter();
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError('');
setLoading(true); setLoading(true);
// TODO: substituir por NextAuth signIn quando NEXTAUTH_SECRET estiver configurado
setTimeout(() => setLoading(false), 1200); try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Email ou palavra-passe incorrectos. Tenta de novo.');
} else {
router.push('/');
router.refresh();
}
} catch {
setError('Erro de rede. Verifica a tua ligação e tenta de novo.');
} finally {
setLoading(false);
}
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '12px 16px 12px 40px',
background: 'var(--color-bg, var(--cream))',
border: '1.5px solid var(--color-border, var(--parchment))',
borderRadius: '10px',
fontFamily: 'var(--font-body)',
fontSize: '15px',
color: 'var(--color-text, var(--soil))',
outline: 'none',
transition: 'border-color 180ms ease',
minHeight: '48px',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
fontFamily: 'var(--font-accent, monospace)',
fontSize: '11px',
fontWeight: 400,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--color-muted, var(--soil-faint))',
}; };
return ( return (
@@ -23,23 +68,23 @@ export default function LoginPage() {
style={{ style={{
width: '100%', width: '100%',
maxWidth: '420px', maxWidth: '420px',
background: 'var(--color-surface)', background: 'var(--color-surface, var(--linen))',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border, var(--parchment))',
borderRadius: '20px', borderRadius: '20px',
padding: 'clamp(28px, 5vw, 44px)', padding: 'clamp(28px, 5vw, 44px)',
boxShadow: '0 8px 40px rgba(45,27,14,0.08)', boxShadow: '0 8px 40px rgba(45,27,14,0.08)',
}} }}
> >
{/* Brand mark */} {/* Marca */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '28px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '28px' }}>
<PawPrint size={20} style={{ color: 'var(--color-terra)' }} /> <PawPrint size={20} style={{ color: 'var(--color-terra, var(--terra))' }} />
<span <span
style={{ style={{
fontFamily: 'var(--font-playfair, Georgia, serif)', fontFamily: 'var(--font-playfair, Georgia, serif)',
fontStyle: 'italic', fontStyle: 'italic',
fontWeight: 700, fontWeight: 700,
fontSize: '18px', fontSize: '20px',
color: 'var(--color-terra)', color: 'var(--color-terra, var(--terra))',
}} }}
> >
PetLink PetLink
@@ -51,36 +96,56 @@ export default function LoginPage() {
fontFamily: 'var(--font-playfair, Georgia, serif)', fontFamily: 'var(--font-playfair, Georgia, serif)',
fontWeight: 900, fontWeight: 900,
fontSize: '28px', fontSize: '28px',
color: 'var(--color-text)', color: 'var(--color-text, var(--soil))',
marginBottom: '6px', marginBottom: '6px',
lineHeight: 1.15, lineHeight: 1.15,
}} }}
> >
Bem-vindo de volta. Iniciar sessão.
</h1> </h1>
<p style={{ fontFamily: 'var(--font-body)', fontSize: '14px', color: 'var(--color-muted)', marginBottom: '28px' }}> <p
Não tens conta?{' '} style={{
<Link fontFamily: 'var(--font-body)',
href="/auth/register" fontSize: '14px',
style={{ color: 'var(--color-terra)', fontWeight: 500, textDecoration: 'underline', textDecorationColor: 'transparent', transition: 'text-decoration-color 180ms ease' }} color: 'var(--color-muted, var(--soil-mid))',
onMouseEnter={e => ((e.target as HTMLElement).style.textDecorationColor = 'var(--color-terra)')} marginBottom: '28px',
onMouseLeave={e => ((e.target as HTMLElement).style.textDecorationColor = 'transparent')} }}
> >
Criar conta grátis Ainda não tens conta?{' '}
<Link href="/auth/register" style={{ color: 'var(--color-terra, var(--terra))', fontWeight: 500 }}>
Registar
</Link> </Link>
</p> </p>
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> {/* Erro */}
{/* Email field */} {error && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}> <div
<label role="alert"
htmlFor="login-email" style={{
style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--color-muted)' }} display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(196,80,26,0.08)',
border: '1px solid rgba(196,80,26,0.25)',
borderRadius: '10px',
padding: '12px 16px',
marginBottom: '20px',
fontFamily: 'var(--font-body)',
fontSize: '14px',
color: 'var(--color-terra, var(--terra))',
}}
> >
Email <AlertCircle size={16} style={{ flexShrink: 0 }} />
</label> {error}
</div>
)}
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Email */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label htmlFor="login-email" style={labelStyle}>Email</label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted)', pointerEvents: 'none' }} /> <Mail size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted, var(--soil-faint))', pointerEvents: 'none' }} />
<input <input
id="login-email" id="login-email"
type="email" type="email"
@@ -89,83 +154,42 @@ export default function LoginPage() {
placeholder="o.teu@email.pt" placeholder="o.teu@email.pt"
required required
autoComplete="email" autoComplete="email"
style={{ style={inputStyle}
width: '100%', onFocus={e => (e.target.style.borderColor = 'var(--color-terra, var(--terra))')}
padding: '12px 16px 12px 40px', onBlur={e => (e.target.style.borderColor = 'var(--color-border, var(--parchment))')}
background: 'var(--color-bg)',
border: '1.5px solid var(--color-border)',
borderRadius: '10px',
fontFamily: 'var(--font-body)',
fontSize: '15px',
color: 'var(--color-text)',
outline: 'none',
transition: 'border-color 180ms ease',
minHeight: '48px',
}}
onFocus={e => (e.target.style.borderColor = 'var(--color-terra)')}
onBlur={e => (e.target.style.borderColor = 'var(--color-border)')}
/> />
</div> </div>
</div> </div>
{/* Password field */} {/* Password */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label <label htmlFor="login-password" style={labelStyle}>Palavra-passe</label>
htmlFor="login-password"
style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--color-muted)' }}
>
Palavra-passe
</label>
<Link <Link
href="/auth/forgot-password" href="/auth/forgot-password"
style={{ fontFamily: 'var(--font-body)', fontSize: '12px', color: 'var(--color-terra)', textDecoration: 'none' }} style={{ fontFamily: 'var(--font-body)', fontSize: '12px', color: 'var(--color-terra, var(--terra))', textDecoration: 'none' }}
> >
Esqueceste-a? Esqueceste-a?
</Link> </Link>
</div> </div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted)', pointerEvents: 'none' }} /> <Lock size={15} style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-muted, var(--soil-faint))', pointerEvents: 'none' }} />
<input <input
id="login-password" id="login-password"
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
placeholder="••••••••" placeholder="A tua palavra-passe"
required required
autoComplete="current-password" autoComplete="current-password"
style={{ style={{ ...inputStyle, paddingRight: '44px' }}
width: '100%', onFocus={e => (e.target.style.borderColor = 'var(--color-terra, var(--terra))')}
padding: '12px 44px 12px 40px', onBlur={e => (e.target.style.borderColor = 'var(--color-border, var(--parchment))')}
background: 'var(--color-bg)',
border: '1.5px solid var(--color-border)',
borderRadius: '10px',
fontFamily: 'var(--font-body)',
fontSize: '15px',
color: 'var(--color-text)',
outline: 'none',
transition: 'border-color 180ms ease',
minHeight: '48px',
}}
onFocus={e => (e.target.style.borderColor = 'var(--color-terra)')}
onBlur={e => (e.target.style.borderColor = 'var(--color-border)')}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPass(s => !s)} onClick={() => setShowPass(s => !s)}
style={{ style={{ position: 'absolute', right: '12px', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-muted, var(--soil-faint))', padding: '4px', display: 'flex', alignItems: 'center' }}
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-muted)',
padding: '4px',
display: 'flex',
alignItems: 'center',
}}
aria-label={showPass ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe'} aria-label={showPass ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe'}
> >
{showPass ? <EyeOff size={16} /> : <Eye size={16} />} {showPass ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -177,48 +201,43 @@ export default function LoginPage() {
<button <button
id="login-submit" id="login-submit"
type="submit" type="submit"
className="btn-primary"
disabled={loading} disabled={loading}
style={{ style={{
width: '100%', width: '100%',
justifyContent: 'center', padding: '14px 24px',
marginTop: '8px', background: loading ? 'var(--color-muted, var(--soil-faint))' : 'var(--color-terra, var(--terra))',
opacity: loading ? 0.75 : 1, color: 'white',
border: 'none',
borderRadius: '100px',
fontFamily: 'var(--font-body)',
fontWeight: 600,
fontSize: '15px',
cursor: loading ? 'not-allowed' : 'pointer', cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all 200ms ease',
marginTop: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}} }}
aria-label="Entrar na conta PetLink" aria-label="Entrar na conta PetLink"
> >
{loading ? ( {loading ? (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <>
<span style={{ <span style={{ display: 'inline-block', width: '16px', height: '16px', border: '2px solid rgba(255,255,255,0.4)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
width: '14px', height: '14px',
border: '2px solid rgba(255,255,255,0.3)',
borderTopColor: 'white',
borderRadius: '50%',
display: 'inline-block',
animation: 'spin 0.7s linear infinite',
}} />
A entrar A entrar
</span> </>
) : ( ) : 'Iniciar sessão'}
'Entrar'
)}
</button> </button>
</form>
{/* Divider */} {/* Registar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', margin: '20px 0' }}> <p style={{ textAlign: 'center', fontFamily: 'var(--font-body)', fontSize: '13px', color: 'var(--color-muted, var(--soil-mid))', marginTop: '4px' }}>
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} /> Não tens conta?{' '}
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '10px', color: 'var(--color-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>ou</span> <Link href="/auth/register" style={{ color: 'var(--color-terra, var(--terra))', fontWeight: 500 }}>
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} /> Criar conta grátis
</div> </Link>
<p style={{ textAlign: 'center', fontFamily: 'var(--font-body)', fontSize: '12px', color: 'var(--color-muted)', lineHeight: 1.5 }}>
Ao entrar, aceitas os nossos{' '}
<Link href="/terms" style={{ color: 'var(--color-terra)', textDecoration: 'none' }}>Termos de Utilização</Link>
{' '}e a nossa{' '}
<Link href="/privacy" style={{ color: 'var(--color-terra)', textDecoration: 'none' }}>Política de Privacidade</Link>.
</p> </p>
</form>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style> <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Playfair_Display, Lora, Fragment_Mono } from 'next/font/google'; import { Playfair_Display, Lora, Fragment_Mono } from 'next/font/google';
import Script from 'next/script'; import Script from 'next/script';
import Providers from './providers';
import './globals.css'; import './globals.css';
const playfair = Playfair_Display({ const playfair = Playfair_Display({
@@ -73,7 +74,7 @@ export default function RootLayout({
fontFamily: 'var(--font-lora, var(--font-body, Lora, Georgia, serif))', fontFamily: 'var(--font-lora, var(--font-body, Lora, Georgia, serif))',
}} }}
> >
{children} <Providers>{children}</Providers>
</body> </body>
</html> </html>
); );

7
app/providers.tsx Normal file
View File

@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}