PeTrace — plan przepisywania
Praca inżynierska · PeTrace

Plan przepisywania

Przepisujesz prototyp z PeTrack/petracer/ do nowego projektu PeTracker-FinalVersion/petracer-final/, jedna funkcja na sesję. Najbliższe sesje (16–19, logowanie) mają tu pełny kod dopasowany do Twojego projektu i wytłumaczony fragment po fragmencie. Reszta ma cel, źródło, test i commit.

Jesteś tutaj: sesja 16 Sesje 14 i 15 są w repo (lista i karty zwierząt). Kolejny kamień milowy: M1 — 8 listopada.
1 · Przeczytaj pojęciaKażda sesja zaczyna się od kart z nowymi pojęciami. Najpierw je zrozum, potem pisz.
2 · Kroki po koleiZielona etykieta nowy plik, żółta zmiana. Podświetlone linie to to, co dopisujesz.
3 · Sprawdź i commitujnpm run lint, npm run build, test z listy „Gotowe, gdy”, potem commit.
4 · Zaznacz „zrobione”Postęp zapisuje się w tej przeglądarce i widać go w menu po lewej.
Spis sesji
Część D · sesje 16–19 · kamień milowy M1

Logowanie bez hasła

Na koniec tej części właściciel loguje się linkiem, panel jest dostępny tylko po zalogowaniu, a po zalogowaniu wraca się tam, skąd się przyszło. Wszystko na danych w localStorage. Prawdziwy serwer zrobi to samo w sesjach 41–42, a strony się nie zmienią.

sequenceDiagram
  actor U as Ty
  participant L as LoginPage
  participant A as api/mock/auth.js
  participant D as localStorage
  participant V as AuthVerifyPage
  participant P as AuthProvider
  U->>L: wpisuje e-mail (sesja 16)
  L->>A: requestMagicLink(email)
  A->>D: loginTokens.push(token, email, expiresAt)
  A-->>L: devLink /auth/verify?token=…
  U->>V: klika „Symuluj kliknięcie” (sesja 17)
  V->>P: login(token)
  P->>A: verifyToken(token)
  A->>D: token zużyty, konto, sessions.push
  A->>D: pettrace:auth-token
  A-->>P: user
  P-->>U: /panel, „Cześć, Maciej”
Sesja 16~1,5 hlogowanie · krok 1 z 4

Proszę o link do logowania

Wpisuję e-mail na /login, widzę „Sprawdź pocztę” i przycisk „[DEV] Symuluj kliknięcie w link”.

Input kontrolowanyWartość pola żyje w useState. Pole tylko ją pokazuje (value) i zgłasza zmiany (onChange). Dzięki temu w każdej chwili masz e-mail w zmiennej, bez czytania z DOM.
preventDefault()Formularz HTML domyślnie wysyła się i przeładowuje stronę. W SPA tego nie chcemy, więc w onSubmit blokujemy domyślne zachowanie i robimy wszystko sami.
noValidateWyłącza dymki walidacji przeglądarki (różne w każdej przeglądarce). Błąd pokazujemy sami pod polem, w stylu aplikacji.
navigate(…, { state })Przekazuje dane do następnej strony bez umieszczania ich w adresie. Znikają po odświeżeniu, dlatego strona docelowa musi obsłużyć ich brak.
Jednorazowy tokenLosowy napis z datą ważności (15 min) i polem usedAt. Kto ma link, ten się loguje, ale tylko raz i tylko przez chwilę.
Konto powstaje dopiero po kliknięciuGdyby prośba o link zakładała konto, każdy mógłby zaśmiecić bazę cudzymi adresami. Token pamięta e-mail, a konto założy verifyToken w sesji 17.
0

Najpierw napraw dane przykładowe

To zablokowałoby całą sesjęTwój seed.js ma tablicę magicLinks, a kod logowania używa db.loginTokens (tak jak tabela login_tokens w bazie). Bez poprawki requestMagicLink rzuci „Cannot read properties of undefined (reading 'push')”.

Zmieniłeś kształt danych, a w przeglądarce leży stara wersja z magicLinks. Dlatego podbij wersję klucza: stary zapis zostanie zignorowany i baza wypełni się od nowa.

1

„Serwer”: zapisz token i zwróć link

