Passa al contenuto principale

One-Time Password Generator

Il Progetto

Un Lab di freeCodeCamp che si è rivelato più un ripasso consapevole che una scoperta: useState, useEffect, rendering condizionale. Tutto già visto. Ma la libertà sul CSS e una domanda al Code Tutor sulla sicurezza crittografica hanno reso l'esercizio più interessante di quanto mi aspettassi.

Codice Sorgente

/* DESIGN
------
* This file contains the React logic for the OTP Generator application
* The architecture focuses on the "Side Effect" pattern and secure randomness:
*
* The Generation Logic (Cryptographic Randomness):
* - I used `window.crypto.getRandomValues()` instead of `Math.random()` because
* Math.random is pseudo-random (predictable), while the Web Crypto API pulls
* entropy from the operating system, making the OTP genuinely unpredictable
* - The modulo trick (`% 1000000`) extracts exactly 6 digits from a 32-bit integer,
* and `padStart(6, "0")` ensures codes like "000042" aren't truncated to "42"
*
* The Timer System (useEffect + setInterval):
* - I implemented a countdown using `useEffect` that watches two dependencies:
* `isActive` (whether the timer is running) and `timeLeft` (remaining seconds)
* - The effect contains a Guard Clause pattern: if the timer isn't active or has
* reached zero, it exits early and resets `isActive` to re-enable the button
* - When active, a `setInterval` decrements `timeLeft` every second
* - The cleanup function (`return () => clearInterval(...)`) is critical because
* this effect re-runs every time `timeLeft` changes (it's in the dependency array:
* `[isActive, timeLeft]`)
* Without cleanup:
* - Second 1: interval #1 created → decrements timeLeft to 4
* - Second 2: effect re-runs → interval #1 still active + interval #2 created
* - Second 3: intervals #1, #2, #3 all active → timeLeft decrements by 3 at once
* - Result: the countdown accelerates exponentially until the timer completes
* The cleanup ensures the old interval is destroyed before creating a new one,
* maintaining exactly one active interval at any time
*
* The UI State Machine:
* - The display cycles through three visual states driven by two boolean conditions:
* 1. No OTP yet → placeholder message (styled with `.empty` class)
* 2. OTP active → large 6-digit code with live countdown
* 3. OTP expired → same code visible, but timer shows expiration message
* - The button's `disabled` prop is bound directly to `isActive`, creating a
* self-regulating loop: generate → disable → countdown → enable.
*/

/* freeCodeCamp instructions:
* 1. You should use the useEffect hook to manage the countdown timer. ✔️
* 2. Your OTPGenerator component should return a div element with the class name container. ✔️
* 3. The div having the class container should include the following elements:
* - An h1 element with the ID otp-title and text "OTP Generator". ✔️
* - An h2 element with the ID otp-display that either displays the message "Click 'Generate OTP' to get a code"
* or shows the generated OTP if one is available. ✔️
* - A p element with the ID otp-timer and aria-live attribute set to a valid value that:
* - Starts off empty. ✔️
* - Displays "Expires in: X seconds" after the button is clicked, where X represents the remaining time
* before the OTP expires. ✔️
* - Shows the message "OTP expired. Click the button to generate a new OTP." once the countdown reaches 0. ✔️
* - A button element with the ID generate-otp-button labeled "Generate OTP". When clicked, it should generate a
* new OTP and start a 5-second countdown. ✔️
* - The "Generate OTP" button must be disabled while the countdown is active. ✔️
* 4. You should ensure the countdown timer stops automatically once it reaches 0 seconds to prevent unnecessary updates. ✔️
* 5. The generated OTP should be 6 digits long. ✔️
*/

const { useState, useEffect, useRef } = React;

