feat(web): integrate Better Auth with sign-in/sign-up pages

Web app integration:
- /api/auth/[...all] route exposes Better Auth handler
- src/lib/auth.ts: server helper getServerSession() reading cookies via headers()
- src/lib/auth-client.ts: browser auth client built on shared makeAuthClient
- (auth) route group with sign-in and sign-up pages, both with email+password
  form and "Continua con Google" button (Google flow active when env is set)
- Home page becomes async, shows signed-in user name with sign-out button or
  routes to sign-up/sign-in CTAs
- Italian copy in messages/it.json under Auth.SignIn / Auth.SignUp
- transpilePackages includes @ketopath/auth
- .env.example: BETTER_AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URL, Google placeholders

Build tooling:
- Drop .js extensions from internal package imports so Next webpack can
  transpile them; switch packages/db tsconfig from NodeNext to Bundler
  resolution to keep TS happy (same as packages/auth)

Verified end-to-end via browser:
- /sign-up creates user (Postgres rows in users + accounts), session cookie set,
  redirect to / shows "Accesso effettuato come Mario Test"
- /sign-out clears session, page falls back to anonymous CTAs
- /sign-in with the same credentials restores session

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 13:01:59 +02:00
1 parent 31eea207ba
commit 0139b13fc6
18 files changed
+410 -26

No files matched your search

@@ -0,0 +1,19 @@
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { SignInForm } from './sign-in-form';
export default function SignInPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const t = useTranslations('Auth.SignIn');
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-8 px-6 py-16">
<header className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-slate-600">{t('subtitle')}</p>
</header>
<SignInForm />
</main>
);
}
@@ -0,0 +1,113 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react';
import { authClient } from '@/lib/auth-client';
export function SignInForm() {
const t = useTranslations('Auth.SignIn');
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
const { error: signInError } = await authClient.signIn.email({ email, password });
if (signInError) {
setError(signInError.message ?? t('error'));
setPending(false);
return;
}
router.push('/');
router.refresh();
}
async function handleGoogle() {
setError(null);
const { error: googleError } = await authClient.signIn.social({
provider: 'google',
callbackURL: '/',
});
if (googleError) {
setError(googleError.message ?? t('error'));
}
}
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('emailLabel')}</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('passwordLabel')}</span>
<input
type="password"
required
autoComplete="current-password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</label>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<button
type="submit"
disabled={pending}
className="w-full rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 disabled:opacity-60"
>
{pending ? t('submitting') : t('submit')}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-xs uppercase tracking-wider text-slate-500">
{t('or')}
</span>
</div>
</div>
<button
type="button"
onClick={handleGoogle}
className="w-full rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
{t('google')}
</button>
<p className="text-center text-sm text-slate-600">
{t.rich('noAccount', {
link: (chunks) => (
<Link href="/sign-up" className="font-medium text-emerald-700 hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
);
}
@@ -0,0 +1,20 @@
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { SignUpForm } from './sign-up-form';
export default function SignUpPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const t = useTranslations('Auth.SignUp');
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-8 px-6 py-16">
<header className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-slate-600">{t('subtitle')}</p>
</header>
<SignUpForm />
<p className="text-xs text-slate-500">{t('disclaimer')}</p>
</main>
);
}
@@ -0,0 +1,126 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react';
import { authClient } from '@/lib/auth-client';
export function SignUpForm() {
const t = useTranslations('Auth.SignUp');
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
const { error: signUpError } = await authClient.signUp.email({ email, password, name });
if (signUpError) {
setError(signUpError.message ?? t('error'));
setPending(false);
return;
}
router.push('/');
router.refresh();
}
async function handleGoogle() {
setError(null);
const { error: googleError } = await authClient.signIn.social({
provider: 'google',
callbackURL: '/',
});
if (googleError) {
setError(googleError.message ?? t('error'));
}
}
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('nameLabel')}</span>
<input
type="text"
required
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('emailLabel')}</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('passwordLabel')}</span>
<input
type="password"
required
autoComplete="new-password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<span className="block text-xs text-slate-500">{t('passwordHint')}</span>
</label>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<button
type="submit"
disabled={pending}
className="w-full rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 disabled:opacity-60"
>
{pending ? t('submitting') : t('submit')}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-xs uppercase tracking-wider text-slate-500">
{t('or')}
</span>
</div>
</div>
<button
type="button"
onClick={handleGoogle}
className="w-full rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
{t('google')}
</button>
<p className="text-center text-sm text-slate-600">
{t.rich('haveAccount', {
link: (chunks) => (
<Link href="/sign-in" className="font-medium text-emerald-700 hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
);
}
+33 -15
View File
@@ -1,28 +1,46 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
import { getServerSession } from '@/lib/auth';
import { SignOutButton } from './sign-out-button';
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
return <HomeContent userName={session?.user?.name ?? session?.user?.email ?? null} />;
}
function HomeContent({ userName }: { userName: string | null }) {
const t = useTranslations('Home');
return (
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-start justify-center gap-6 px-6 py-16">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{t('title')}</h1>
<p className="text-lg text-slate-600">{t('tagline')}</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
>
{t('ctaPrimary')}
</button>
<button
type="button"
className="rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
>
{t('ctaSecondary')}
</button>
</div>
{userName ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-700">{t('signedInAs', { name: userName })}</span>
<SignOutButton label={t('signOut')} />
</div>
) : (
<div className="flex flex-wrap gap-3">
<Link
href="/sign-up"
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
>
{t('ctaPrimary')}
</Link>
<Link
href="/sign-in"
className="rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
>
{t('ctaSecondary')}
</Link>
</div>
)}
<p className="mt-8 text-xs text-slate-500">{t('disclaimer')}</p>
</main>
);
@@ -0,0 +1,29 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { authClient } from '@/lib/auth-client';
export function SignOutButton({ label }: { label: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
async function handleClick() {
setPending(true);
await authClient.signOut();
router.push('/');
router.refresh();
}
return (
<button
type="button"
onClick={handleClick}
disabled={pending}
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-60"
>
{label}
</button>
);
}