Masz już ten plik, ale w starszej wersji: zakłada konto od razu przy prośbie o link. Zamień go w całości na wersję poniżej.

EMAIL_PATTERNProsty wzorzec: coś, potem @, coś, kropka, coś. Nie musi łapać każdego dziwnego adresu, ma odsiać literówki. Prawdziwy test to i tak to, czy mail dojdzie.
15 * 60 * 1000Czas w milisekundach, bo tak liczy JavaScript (Date.now()). Zapis jako mnożenie czyta się jak „15 minut”.
{ name = '', next = null } = {}Drugi argument to obiekt opcji z wartościami domyślnymi. Końcowe = {} sprawia, że można wywołać requestMagicLink(email) bez drugiego argumentu.
await delay()Udajemy sieć (200–500 ms), żeby na stronie było widać stan „Wysyłam…”. Serwer też nie odpowiada natychmiast.
.trim().toLowerCase()„ Maciej@PetTrace.pl ” i „maciej@pettrace.pl” to ten sam adres. Bez normalizacji jeden człowiek miałby dwa konta.
throw apiError(400, …)„Serwer” odrzuca złe dane kodem 400, tak jak zrobi Express. Strona złapie błąd w catch i pokaże message.
crypto.randomUUID()Losowy identyfikator z kryptograficznego generatora przeglądarki. Nie do zgadnięcia, w przeciwieństwie do Math.random().
loginTokens.push({…})Wiersz jak w tabeli login_tokens. Zapisujemy e-mail i imię, ale nie zakładamy konta.
new URLSearchParamsBuduje token=…&next=… i sam koduje znaki specjalne. Ręczne sklejanie napisów psuje się na & albo ? w danych.
safeRedirect(next)Przepuszcza tylko ścieżki zaczynające się od jednego /. Chroni przed „open redirect”: linkiem, który po zalogowaniu wyrzuca na obcą stronę.
devLinkTylko na czas pracy bez maila. Prawdziwy serwer w produkcji nigdy nie zwraca linku, bo każdy mógłby zalogować się na cudzy adres.
2

Fasada api/auth.js ze specyfikacją

Dziś plik ma export * from './mock/auth.js'. Zamień to na jawną listę: komentarz jest specyfikacją przyszłego backendu (adres i co zwraca), a jawny eksport pilnuje, żeby strony używały tylko tego, co jest w specyfikacji.

3

Strona logowania

Zamień szkielet z TODO na formularz. Na razie bez sprawdzania, czy ktoś jest już zalogowany (nie ma jeszcze useAuth) i bez konta demo.

useState × 4Cztery niezależne rzeczy, które zmieniają wygląd: dwa pola, błąd i „czy wysyłam”. Każda zmiana stanu przerysowuje komponent.
const next = nullMiejsce na „dokąd wrócić po zalogowaniu”. W sesji 18 wypełni je RequireAuth.
async function handleSubmitasync, bo czekamy na „serwer” (await requestMagicLink). Funkcja w środku komponentu widzi aktualne email i name.
if (!EMAIL_PATTERN…) returnSzybka walidacja w przeglądarce: błąd od razu, bez czekania. „Serwer” i tak sprawdzi jeszcze raz, bo przeglądarce nie można ufać.
setIsSubmitting(true)Blokuje przycisk. Bez tego podwójne kliknięcie wysłałoby dwa linki.
try / catchBłąd z apiError trafia do catch, a jego message pod pole. Przycisk odblokowujemy tylko przy błędzie; przy sukcesie i tak zmieniamy stronę.
navigate(…, { state })Przechodzi na „Sprawdź pocztę” i przekazuje e-mail oraz devLink bez wpisywania ich w adres.
Card as="form"Twoja karta ma prop as, więc sama staje się elementem <form>. Jeden znacznik mniej, a onSubmit i noValidate przechodzą przez ...rest.
error={error}Input przez FormField pokazuje błąd pod polem i ustawia aria-invalid. To praca z etapu 10, która teraz się zwraca.
type="submit"Twój Button ma domyślnie type="button". Tutaj musi być submit, żeby Enter w polu i kliknięcie wysyłały formularz.
4

Strona „Sprawdź pocztę”