export const OTPGenerator = () => {
const [otp, setOtp] = useState("");
const [timeLeft, setTimeLeft] = useState(0);
const [isActive, setIsActive] = useState(false);

const generateOTP = () => {
/*
* I chose the Web Crypto API over Math.random() for security reasons and to gain
* hands-on experience with the API
* `Uint32Array(1)` creates a typed array that holds one 32-bit unsigned integer,
* and `getRandomValues` fills it with cryptographically strong random data
* from the OS entropy pool (e.g. 2,567,523,558)
* The modulo operation (`% 1000000`) extracts the last 6 digits (e.g. 523,558),
* and `padStart` ensures we always get exactly 6 characters, even if the result
* starts with zeros (e.g. 4,821 becomes "004821")
*/
const array = new Uint32Array(1);
window.crypto.getRandomValues(array);
const sixDigits = array[0] % 1000000;
const secureOtp = sixDigits.toString().padStart(6, "0");

setOtp(secureOtp);
setTimeLeft(5);
setIsActive(true);
};

useEffect(() => {
/*
* Guard Clause pattern: I check the "stop conditions" first and exit early
* If the timer isn't active OR has reached zero, there's nothing to do
* Important detail: when `timeLeft` hits 0, I must also set `isActive` to false,
* otherwise the button would stay disabled forever since nothing would reset it
*/
if (!isActive || timeLeft === 0) {
if (timeLeft === 0) setIsActive(false);
return;
}

/*
* The interval engine: if we reach this point, the timer is active and has
* seconds remaining. I set up a `setInterval` that decrements `timeLeft` by 1
* every 1000ms
*
* The cleanup function (the returned arrow function) is critical here:
* because `useEffect` re-runs every time `timeLeft` changes, without cleanup
* each re-render would stack a NEW interval on top of the old one, causing
* the countdown to accelerate exponentially. The cleanup kills the previous
* interval before the next one starts
*/
const intervalId = setInterval(() => {
setTimeLeft(timeLeft - 1);
}, 1000);

return () => clearInterval(intervalId);

}, [isActive, timeLeft]); // Re-run when timer state or countdown value changes

return (
<div className="container">
<h1 id="otp-title" className="title">OTP Generator</h1>

{/*
* Conditional rendering with ternary:
* If `otp` has a value, I display the 6-digit code; otherwise, I show
* the placeholder message. The `.empty` class is toggled dynamically
* to switch between the large monospace OTP style and the smaller
* instructional text style
*/}
<h2 id="otp-display" className={`display ${!otp ? "empty" : ""}`}>
{otp ? otp : "Click 'Generate OTP' to get a code"}
</h2>

{/*
* The timer display uses a nested ternary to handle three states:
* 1. No OTP generated yet (`!otp`) → empty string (aria-live region stays silent)
* 2. OTP active (`isActive`) → "Expires in: X seconds" (live countdown)
* 3. OTP expired (`!isActive`) → expiration message prompting regeneration
*
* I set `aria-live="polite"` so screen readers announce countdown changes
* without interrupting the user's current focus
*/}
<p id="otp-timer" className="timer-text" aria-live="polite">
{otp ? (isActive ? `Expires in: ${timeLeft} seconds` : "OTP expired. Click the button to generate a new OTP.") : ""}
</p>

{/*
* The button's `disabled` prop is bound directly to `isActive`, creating
* a self-regulating cycle: clicking generates an OTP and disables the button,
* the countdown runs, and when it expires `isActive` becomes false,
* automatically re-enabling the button
*/}
<button
id="generate-otp-button"
className="generate-btn"
onClick={generateOTP}
disabled={isActive}
>
Generate OTP
</button>
</div>
);
};

crypto.getRandomValues(): La Domanda Giusta al Momento Giusto

La primissima cosa che ho pensato prima di iniziare è stata: posso usare crypto.randomUUID() che avevo approfondito nella Profile Card?
Il problema è che randomUUID() genera una stringa come c90d075d-53a1-..., e per estrarne 6 cifre avrei dovuto rimuovere i trattini, filtrare le lettere, estrarre i numeri, sperare ce ne fossero abbastanza e, altrimenti, rigenerare. Un accrocchio. Ho chiesto al Code Tutor, che mi ha spiegato che esiste uno strumento più diretto, crypto.getRandomValues(), nato esattamente per questo.
Non volevo usare Math.random(): avevo imparato che è pseudo-casuale e quindi prevedibile. Seppur fosse over-engineering per un esercizio didattico focalizzato sull'applicare useState, useEffect e il rendering condizionale, il funzionamento di questa API mi affascinava così tanto che non vedevo l'ora di applicarla.

Design: Obsidian + Roman Numeral Converter

Non mi sono focalizzato sul design e non ho fatto il prototipo in Figma, ma avendo carta bianca sul CSS ho deciso di andare sul semplice. Cercando alternative ad AppFlowy questa mattina, mi ero imbattuto nel sito di Obsidian: dark, minimal, nessun elemento decorativo superfluo. Ho deciso di partire da quello stile e di combinarlo con un dettaglio che avevo usato nel Roman Numeral Converter: il pulsante ispirato al Material Design che in stato active passa da angoli squadrati a pill-shaped (a forma di pillola) tramite animazione del border-radius, dando un feedback tattile gradevole.

Landscape: L'Edge Case Preferito

Ho dedicato più attenzione del solito al landscape, dopo essermi reso conto di quanto siti anche importanti lo trattino come un caso secondario. Ho preso in mano il mio edge case preferito, l'iPhone 12 mini, e ho ottimizzato ogni spaziatura per quella viewport strettissima in verticale. Ho aggiunto anche un'altra scheda in Safari per simulare la massima costrizione possibile.
Ecco il risultato:

