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:
1 parent
31eea207ba
commit
0139b13fc6
18 files changed
+410
-26
No files matched your search
+11
-3
@@ -1,9 +1,17 @@
|
||||
# Next.js
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Clerk (verrà configurato in uno step dedicato)
|
||||
# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
# CLERK_SECRET_KEY=
|
||||
# Better Auth (deve coincidere con apps/api/.env)
|
||||
# Genera un secret a 32+ byte: `openssl rand -base64 32`
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Database (Better Auth usa @ketopath/db, che legge DATABASE_URL)
|
||||
DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public
|
||||
|
||||
# Google OAuth (configurato a fine progetto)
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Sentry (verrà configurato in uno step dedicato)
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
@@ -4,10 +4,41 @@
|
||||
"tagline": "Il tuo percorso personalizzato verso uno stile di vita chetogenico sostenibile.",
|
||||
"ctaPrimary": "Inizia gratis",
|
||||
"ctaSecondary": "Scopri come funziona",
|
||||
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico."
|
||||
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.",
|
||||
"signedInAs": "Accesso effettuato come {name}",
|
||||
"signOut": "Esci"
|
||||
},
|
||||
"Metadata": {
|
||||
"title": "KetoPath — Chetogenica e digiuno intermittente personalizzati",
|
||||
"description": "Web app italiana per la gestione strutturata di chetogenica e digiuno intermittente, con piani adattivi, tracking e lista della spesa automatica."
|
||||
},
|
||||
"Auth": {
|
||||
"SignIn": {
|
||||
"title": "Accedi",
|
||||
"subtitle": "Bentornato. Inserisci le tue credenziali per continuare.",
|
||||
"emailLabel": "Email",
|
||||
"passwordLabel": "Password",
|
||||
"submit": "Accedi",
|
||||
"submitting": "Accesso in corso…",
|
||||
"google": "Accedi con Google",
|
||||
"or": "oppure",
|
||||
"noAccount": "Non hai un account? <link>Registrati</link>",
|
||||
"error": "Credenziali non valide. Riprova."
|
||||
},
|
||||
"SignUp": {
|
||||
"title": "Registrati",
|
||||
"subtitle": "Crea il tuo profilo KetoPath in meno di un minuto.",
|
||||
"nameLabel": "Nome",
|
||||
"emailLabel": "Email",
|
||||
"passwordLabel": "Password",
|
||||
"passwordHint": "Almeno 8 caratteri.",
|
||||
"submit": "Crea account",
|
||||
"submitting": "Creazione in corso…",
|
||||
"google": "Continua con Google",
|
||||
"or": "oppure",
|
||||
"haveAccount": "Hai già un account? <link>Accedi</link>",
|
||||
"error": "Non è stato possibile completare la registrazione. Riprova.",
|
||||
"disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n.ts');
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@ketopath/shared', '@ketopath/ui'],
|
||||
transpilePackages: ['@ketopath/auth', '@ketopath/shared', '@ketopath/ui'],
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ketopath/auth": "workspace:*",
|
||||
"@ketopath/shared": "workspace:*",
|
||||
"@ketopath/ui": "workspace:*",
|
||||
"better-auth": "^1.0.21",
|
||||
"next": "^14.2.5",
|
||||
"next-intl": "^3.17.2",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { auth } from '@ketopath/auth';
|
||||
import { toNextJsHandler } from 'better-auth/next-js';
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { makeAuthClient } from '@ketopath/auth/client';
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
|
||||
|
||||
export const authClient = makeAuthClient(baseURL);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { auth } from '@ketopath/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export async function getServerSession() {
|
||||
return auth.api.getSession({ headers: headers() });
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export { auth, type Auth } from './server.js';
|
||||
export { readAuthEnv, type AuthEnv } from './env.js';
|
||||
export { auth, type Auth } from './server';
|
||||
export { readAuthEnv, type AuthEnv } from './env';
|
||||
@@ -2,7 +2,7 @@ import { prisma } from '@ketopath/db';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
|
||||
import { readAuthEnv } from './env.js';
|
||||
import { readAuthEnv } from './env';
|
||||
|
||||
const env = readAuthEnv();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { prisma } from './client.js';
|
||||
export { prisma } from './client';
|
||||
export {
|
||||
ActivityLevel,
|
||||
CookingTime,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"extends": "@ketopath/tsconfig/node.json",
|
||||
"extends": "@ketopath/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user