const { state } = useLocation()Odbiera to, co LoginPage przekazała w navigate.
if (!state?.email)?. nie wywala się, gdy state jest null. Tak jest po odświeżeniu albo po wpisaniu adresu ręcznie. Wtedy pokazujemy prośbę o ponowne wpisanie e-maila.
{state.devLink && …}Renderowanie warunkowe: przycisk [DEV] pojawia się tylko, gdy „serwer” zwrócił link. Produkcyjny serwer go nie zwróci, więc przycisk sam zniknie.
<Link to={devLink}>Link zmienia stronę bez przeładowania. Link z maila otworzy się jako pełne wejście, ale w SPA wynik jest ten sam.
{' '}JSX zjada spację na końcu linii. {' '} wstawia ją jawnie przed linkiem.
5

Podepnij trasy

✓

Gotowe, gdy

  • Pusty i błędny e-mail pokazują czerwony błąd pod polem.
  • Poprawny e-mail prowadzi na /login/sprawdz-poczte z Twoim adresem i przyciskiem [DEV].
  • W DevTools → Application → Local Storage → pettrace:db:v2 w loginTokens jest token z Twoim e-mailem i expiresAt za 15 minut.
  • Nowy adres nie pojawia się w users.
  • Odświeżenie „Sprawdź pocztę” pokazuje „Wróć do logowania”.
  • Przycisk [DEV] prowadzi na razie na placeholder „Weryfikacja logowania”. To normalne, stronę zrobisz w sesji 17.
feat(auth): formularz prośby o link do logowania
Sesja 17~2 hlogowanie · krok 2 z 4

Klikam link i jestem zalogowany

„Symuluj kliknięcie” loguje mnie i przenosi do panelu, a w sidebarze widzę swoje imię, także po odświeżeniu.

Context„Kanał” przez całe drzewo komponentów. Provider u góry wkłada wartość, a dowolny komponent niżej czyta ją przez useContext, bez przekazywania przez props.
Prop drillingProblem, który Context rozwiązuje: user musiałby iść przez Router → OwnerLayout → Sidebar tylko po to, żeby Sidebar pokazał imię.
Własny hookFunkcja zaczynająca się od use, która woła inne hooki. useAuth() ukrywa, skąd dane pochodzą, i daje czytelny błąd, gdy zapomnisz o providerze.
useMemoZapamiętuje obiekt między renderami i tworzy nowy tylko po zmianie zależności. Bez tego każdy render providera przerysowałby wszystkich użytkowników useAuth.
Stan „jeszcze nie wiadomo”Po starcie aplikacji przez chwilę nie wiadomo, czy sesja istnieje. isLoading odróżnia „nie zalogowany” od „jeszcze sprawdzam”.
StrictMode i podwójny efektW trybie dev React celowo uruchamia efekt dwa razy, żeby wyłapać błędy. Token jest jednorazowy, więc drugie wywołanie by go „zużyło”. Pomaga useRef, który przetrwa oba przebiegi.
Sesja w localStorageTylko w trybie mock. Prawdziwy serwer trzyma sesję w ciasteczku httpOnly, którego JavaScript nie widzi (decyzja D8), więc złośliwy skrypt jej nie ukradnie.
1

Narzędzia sesji w client.js

Dopisz na końcu pliku, pod toPet. To odpowiednik ciasteczka sesji i zapytania „kto jest zalogowany” po stronie serwera.

TOKEN_KEYOsobny klucz niż baza (pettrace:db:v2). Token to „ciasteczko przeglądarki”, a baza to „serwer”. Reset bazy nie powinien kasować tego klucza i odwrotnie.
try { localStorage… } catchlocalStorage potrafi rzucić wyjątek (tryb prywatny w starym Safari, zablokowane dane strony). Aplikacja ma wtedy działać, tylko bez zapamiętania sesji.
token && db.sessions.find(…)Gdy tokenu brak, wynik to null i find się nie wykonuje. Krótki zapis „jeśli jest token, szukaj sesji”.
?? nullfind zwraca undefined, gdy nic nie znajdzie. Zamieniamy na null, żeby zawsze było „użytkownik albo null”.
toUser({ id, email, name, phone })Wybiera tylko pola, które API ma zwracać. Gdy w bazie dojdzie coś prywatnego (np. city w Twoim seedzie), nie wycieknie na stronę.
2

Weryfikacja, „kim jestem” i wylogowanie