OTP Generator in modalità landscape su iPhone 12 mini con due schede Safari aperte, che mostra lo stato iniziale vuoto con il pulsante giallo di generazione
OTP Generator in modalità landscape che mostra il codice generato 795635 con il timer del conto alla rovescia 'Scade tra: 2 secondi' sotto di esso

Questa attenzione non è nata casualmente: tre giorni prima avevo aggiunto una media query al custom.css di questo sito per gestire il landscape su iPhone. Il problema erano le barre colorate laterali che il browser aggiunge di default per evitare la tacca. Sono funzionali, ma orribili da vedere. Guardando come lo risolvono i migliori, ho notato due approcci opposti:

  • Apple e Google: la navbar si estende fino ai bordi fisici dello schermo ("Full Bleed"), mentre il contenuto rimane allineato entro i margini sicuri, molto evidente nel sito Apple.com.
  • Wikipedia: la navbar resta compressa dentro i margini, creando un effetto "inscatolato" che spezza la continuità visiva, esattamente l'effetto che dava il mio sito.

Ho scelto l'approccio di Apple.com. Il pattern che ho usato è calc(env(safe-area-inset-left) + 12px): garantisce quindi sempre almeno 12px di padding, aggiungendo sopra di essi la dimensione della tacca sui dispositivi che ce l'hanno. Su dispositivi senza tacca, env(safe-area-inset-left) vale zero, quindi il risultato è semplicemente 12px, risolvendo anche il problema del "contenuto incollato al bordo" sui foldable, il secondo edge case su cui ho la fortuna di poter testare.

Anti-CLS

Un problema che non avevo considerato fin dall'inizio, ma che si è rivelato interessante da risolvere: il CLS (Cumulative Layout Shift), ovvero quei "salti" visivi che si verificano quando un elemento cambia dimensione e trascina tutto il resto su o giù con sé.

Il display dell'OTP cambia contenuto dinamicamente: prima mostra un placeholder con un font piccolo, poi un codice a 6 cifre con un font quasi tre volte più grande. Stessa cosa per il timer: parte vuoto, poi mostra il conto alla rovescia, poi un messaggio di scadenza che occupa due righe. Ogni volta che il testo cambia, il browser ricalcola quanto spazio occupa quell'elemento, e tutto quello che sta sotto si sposta di conseguenza.
La soluzione è stata riservare in anticipo lo spazio necessario con un height fisso. Ho scelto height e non min-height perché min-height lascia ancora libero l'elemento di crescere. Il box deve essere rigido. Il contenuto si centra dentro con flexbox e il layout non si muove, qualunque cosa ci stia dentro.

.display {
height: var(--display-min-height); /* Box rigido: non cresce, non si restringe */
display: flex;
align-items: center;
justify-content: center;
}

Un'altra cosa che ho scoperto lavorando sul letter-spacing: CSS aggiunge lo spazio dopo ogni carattere, incluso l'ultimo, spostando visivamente tutto il blocco verso sinistra. Ho compensato con un margin-right: calc(var(--otp-letter-spacing) * -1) che riporta tutto esattamente in asse.

Cosa Ho Imparato

Guard Clause nel useEffect:
Controllare le condizioni di uscita all'inizio dell'effect, prima di qualsiasi logica, mantiene il codice piatto e leggibile. Senza il setIsActive(false) quando timeLeft raggiunge zero, il bottone resterebbe disabilitato per sempre: niente reimposta lo stato, il ciclo si blocca.

Interval Stacking:
Senza la funzione di cleanup (return () => clearInterval(intervalId)), ogni re-render causato dal cambio di timeLeft avvierebbe un nuovo setInterval senza fermare il precedente. Il conto alla rovescia accelererebbe esponenzialmente. La cleanup uccide l'intervallo precedente prima che ne parta uno nuovo.

aria-live="polite":
Aggiungere aria-live al timer significa che uno screen reader annuncia ogni aggiornamento del conto alla rovescia senza interrompere il focus corrente dell'utente. "Polite" aspetta che l'utente abbia finito ciò che sta facendo prima di parlare. È un dettaglio minimo nel codice, ma fa la differenza per chi non vede lo schermo.

hover: hover:
Wrappare gli stili hover dentro @media (hover: hover) previene il cosiddetto "sticky hover" su touch screen: su iOS, dopo un tap, lo stato :hover rimane attivo visivamente finché non si tocca qualcos'altro. Limitando l'hover ai dispositivi che supportano il puntatore, si evita questo artefatto senza rinunciare al feedback per chi usa il mouse.


Next:
Consolidare tutto con il React State and Hooks Review e il React State and Hooks Quiz, per poi affrontare la teoria di Working with Forms in React sul funzionamento dei form in React e il nuovo hook useActionState, che applicherò nella Superhero Application Form (Workshop).