diff --git a/apps/platforms/src/lib/longevity/accesso.ts b/apps/platforms/src/lib/longevity/accesso.ts
new file mode 100644
index 0000000..51801de
--- /dev/null
+++ b/apps/platforms/src/lib/longevity/accesso.ts
@@ -0,0 +1,88 @@
+//
+// Crea l'account di accesso al sito per un cliente che ha GIA' un fascicolo aperto
+// (il percorso inverso di crea-fascicolo.ts, che parte da un utente esistente e apre il
+// fascicolo). Qui si parte dal fascicolo — creato dal trainer al check-up, spesso prima
+// che il cliente abbia un account — e si crea l'utente che gli permette di entrare da
+// /longevity a leggere il proprio referto e compilare il questionario.
+//
+// ⚠️ Non apre identity da sé: passa dal solo modulo autorizzato a farlo (anagrafica.ts),
+// che è anche l'unico a sapere cosa sia "un accesso" per un fascicolo. Questo file tocca
+// due database (users in piattaforme.db, il collegamento in identity.db) ma non li apre
+// mai insieme: riceve `utenti` da fuori e delega identity alle funzioni di anagrafica.ts.
+import type Database from 'better-sqlite3';
+import { getDb } from '../db';
+import { createUser, deleteUser, randomPassword } from '../auth';
+import { accessoDelCliente, collegaAccessoCliente } from './anagrafica';
+
+/** Solo lettere minuscole, cifre e ".", "_", "@", "-": stesso alfabeto permissivo di create-user.mjs. */
+const USERNAME_VALIDO = /^[a-z0-9._@-]{3,64}$/;
+
+/**
+ * Le dipendenze di creaAccessoCliente, iniettabili per i test (DB in memoria, doppi finti
+ * per accessoDi/collega) invece del singleton reale. In produzione si passa `undefined` e
+ * si prende il default qui sotto — lo stesso schema del singleton di getDb(): una
+ * connessione per processo, non riaperta a ogni chiamata.
+ */
+export interface DipendenzeAccesso {
+ utenti: Database.Database;
+ accessoDi: (code: string) => { userId: number | null; email: string | null } | null;
+ collega: (code: string, userId: number) => boolean;
+}
+
+function dipendenzeDiDefault(): DipendenzeAccesso {
+ return { utenti: getDb(), accessoDi: accessoDelCliente, collega: collegaAccessoCliente };
+}
+
+export type EsitoAccesso =
+ | { ok: true; username: string; password: string }
+ | { ok: false; errore: string };
+
+/**
+ * Crea l'utente `cliente` e lo collega al fascicolo `code`. Ordine dei controlli: prima
+ * tutto ciò che si può verificare SENZA scrivere (username valido, fascicolo esistente e
+ * ancora senza accesso, username libero), poi si crea l'utente, poi si collega.
+ *
+ * Se il collegamento fallisce (fascicolo sparito o già collegato nel frattempo, o
+ * l'update lancia per un motivo diverso) l'utente appena creato si cancella: due database
+ * distinti, quindi nessuna transazione unica possibile — stessa compensazione di
+ * creaCliente in anagrafica.ts. Un utente `cliente` senza fascicolo collegato non
+ * servirebbe a niente e resterebbe un accesso fantasma.
+ *
+ * La password è generata qui (randomPassword, mai scelta da chi chiama) e torna in
+ * chiaro UNA volta sola nell'esito: non viene salvata da nessuna parte se non nel suo
+ * hash dentro users.password_hash. Chi chiama non la deve loggare.
+ */
+export function creaAccessoCliente(
+ deps: DipendenzeAccesso = dipendenzeDiDefault(),
+ { code, username }: { code: string; username: string }
+): EsitoAccesso {
+ const nome = username.trim().toLowerCase();
+ if (!USERNAME_VALIDO.test(nome)) {
+ return { ok: false, errore: 'Username non valido: 3-64 caratteri fra lettere minuscole, cifre, ". _ @ -".' };
+ }
+
+ const fascicolo = deps.accessoDi(code);
+ if (!fascicolo) return { ok: false, errore: `Nessun fascicolo con codice ${code}.` };
+ if (fascicolo.userId !== null) {
+ return { ok: false, errore: 'Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.' };
+ }
+
+ const usernameOccupato = deps.utenti.prepare('SELECT 1 FROM users WHERE username = ?').get(nome);
+ if (usernameOccupato) return { ok: false, errore: `L'username "${nome}" è già in uso.` };
+
+ const password = randomPassword();
+ const userId = createUser(deps.utenti, nome, password, 'cliente');
+
+ let collegato: boolean;
+ try {
+ collegato = deps.collega(code, userId);
+ } catch {
+ collegato = false;
+ }
+ if (!collegato) {
+ deleteUser(deps.utenti, userId);
+ return { ok: false, errore: 'Il fascicolo non è più collegabile (cancellato o già collegato nel frattempo).' };
+ }
+
+ return { ok: true, username: nome, password };
+}
diff --git a/apps/platforms/src/lib/longevity/anagrafica.ts b/apps/platforms/src/lib/longevity/anagrafica.ts
index cfea267..e103022 100644
--- a/apps/platforms/src/lib/longevity/anagrafica.ts
+++ b/apps/platforms/src/lib/longevity/anagrafica.ts
@@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null {
return codicePerUtente(getIdentityDb(), userId);
}
+/**
+ * Lo stato di accesso di un fascicolo: chi (se qualcuno) lo può aprire da sito, e l'email
+ * già in anagrafica (serve solo a precompilare l'username in fase di creazione, non è
+ * usata per altro). `null` vuol dire codice inesistente — diverso da "esiste ma senza
+ * accesso", che è `{ userId: null, email }`.
+ */
+export function accessoDi(
+ identity: Database.Database,
+ code: string
+): { userId: number | null; email: string | null } | null {
+ const r = identity.prepare(`SELECT user_id, email FROM clienti WHERE client_code = ?`).get(code) as
+ { user_id: number | null; email: string | null } | undefined;
+ if (!r) return null;
+ return { userId: r.user_id, email: r.email };
+}
+
+/** Come accessoDi, ma sulla connessione singleton: per il gestionale, che identity non la apre mai da sé. */
+export function accessoDelCliente(code: string): { userId: number | null; email: string | null } | null {
+ return accessoDi(getIdentityDb(), code);
+}
+
+/**
+ * Lega un utente già creato al fascicolo: vero solo se il fascicolo esiste ed era ancora
+ * senza accesso (`user_id IS NULL` nel WHERE, non un controllo prima separato) — così due
+ * chiamate concorrenti sullo stesso fascicolo non possono collegare due utenti diversi,
+ * la seconda trova zero righe e torna false. Chi chiama con false deve considerare
+ * l'utente appena creato orfano e cancellarlo (vedi accesso.ts).
+ */
+export function collegaAccesso(identity: Database.Database, code: string, userId: number): boolean {
+ const res = identity.prepare(
+ `UPDATE clienti SET user_id = ? WHERE client_code = ? AND user_id IS NULL`
+ ).run(userId, code);
+ return res.changes === 1;
+}
+
+/** Come collegaAccesso, ma sulla connessione singleton. */
+export function collegaAccessoCliente(code: string, userId: number): boolean {
+ return collegaAccesso(getIdentityDb(), code, userId);
+}
+
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
export function etaAllaData(dataNascita: string, alla: string): number {
const n = new Date(dataNascita);
diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro
new file mode 100644
index 0000000..f27e423
--- /dev/null
+++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro
@@ -0,0 +1,82 @@
+---
+// Crea l'accesso al sito per un cliente che ha già il fascicolo: il trainer sceglie
+// l'username, la password la genera il sistema e si mostra UNA volta sola, qui, mai
+// via redirect (finirebbe nella query string dell'URL e nella cronologia del browser).
+// ⚠️ Questa pagina non apre mai il database anagrafico da sé: passa solo da
+// anagrafica.ts e accesso.ts, gli unici moduli autorizzati (li sorveglia un test).
+import Longevity from '../../../../../layouts/Longevity.astro';
+import { accessoDelCliente } from '../../../../../lib/longevity/anagrafica';
+import { creaAccessoCliente } from '../../../../../lib/longevity/accesso';
+
+export const prerender = false;
+
+const { code } = Astro.params as { code: string };
+
+// La password passa da qui una volta sola: niente cache, né del browser né di un proxy
+// davanti a Node, che altrimenti la servirebbe di nuovo a chi ripete la stessa GET.
+Astro.response.headers.set('Cache-Control', 'no-store');
+
+const fascicolo = accessoDelCliente(code);
+if (!fascicolo) Astro.response.status = 404;
+
+let errore = '';
+let creato: { username: string; password: string } | null = null;
+
+if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') {
+ const form = await Astro.request.formData();
+ const username = String(form.get('username') ?? '');
+ const esito = creaAccessoCliente(undefined, { code, username });
+ if (esito.ok) creato = { username: esito.username, password: esito.password };
+ else errore = esito.errore;
+}
+---
+
+ {!fascicolo && (
+