Rozszerz import z client.js i dopisz trzy funkcje pod requestMagicLink.

!link || link.usedAt || isExpiredTrzy powody odmowy w jednym komunikacie. Nie mówimy, który zaszedł, żeby nie podpowiadać atakującemu.
link.usedAt = …Oznaczamy token jako zużyty. Działa, bo getDb() zwraca kopię, którą wolno zmieniać, a saveDb(db) zapisuje ją na końcu.
if (!user) { … push }Rejestracja przy pierwszym logowaniu: konto powstaje dopiero teraz, gdy ktoś udowodnił, że ma dostęp do skrzynki.
link.name || email.split('@')[0]Bez podanego imienia bierzemy część adresu przed @: „ola@x.pl” wita się jako „ola”.
sessions.push + setAuthTokenDwie strony sesji: „serwer” pamięta, że ten token należy do Macieja, a „przeglądarka” trzyma token. Tak samo będzie z ciasteczkiem.
getCurrentUser → nullBrak sesji to zwykła odpowiedź, a nie błąd. Strony publiczne też pytają „kim jestem”.
sessions.filter(…)Wylogowanie usuwa sesje użytkownika po stronie „serwera” i token po stronie przeglądarki.
3

Context: jedno miejsce z „kto jest zalogowany”

Trzy małe pliki. Utwórz folder src/context/.

useState(true) dla isLoadingStartujemy w „nie wiem”, a nie w „nie zalogowany”. Gdyby było false, odświeżenie /panel przez ułamek sekundy wyglądałoby jak brak sesji.
useEffect(…, [])Pusta tablica zależności: efekt uruchamia się raz, po pierwszym wyświetleniu. Pytamy „kim jestem” przy starcie aplikacji.
let ignore = falseTen sam wzór co w useAsync: gdy komponent zniknie przed odpowiedzią, nie ustawiamy już stanu.
.catch(() => {})Gdy „serwer” nie odpowie, traktujemy to jak brak sesji. Strona główna i zawieszka mają działać nawet wtedy.
.finally(…)Wykonuje się po sukcesie i po błędzie, więc isLoading zawsze w końcu spadnie na false.
async login(token)Strony nie wołają verifyToken same. Wołają login z kontekstu, który przy okazji ustawia user, więc sidebar od razu wie, kto się zalogował.
<AuthContext value={value}>Zapis z React 19. W starszych tutorialach zobaczysz <AuthContext.Provider value={…}>; robi to samo.

Dlaczego AuthContext.js to osobny plik? Vite odświeża na żywo tylko pliki .jsx, które eksportują same komponenty. Gdyby createContext siedział w AuthProvider.jsx, lint zgłosi react-refresh/only-export-components.

4

Owiń aplikację w provider

AuthProvider jest nad routerem, bo kontekst muszą widzieć wszystkie strony i layouty. Kolejność ma znaczenie: hook może czytać tylko kontekst, który jest wyżej w drzewie.

5

Strona, na którą prowadzi link

useSearchParams()Czyta część adresu po ?. Z /auth/verify?token=abc&next=/ustawienia wyciąga token i next.
safeRedirect(…get('next'))Adres z linku mógł zostać podmieniony. Sprawdzamy go jeszcze raz, zanim po nim przekierujemy.
useRef(false)ref to pudełko, które przetrwa rendery i nie wywołuje nowego renderu po zmianie. Przy drugim przebiegu StrictMode hasStarted.current już jest true, więc nie zużyjemy tokenu drugi raz.
login(token ?? '')Bez tokenu w adresie wysyłamy pusty napis. „Serwer” odpowie błędem i pokażemy „Nie udało się zalogować”.
navigate(…, { replace: true })replace podmienia wpis w historii. Strzałka „wstecz” z panelu nie wróci na zużyty link.
6

Pokaż, kto jest zalogowany

W sidebarze i nagłówku panelu. Pisz na razie user?.name ze znakiem zapytania: do sesji 18 da się wejść na /panel bez logowania, a wtedy user to null i zwykłe user.name wywali stronę.

✓

