Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ jobs:
--config=tests/playwright.config.js \
--workers=1

- name: "[required] issue-255-registration.spec.js — configurable registration fields"
run: |
npx playwright test tests/issue-255-registration.spec.js \
--config=tests/playwright.config.js \
--workers=1

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# ── Orphan-spec backfill: tests that were authored against a fresh
# install but never wired into the CI workflow. Adding them as
# `continue-on-error: true` so we get the signal from a clean
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri

---

## What's New in v0.7.37

- **Configurable registration fields ([#255](https://github.com/fabiodalez-dev/Pinakes/issues/255)).**
Small communities can no longer be forced to collect personal data they don't
need: surname, phone and address each get an admin toggle (Settings →
Registration) deciding whether they are required at self-registration.
Defaults keep today's behaviour, so nothing changes until you opt out.
- **Custom registration fields (#255).** Administrators can define their own
fields (text, textarea, email, URL, number, checkbox — required or optional):
they appear on the registration form and in the user profile, and are shown
on the admin user detail. Ideal for community handles such as a Telegram
username. The bundled Mobile API exposes the requirements and definitions in
`/api/v1/health`, accepts the values during app registration/profile editing,
and includes them in `/api/v1/me`. New migration `migrate_0.7.37.sql` adds the two supporting tables
(`registrazione_campi`, `utenti_campi_valori`); it is idempotent and runs
automatically on upgrade.

## What's New in v0.7.35

- **Docker-aware in-app updater.** On the official Docker image the app files are
Expand Down
2 changes: 1 addition & 1 deletion app/Controllers/PrestitiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public function createForm(Request $request, Response $response, mysqli $db): Re
$stmt->close();
}
if ($presetUser) {
$presetUserName = $presetUser['nome'] . ' ' . $presetUser['cognome'] . ' (' . $presetUser['codice_tessera'] . ')';
$presetUserName = full_name($presetUser['nome'] ?? '', $presetUser['cognome'] ?? '') . ' (' . $presetUser['codice_tessera'] . ')';
$presetUserLocked = true;
} else {
$presetUserId = 0;
Expand Down
63 changes: 59 additions & 4 deletions app/Controllers/ProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public function show(Request $request, Response $response, mysqli $db, mixed $co
$stmt->execute();
$user = $stmt->get_result()->fetch_assoc() ?: [];
$stmt->close();
// Custom registration fields (issue #255): definitions + this user's values.
$customFields = \App\Support\RegistrationFields::definitions($db);
$customFieldValues = \App\Support\RegistrationFields::valuesForUser($db, $uid);
ob_start();
require __DIR__ . '/../Views/profile/index.php';
$content = ob_get_clean();
Expand Down Expand Up @@ -159,12 +162,39 @@ public function update(Request $request, Response $response, mysqli $db): Respon
$locale = ($locale !== '' && isset($availableLocales[$locale])) ? $locale : null;
}

// Validate required fields
if (empty($nome) || empty($cognome)) {
// Validate required fields. Surname/phone/address follow the admin
// toggles (issue #255) so a member can't blank a field the library
// requires. An optional surname is stored as '' — the column is NOT
// NULL by design so display paths that CONCAT nome+cognome stay whole.
if (empty($nome)
|| ($cognome === '' && \App\Support\RegistrationFields::isRequired('cognome'))
|| ($telefono === null && \App\Support\RegistrationFields::isRequired('telefono'))
|| ($indirizzo === null && \App\Support\RegistrationFields::isRequired('indirizzo'))
) {
$profileUrl = RouteTranslator::route('profile');
return $response->withHeader('Location', $profileUrl . '?error=required_fields')->withStatus(302);
}

// Custom registration fields (issue #255): validate before writing.
// enforceRequired=false — a field marked obbligatorio AFTER this user
// signed up must not wall them out of unrelated profile edits (they
// never saw it). Format checks still apply to anything they typed.
$customValues = null;
$customFieldsSubmitted = array_key_exists('custom_fields_present', $data)
|| array_key_exists('custom_field', $data);
if ($customFieldsSubmitted) {
$customDefinitions = \App\Support\RegistrationFields::definitions($db);
$customValidation = \App\Support\RegistrationFields::validate($customDefinitions, $data, false);
if ($customValidation['error'] !== null) {
// Name the actual problem instead of the generic "required fields"
// banner (which is wrong when the user did fill the field).
$customError = $customValidation['error_reason'] === 'format' ? 'custom_field_invalid' : 'required_fields';
$profileUrl = RouteTranslator::route('profile');
return $response->withHeader('Location', $profileUrl . '?error=' . $customError)->withStatus(302);
}
$customValues = $customValidation['values'];
}

// Update user — only include locale in SQL when the field was posted
if ($localeProvided) {
$stmt = $db->prepare("UPDATE utenti SET nome = ?, cognome = ?, telefono = ?, data_nascita = ?, cod_fiscale = ?, sesso = ?, indirizzo = ?, locale = ? WHERE id = ?");
Expand All @@ -185,9 +215,34 @@ public function update(Request $request, Response $response, mysqli $db): Respon
$stmt->bind_param('sssssssi', $nome, $cognome, $telefono, $data_nascita, $cod_fiscale, $sesso, $indirizzo, $uid);
}

if ($stmt->execute()) {
// Profile row + custom field values land atomically: a failed custom
// value write rolls back the profile update too, so the user never
// sees "saved" with half the form persisted.
$updated = false;
try {
$db->begin_transaction();
$updated = $stmt->execute();
if ($updated) {
if ($customValues !== null) {
\App\Support\RegistrationFields::saveValues($db, $uid, $customValues);
}
$db->commit();
} else {
$db->rollback();
}
} catch (\Throwable $e) {
try {
$db->rollback();
} catch (\Throwable) {
// best-effort — never mask the original error
}
SecureLogger::error('ProfileController: profile/custom-field save failed', ['user_id' => $uid, 'error' => $e->getMessage()]);
$updated = false;
}

if ($updated) {
// Update session data
$_SESSION['user']['name'] = $nome . ' ' . $cognome;
$_SESSION['user']['name'] = trim($nome . ' ' . $cognome);
// Apply locale change immediately (only when locale was in the form)
if ($localeProvided) {
if ($locale !== null) {
Expand Down
72 changes: 62 additions & 10 deletions app/Controllers/RegistrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@

class RegistrationController
{
public function form(Request $request, Response $response): Response
public function form(Request $request, Response $response, mysqli $db): Response
{
Csrf::ensureToken();
// Admin-configurable field requirements + custom fields (issue #255),
// consumed by the view for labels/required attributes and rendering.
$registrationRequired = [
'cognome' => \App\Support\RegistrationFields::isRequired('cognome'),
'telefono' => \App\Support\RegistrationFields::isRequired('telefono'),
'indirizzo' => \App\Support\RegistrationFields::isRequired('indirizzo'),
];
$customFields = \App\Support\RegistrationFields::definitions($db);
ob_start();
require __DIR__ . '/../Views/auth/register.php';
$html = ob_get_clean();
Expand Down Expand Up @@ -64,11 +72,29 @@ public function register(Request $request, Response $response, mysqli $db): Resp
return $response->withHeader('Location', RouteTranslator::route('register') . '?error=privacy_required')->withStatus(302);
}

// Validate required fields
if ($nome === '' || $cognome === '' || $email === '' || $telefono === '' || $indirizzo === '' || $password === '' || $password !== $password2) {
// Validate required fields. Surname/phone/address are admin-configurable
// (issue #255, Settings → Registration); defaults keep them required so
// existing installs behave exactly as before until the admin opts out.
if ($nome === '' || $email === '' || $password === '' || $password !== $password2) {
return $response->withHeader('Location', RouteTranslator::route('register') . '?error=missing_fields')->withStatus(302);
}
if (($cognome === '' && \App\Support\RegistrationFields::isRequired('cognome'))
|| ($telefono === '' && \App\Support\RegistrationFields::isRequired('telefono'))
|| ($indirizzo === '' && \App\Support\RegistrationFields::isRequired('indirizzo'))
) {
return $response->withHeader('Location', RouteTranslator::route('register') . '?error=missing_fields')->withStatus(302);
}

// Admin-defined custom fields (issue #255): validate before any write.
// Required IS enforced at signup (the field's contract); a format error
// gets a distinct code so the user isn't told to "fill" a field they did.
$customDefinitions = \App\Support\RegistrationFields::definitions($db);
$customValidation = \App\Support\RegistrationFields::validate($customDefinitions, $data);
if ($customValidation['error'] !== null) {
$customError = $customValidation['error_reason'] === 'format' ? 'custom_field_invalid' : 'missing_fields';
return $response->withHeader('Location', RouteTranslator::route('register') . '?error=' . $customError)->withStatus(302);
}

// Validate input lengths
if (strlen($nome) > 100 || strlen($cognome) > 100) {
return $response->withHeader('Location', RouteTranslator::route('register') . '?error=name_too_long')->withStatus(302);
Expand Down Expand Up @@ -131,17 +157,18 @@ public function register(Request $request, Response $response, mysqli $db): Resp
$data_accettazione_privacy = gmdate('Y-m-d H:i:s');
$privacy_policy_version = '1.0';

// Build dynamic INSERT to handle NULL values properly for ENUM fields
$columns = 'nome, cognome, email, password, telefono, indirizzo, codice_tessera, stato, tipo_utente, email_verificata, token_verifica_email, data_token_verifica, data_scadenza_tessera, privacy_accettata, data_accettazione_privacy, privacy_policy_version';
$placeholders = '?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?';
$types = 'ssssssssssssss';
// Build dynamic INSERT to handle NULL values properly for ENUM fields.
// cognome stays in the base list: the column is NOT NULL by design (70+
// display paths CONCAT nome+cognome and a NULL would blank the whole
// name), so an optional surname is stored as an empty string.
$columns = 'nome, cognome, email, password, codice_tessera, stato, tipo_utente, email_verificata, token_verifica_email, data_token_verifica, data_scadenza_tessera, privacy_accettata, data_accettazione_privacy, privacy_policy_version';
$placeholders = '?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?';
$types = 'ssssssssssss';
$values = [
$nome,
$cognome,
$email,
$hash,
$telefono,
$indirizzo,
$codice_tessera,
$stato,
$ruolo,
Expand All @@ -152,6 +179,21 @@ public function register(Request $request, Response $response, mysqli $db): Resp
$privacy_policy_version
];

// Nullable contact fields: store NULL (not '') when the admin made them
// optional and the user left them blank.
if ($telefono !== '') {
$columns .= ', telefono';
$placeholders .= ', ?';
$types .= 's';
$values[] = $telefono;
}
if ($indirizzo !== '') {
$columns .= ', indirizzo';
$placeholders .= ', ?';
$types .= 's';
$values[] = $indirizzo;
}

// Add optional fields only if they have values (to avoid ENUM truncation errors)
if ($dataNascita !== null) {
$columns .= ', data_nascita';
Expand Down Expand Up @@ -182,13 +224,23 @@ public function register(Request $request, Response $response, mysqli $db): Resp
// throw as well, and must route to ?error=db, not bubble up as a 500.
$stmt = null;
try {
// Account row + custom field values land atomically: a failed custom
// value write must not leave a half-registered account behind.
$db->begin_transaction();
$stmt = $db->prepare("
INSERT INTO utenti ({$columns}) VALUES ({$placeholders})
");
$stmt->bind_param($types, ...$values);
$stmt->execute();
$userId = (int) $stmt->insert_id;
\App\Support\RegistrationFields::saveValues($db, $userId, $customValidation['values']);
$db->commit();
} catch (\Throwable $e) {
try {
$db->rollback();
} catch (\Throwable) {
// best-effort — never mask the original error below
}
// Public form: collapse ANY unique-key duplicate (email / cod_fiscale /
// codice_tessera) into one GENERIC message so an anonymous visitor can't tell
// which field is taken (member enumeration). Only a genuine non-duplicate DB
Expand Down Expand Up @@ -222,7 +274,7 @@ public function register(Request $request, Response $response, mysqli $db): Resp
// Notify admins of new registration (email)
$notificationService->notifyNewUserRegistration($userId);
// Create in-app notification for admins
$notificationService->notifyNewUserInApp($userId, $nome . ' ' . $cognome, $email);
$notificationService->notifyNewUserInApp($userId, trim($nome . ' ' . $cognome), $email);
} catch (\Throwable $e) {
SecureLogger::error('[Registration] post-registration side-effect failed (account ' . $userId . ' was created): ' . $e->getMessage());
}
Expand Down
Loading
Loading