From 0139b13fc6600551656006abfe88655f15ba6808 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 13:01:59 +0200 Subject: [PATCH] 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) --- apps/web/.env.example | 14 +- apps/web/messages/it.json | 33 ++++- apps/web/next.config.mjs | 2 +- apps/web/package.json | 2 + .../src/app/[locale]/(auth)/sign-in/page.tsx | 19 +++ .../[locale]/(auth)/sign-in/sign-in-form.tsx | 113 ++++++++++++++++ .../src/app/[locale]/(auth)/sign-up/page.tsx | 20 +++ .../[locale]/(auth)/sign-up/sign-up-form.tsx | 126 ++++++++++++++++++ apps/web/src/app/[locale]/page.tsx | 48 ++++--- apps/web/src/app/[locale]/sign-out-button.tsx | 29 ++++ apps/web/src/app/api/auth/[...all]/route.ts | 4 + apps/web/src/lib/auth-client.ts | 7 + apps/web/src/lib/auth.ts | 6 + packages/auth/src/index.ts | 4 +- packages/auth/src/server.ts | 2 +- packages/db/src/index.ts | 2 +- packages/db/tsconfig.json | 5 +- pnpm-lock.yaml | Bin 217082 -> 217428 bytes 18 files changed, 410 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/[locale]/(auth)/sign-in/page.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/sign-up/page.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx create mode 100644 apps/web/src/app/[locale]/sign-out-button.tsx create mode 100644 apps/web/src/app/api/auth/[...all]/route.ts create mode 100644 apps/web/src/lib/auth-client.ts create mode 100644 apps/web/src/lib/auth.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index c26be16..175aa5c 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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= diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 7705cb3..42e9d21 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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? Registrati", + "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? Accedi", + "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." + } } } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 779ade9..ec6068c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -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, }, diff --git a/apps/web/package.json b/apps/web/package.json index b0a742b..f7cfb42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx b/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..f095b3d --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx @@ -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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx b/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..2129105 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx @@ -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(null); + const [pending, setPending] = useState(false); + + async function handleSubmit(event: FormEvent) { + 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 ( +
+
+ + + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+
+
+ + {t('or')} + +
+
+ +

+ {t.rich('noAccount', { + link: (chunks) => ( + + {chunks} + + ), + })} +

+
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx b/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..633135c --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx @@ -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 ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +

{t('disclaimer')}

+
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx b/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx new file mode 100644 index 0000000..2f9ce74 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx @@ -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(null); + const [pending, setPending] = useState(false); + + async function handleSubmit(event: FormEvent) { + 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 ( +
+
+ + + + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+
+
+ + {t('or')} + +
+
+ +

+ {t.rich('haveAccount', { + link: (chunks) => ( + + {chunks} + + ), + })} +

+
+ ); +} diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 969901d..ea1e4a8 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -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 ; +} + +function HomeContent({ userName }: { userName: string | null }) { const t = useTranslations('Home'); return (

{t('title')}

{t('tagline')}

-
- - -
+ {userName ? ( +
+ {t('signedInAs', { name: userName })} + +
+ ) : ( +
+ + {t('ctaPrimary')} + + + {t('ctaSecondary')} + +
+ )}

{t('disclaimer')}

); diff --git a/apps/web/src/app/[locale]/sign-out-button.tsx b/apps/web/src/app/[locale]/sign-out-button.tsx new file mode 100644 index 0000000..834f23e --- /dev/null +++ b/apps/web/src/app/[locale]/sign-out-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..937fb6a --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from '@ketopath/auth'; +import { toNextJsHandler } from 'better-auth/next-js'; + +export const { POST, GET } = toNextJsHandler(auth); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts new file mode 100644 index 0000000..ae1e4f8 --- /dev/null +++ b/apps/web/src/lib/auth-client.ts @@ -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); diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..e541af5 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,6 @@ +import { auth } from '@ketopath/auth'; +import { headers } from 'next/headers'; + +export async function getServerSession() { + return auth.api.getSession({ headers: headers() }); +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 54f39cc..8e8e3b7 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -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'; diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index f1cd296..9d72257 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -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(); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf10914..198cde3 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,4 @@ -export { prisma } from './client.js'; +export { prisma } from './client'; export { ActivityLevel, CookingTime, diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 5100b45..7f67cce 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -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"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cf2904fb5cf77a24ad020fec2f6736599170855..c456f346bf9f69f34b44b5c65b27176a6be9f56f 100644 GIT binary patch delta 406 zcmex0pZCfl-VNDYEQzHh8IudS+#w8K_MFK_xnehe7EoktR^o0~;%3~g#Le_bnZ-oU zSa147VI%2U87PZ{l=4BPPD-vpbn`;bK11fqq*6=O#|S qXUfDoJ+B){$sM??&vc+8O{e$vGqF$i?qzC0k^s4CyKNuS-h%+|HG+)* delta 459 zcmcbzi1*ig-VNDYljGT=CaZDBHZS36U&6z+^XVIHm<+e?HelMq zh%7R_pqEK{`hFv(CM402$+wgRr>`<*szeeroF34}Br|<)HxvK#W)l?k5z{Tam}REd znljBo(SXZrmFfG8nNo>TDmZ;XFB9MN9mY)ADC&W337B3N#l$|H%ZjNQNeb*1x#|C{ zm<}L`8ct8>XEL9zZ3B1rc3^mj`XVWDn=a7BRD&d72nrhmP%!XK|KG)wiy{RJi}@&` gAPtjWEAvi2*Mp`WD6<+xDbRr1$O7Ae4*YTu08jLeod5s;