Gotowe, gdy

  • Po kliknięciu linku jesteś na /panel z „Cześć, Maciej”, a w sidebarze „Zalogowano jako Maciej”.
  • Po odświeżeniu nadal jesteś zalogowany.
  • Ponowne otwarcie tego samego linku pokazuje „Nie udało się zalogować”.
  • Link dla nowego adresu z imieniem „Ola” zakłada konto (widać je w users) i wita „Cześć, Ola”. Panel Oli pokazuje na razie Lunę i Mruczka; filtr po właścicielu dojdzie w sesji 18.
  • W konsoli brak błędów mimo StrictMode.
feat(auth): weryfikacja linku i kontekst zalogowanego użytkownika
Sesja 18~1,5 hlogowanie · krok 3 z 4

Panel tylko dla zalogowanych, powrót po logowaniu i wylogowanie

Niezalogowany trafia z /panel na /login, po zalogowaniu wraca tam, skąd przyszedł; „Wyloguj się” działa.

<Navigate>Przekierowanie zapisane jako komponent. Wyrenderowanie <Navigate to="/login" /> od razu zmienia stronę. Używasz go w JSX, a navigate() w obsłudze zdarzeń.
replaceZamiast dopisywać nowy wpis do historii, podmienia bieżący. Bez tego „wstecz” z /login wróciłoby na /panel, które znów przekierowuje: pętla.
Strażnik tras (layout route)Trasa bez path z element: <RequireAuth />. Albo przepuszcza dzieci przez <Outlet />, albo przekierowuje. Jedna linijka chroni całą grupę stron.
Open redirectGdyby next mógł być dowolnym adresem, ktoś wysłałby link „zaloguj się w PeTrace” kończący się na stronie-podróbce. safeRedirect przepuszcza tylko ścieżki wewnątrz aplikacji.
401Kod HTTP „nie wiem, kim jesteś”. Funkcje tylko dla zalogowanych rzucają go, gdy nie ma sesji. 403 znaczyłoby „wiem, kim jesteś, ale nie wolno ci”.
Najpierw nawigacja, potem wylogowanieGdy najpierw usuniesz użytkownika, RequireAuth zdąży zobaczyć brak sesji i przerzuci Cię na /login, a nie na stronę główną.
1

Strażnik tras

if (isLoading) return …SpinnerNajważniejsza linijka. Bez niej odświeżenie dowolnej strony panelu zawsze kończyłoby się na /login, bo przez chwilę user jest null.
state={{ from: location }}Zapamiętuje cały adres (ścieżkę i ?tab=…), na który ktoś próbował wejść. LoginPage go odczyta.
<Outlet />„Tu wstaw dziecko”. Zalogowany widzi OwnerLayout z właściwą stroną, tak jakby strażnika nie było.

W routerze grupa właściciela ma już miejsce na strażnika (komentarz // RequireAuth). Wystarczy dodać element:

2

Każdy widzi tylko swoje zwierzęta

Dopisz requireUser do client.js (pod findSessionUser) i użyj go w getPets. TODO z ostatnich poprawek możesz usunąć.

requireUser(db)Każda funkcja panelu zaczyna od tego samego: „kto pyta?”. To przyszły middleware requireAuth w Expressie.
.filter(… ownerId === user.id)Odpowiednik WHERE owner_id = $1. Filtrujemy na „serwerze”, a nie na stronie: przeglądarka nigdy nie dostaje cudzych danych.
.map(toPet)Usuwa ownerId i dokleja kod zawieszki. Strona dostaje dokładnie kształt z dokumentacji API.
3

Logowanie pamięta, skąd przyszedłeś

W LoginPage zamień const next = null na odczyt z location.state i dodaj przekierowanie dla zalogowanych.

location.state?.fromTo, co RequireAuth zapisał w state. Wejście na /login z palca daje undefined i wtedy po zalogowaniu idziemy do /panel.
pathname + searchŚcieżka plus parametry: /zwierzeta/luna + ?tab=health. Wrócisz dokładnie na tę zakładkę.
if (user) return <Navigate …>Zalogowany nie ma czego szukać na /login. Musi stać po wszystkich hookach: hooki zawsze wywołujemy w tej samej kolejności, bez wcześniejszego return.
requestMagicLink(email, { name, next })next trafia do linku w mailu. Powrót zadziała nawet wtedy, gdy link otworzysz w innej karcie.
4

Ustawienia z przyciskiem „Wyloguj się”

Na razie tylko wylogowanie. Formularz z imieniem i telefonem dopiszesz w sesji 23.

Teraz user w panelu zawsze istnieje, więc w Sidebar i DashboardPage możesz wrócić z user?.name do user.name.

✓

Gotowe, gdy

  • Wylogowany wchodzi na /ustawienia → ląduje na /login → „Symuluj kliknięcie” → wraca na /ustawienia.
  • Odświeżenie zalogowanej strony nie wyrzuca na /login.
  • Wylogowanie przenosi na /, a „wstecz” nie wpuszcza z powrotem do panelu.
  • Nowe konto (inny e-mail) widzi pusty panel z EmptyState.
feat(auth): ochrona panelu, powrót po zalogowaniu i wylogowanie
Sesja 19~0,5 hlogowanie · krok 4 z 4

Wchodzę na konto demo jednym przyciskiem

Na /login jest przycisk „Wejdź na konto demo”, który od razu loguje na konto Macieja. Przyda się na obronie.

Komponent, który sam decyduje, czy się pokazaćDemoLogin pyta „serwer”, czy demo jest włączone, i zwraca null, gdy nie. Strona logowania nie musi o tym wiedzieć.
Reakcja na zmianę kontekstuPo loginAsDemo() zmienia się user w kontekście, LoginPage się przerysowuje i jej <Navigate> sam przenosi do panelu. Nie wołasz navigate().
1

„Serwer”: konto demo

Dopisz na końcu api/mock/auth.js i dodaj obie funkcje do fasady api/auth.js.

getDemoLogin()W mocku zawsze włączone. Prawdziwy serwer odczyta to ze zmiennej DEMO_LOGIN i na produkcji może demo wyłączyć.
404 „Konto demo nie istnieje”Gdy usuniesz Macieja z danych, przycisk pokaże zrozumiały błąd zamiast wywalić stronę.
sessions.push + setAuthTokenTo samo co w verifyToken, tylko bez tokenu z maila.
2

Kontekst: loginAsDemo

3

Przycisk pod formularzem

useAsync(getDemoLogin, [])Twój hook z sesji 14 w nowym miejscu. data jest undefined w trakcie ładowania, więc przycisk po prostu pojawi się chwilę później.
if (!data?.enabled) return nullStoi po hookach, tak jak Navigate w LoginPage. Komponent zwracający null nic nie rysuje.
role="alert"Czytnik ekranu od razu przeczyta błąd, bez szukania go na stronie.
✓

Gotowe, gdy

  • Jedno kliknięcie na /login przenosi do panelu z Luną i Mruczkiem.
  • Wejście na /ustawienia bez sesji → „Wejdź na konto demo” → wraca na /ustawienia.
  • npm run lint i npm run build przechodzą.
feat(auth): logowanie na konto demo jednym przyciskiem
Kamień milowy M1: logowanie na danych przykładowych, ochrona tras, powrót na stronę sprzed logowania
Dodatek

Pułapki i rzeczy, których nie kopiujemy

React Router v7
Importy z react-router, nie z react-router-dom.
Tailwind v4
Nie ma tailwind.config.js. Konfiguracja jest w @theme w CSS.
Nie przepisuj
SketchBox.jsx ani fromSeed i describeHealth z mocks/db.js.
Ścieżki
Prototyp ma frontend w korzeniu. Ty masz go w petracer-final/, a od sesji 36 w client/.
Incognito w trybie mock
To „inny komputer” z osobnym localStorage. Wspólne dane będą dopiero w trybie http.
Express 5, nie 4
Błąd rzucony w funkcji async sam trafia do obsługi błędów. Nie potrzebujesz try/catch + next(error) jak w starych tutorialach.
SQL zawsze z parametrami
$1, $2. Nigdy nie sklejaj zapytania z danymi od użytkownika.
Schemat bazy wczytuje się raz
Po zmianie 001_init.sql: docker compose -f docker-compose.dev.yml down -v i up -d.
Hasło do bazy
Ustawia się tylko przy pierwszym starcie. Zmiana później daje password authentication failed; pomaga down -v (kasuje dane).
Port 5434, nie 5432
Na Windowsie często działa już systemowy PostgreSQL.
Dwa projekty na serwerze
Prototyp ma name: petrace, Twój name: pettrace, inaczej Docker pomiesza kontenery.