Old-School Front-End / 2026 ・ No.11

Old-School Front-End

Part 11: signing in and moving between screens, asking "who are you?" on every page

Published

My claims, signed in as Taro Arai (claimant). On the right of the header are the name and a Sign out button. The menu has only My claims and Dashboard. This month: 5 claims, ¥79,251 awaiting approval, 1 sent back. The top row is 0012, New York trip, 46,851, Submitted
This part's goal: the signed-in person's name, and only that person's claims. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The accounts are made up for this demo. The browser frame was drawn in for illustration.)

An "old-school" front-end course: no AI doing it for you, every line typed by hand. Part 11 is about signing in and moving between screens.
Until now, "Taro Arai (claimant)" in the header was just decoration written into the HTML.
This time we build a sign-in page, and whichever page you open (My claims, the claim form or Approvals), it first asks the server "who are you?".
We'll also try out the difference between hiding things on the page and refusing them on the server.

1

This part's goal

AraiLast time I got a claim number! But Furui, who is that claim supposed to be from?

FuruiNobody, for now. "Taro Arai" in the header is still just text written in the HTML.

AraiSo if I changed it to "Ichiro Furui", could I approve things in your place…?

FuruiToday we build what stops that.

You are here: part 11

1 Setup 2 HTML 3 Forms 4 CSS basics 5 Layout 6 Bootstrap 7 JS basics 8 DOM, events 9 Validation 10 Fetch 11 Sign-in 12 Approvals 13 Charts, print 14 jQuery 15 Finish You are here Setup HTML CSS JavaScript Real app Finish
  • A sign-in page — identify yourself with an email address and password. If it's wrong, say so without saying which part
  • Check on every page — My claims, the claim form and Approvals first ask the server who you are. Not signed in? Off to the sign-in page, then back to where you were
  • Different for different people — My claims shows only your own claims. Only approvers see the Approvals menu, and the server only gives approval data to approvers
  • Sign out — throw the session away and go to the sign-in page

The other end is last part's practice server with sign-in and lists added (not the topic). The accounts and passwords ([email protected] / demo-arai and so on) are made up for this demo.

2

A map of the screens

The sign-in page and three screens

login.html the way in index.html My claims auth.js form.html claim form auth.js approve.html Approvals (approvers) auth.js once signed in, back to where you were (next) any page, not signed in (401): to the sign-in page One HTML file per screen; shared work lives in auth.js Anyone can fetch the HTML. What we protect is the data from /api/…

We switch screens the old-school way: one HTML file per screen, and the menu is plain <a href> links. Click one and the browser loads the next page. The menu item for the current page carries aria-current="page", so screen readers know where you are too.

The important point here: anyone can fetch the screens (the HTML files) themselves. I tried from a browser that wasn't signed in.

# From a browser that is not signed in (measured in Chromium)
what was fetched status
/approve.html (the page HTML) 200   anyone can get it
/api/me 401   not signed in
/api/expenses (my claims) 401
/api/approvals (waiting list) 401

The Approvals HTML comes back with 200. Its table is empty, because the data is fetched from /api/… and filled in. What needs protecting isn't the screen but the data the server returns. Everything in this part follows from that.

3

How signing in works

The password once; after that, the session ID

browser server POST /api/login email address, password 200 OK Set-Cookie: sid=…; HttpOnly GET /api/me Cookie: sid=… 200 OK {"name": …, "role": …} keeps the cookie who is who sid → arai Send the password once; after that, the session ID says who you are The browser attaches the cookie itself; no JavaScript needed

When you sign in successfully, the server adds Set-Cookie to its reply and hands the browser a session ID, a kind of password-of-the-day. The browser keeps it as a cookie and attaches it to every request to the same site. The server looks it up in its own table: "this one is Arai".

Cookie flagMeaning
HttpOnlyJavaScript can't read it (tried in section 8)
SameSite=Laxnot sent with, for example, POSTs coming from other sites
Path=/sent with requests to any page on this site
Max-Age=28800thrown away after 8 hours (then sign in again)

On a real https site you also add Secure (only sent over encrypted connections). The practice server uses plain http, so it's left out.

4

Building the sign-in page

<form id="login-form">
  <div>
    <label for="email">Email address</label>
    <input type="email" id="email" name="email" autocomplete="username" required>
  </div>
  <div>
    <label for="password">Password</label>
    <input type="password" id="password" name="password" autocomplete="current-password" required>
  </div>
  <p id="login-error" role="alert" hidden></p>
  <button type="submit">Sign in</button>
</form>
  • type="password" — hides what you type behind dots
  • autocomplete — with username and current-password, the browser and password managers know these are the sign-in fields and can save and fill them
  • Leave the checks to the browser — with just two fields, part 9's required bubbles are enough. Only long forms like the claim form need the extra effort
// Expense System: the sign-in page (part 11)
// Once signed in, go back to the page they came from (next)

const loginForm = document.querySelector('#login-form');
const loginError = document.querySelector('#login-error');

// When the sign-in button is pressed
loginForm.addEventListener('submit', async (event) => {
  event.preventDefault();
  loginError.hidden = true;
  const button = event.submitter;
  button.disabled = true;
  try {
    const res = await fetch('/api/login', { method: 'POST', body: new FormData(loginForm) });
    if (res.status === 401) { // deliberately don't say whether the email or the password was wrong
      loginError.textContent = 'The email address or password is incorrect.';
      loginError.hidden = false;
      loginForm.elements.password.select(); // select the password so it's easy to retype
      return;
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const next = new URLSearchParams(location.search).get('next');
    location.replace(safeNext(next, location.origin)); // replace: Back won't return to the sign-in page
  } catch (error) {
    loginError.textContent = "Couldn't sign in. Check your connection and try again.";
    loginError.hidden = false;
  } finally {
    button.disabled = false;
  }
});

The message on failure is "The email address or password is incorrect". It deliberately doesn't say which. "Wrong password" would tell a stranger that the email address is registered. I checked that a wrong password and a wrong email address produce the same message (see the table in section 9).

5

The trap in "go back to"

If you were sent to the sign-in page on your way to the claim form, you'd like to land on the claim form after signing in. So we put the destination in the sign-in page's URL.

/login.html?next=%2Fform.html

But anyone can rewrite next. Send someone a link with ?next=https://evil.example/ and right after signing in they'd be taken to a convincing fake site (a trap known as an open redirect). It's tempting to think "starts with / means same site", but try it…

The parts of a URL, and same site or not

The parts of a URL http://127.0.0.1:3000 origin which site /approve.html pathname which page ?tab=1 search extras How the browser reads next (measured) /form.html → origin http://127.0.0.1:3000 //evil.example/x → origin http://evil.example /\evil.example/x → origin http://evil.example Don't judge by the text; parse with new URL() and compare origins
# safeNext results (measured in Chromium; the page is http://127.0.0.1:3000)
next read as a URL goes to
/form.html (same site)/form.html /form.html
/approve.html?tab=1 (same site)/approve.html?tab=1 /approve.html?tab=1
(empty) (same site)/index.html /index.html
https://evil.example/ https://evil.example/ /index.html   <- another site
//evil.example/x http://evil.example/x /index.html   <- another site
/\evil.example/x http://evil.example/x /index.html   <- another site
javascript:alert(1) javascript:alert(1) /index.html   <- another site

Both //evil.example/x and /\evil.example/x, with a backslash after the slash, start with /, yet the browser read them as another site. So safeNext doesn't judge by the text: it parses with new URL() exactly as the browser would, then checks that the origin (which site) is the same. If not, it goes to My claims.

One more thing: after signing in we move on with location.replace().

# Moving on after signing in (measured in Chromium)
how history pressing Back goes to
location.assign() 3 the sign-in page
location.replace() 2 before the sign-in page (about:blank)

With assign (like a normal link) the sign-in page stays in the history, and Back brings it up again. replace swaps out the current history entry, so the sign-in page isn't left behind.

6

"Who are you?" on every page

The work every page shares lives in js/auth.js. My claims, the claim form and Approvals all call requireLogin() first.

// Expense System: checking who is signed in and filling the header (part 11)
// Loaded first on every page. Not signed in? Off to the sign-in page

const ROLE = { applicant: 'claimant', approver: 'approver' };

/** Is this a safe place to go back to? Only paths on this site are allowed */
function safeNext(next, origin) {
  try {
    const url = new URL(next || '/index.html', origin); // no next: go to the claim list
    if (url.origin !== origin) return '/index.html';     // other sites are not allowed
    return url.pathname + url.search;
  } catch {
    return '/index.html';
  }
}

/** Ask who is signed in. If nobody, send them to the sign-in page */
async function requireLogin() {
  const res = await fetch('/api/me');
  if (res.status === 401) { // 401 = not signed in
    const next = location.pathname + location.search;
    location.replace(`/login.html?next=${encodeURIComponent(next)}`);
    return new Promise(() => {}); // don't run anything else until the page changes (a ticket that never pays out)
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const me = await res.json();
  showUser(me);
  return me;
}

/** Show the name in the header and hide menus they can't use */
function showUser(me) {
  document.querySelector('#user-name').textContent = `${me.name} (${ROLE[me.role]})`;
  for (const el of document.querySelectorAll('[data-role]')) {
    el.hidden = el.dataset.role !== me.role; // hiding only; the server does the protecting (section 7 of part 11)
  }
}

// Sign out
document.querySelector('#logout')?.addEventListener('click', async () => {
  await fetch('/api/logout', { method: 'POST' });
  location.replace('/login.html');
});
  • 401 means the sign-in page — if /api/me answers 401 (not signed in), put the current location in next and go to the sign-in page
  • A ticket that never pays out — new Promise(() => {}) is a Promise whose reply never comes. Return it, and the code after the caller's await requireLogin() never runs, so no table gets drawn from empty data while the page is changing
  • The name goes in with textContent — the name from the server is shown with textContent, as agreed in part 8

Sending the claim form (part 10) now handles 401 too. If the sign-in expires while you're filling it in, nothing you typed is wiped; it just asks you to sign in again.

7

Hiding is not refusing

Only approvers need the Approvals menu. Its li carries data-role="approver" and hidden, and showUser removes hidden only for approvers.

Two headers, one above the other. Top: signed in as Taro Arai (claimant), the menu is My claims and Dashboard. Bottom: Ichiro Furui (approver), with Approvals in between
Same HTML, a different menu depending on who is signed in. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The accounts are made up for this demo. The browser frame was drawn in for illustration.)

But that's only cosmetic. With the menu hidden, typing approve.html into the address bar still opens the page (as section 2 showed, anyone can fetch the HTML). So it's the server that decides whether the approval data is handed over.

The server's checkpoint: 401 and 403

/api/approvals approval list signed in? yes approver? yes 200 the list no 401 not signed in no 403 not allowed On the page: hide the menu <li data-role="approver" hidden> … claimants don't see Approvals Hiding is a courtesy; typing the URL still opens the page The server protects. 401: go and sign in. 403: say you're not allowed
// Expense System: the approvals page (part 11)
// Approvers only. Anyone else gets a 403 from the server

async function showApprovals() {
  await requireLogin();
  const res = await fetch('/api/approvals');
  if (res.status === 403) { // 403 = signed in, but not allowed
    const note = document.querySelector('#forbidden');
    note.textContent = "You don't have permission to see this page (approvers only).";
    note.hidden = false;
    return;
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  // …
}

401 means "I don't know who you are" (go and sign in). 403 means "I know who you are, and you can't do that" (signing in again won't help, so say so). The numbers are close, but the page has to do different things.

AraiI asked the AI to "show the approve button only to approvers", and it just wrote code to hide the button.

FuruiIt did what you asked. You said "hide", so it hid. You never said "protect".

8

Where to keep the token

Articles on the web often keep the sign-in token in localStorage. I compared that with an HttpOnly cookie.

# Where it is kept (measured in Chromium)
localStorage.setItem('token', …) → another script could read it: 'secret-123'
document.cookie after signing in → ''  (JavaScript can't see an HttpOnly cookie)
then fetch('/api/me') → 200  (the browser attaches the cookie itself)

# The cookie the server set: sid (36 characters), HttpOnly, SameSite=Lax

An HttpOnly cookie and localStorage

HttpOnly cookie Set-Cookie: sid=…; HttpOnly JavaScript can't read it browser sends it itself server sets the expiry localStorage localStorage.setItem('token', …) any script could read it you attach it every time stays until deleted If XSS (part 8) runs a stranger's script, a readable token is stolen Keep it in an HttpOnly cookie; JavaScript doesn't touch it

Anything in localStorage can be read by any script running on the page. As with part 8's XSS, if a stranger's script runs even once, the token walks out the door. An HttpOnly cookie is invisible to JavaScript, yet the browser still sends it. Keeping the token away from JavaScript is the solid, old-school approach.

9

Taking it for a spin

I ran the practice server and let Playwright actually type and click. Halfway through, every session on the server was thrown away to cause an "expired" sign-in.

Three moments. 1 A wrong password on the sign-in page shows The email address or password is incorrect, with the password field selected. 2 Signed in as Ichiro Furui (approver), the menu gains Approvals and three claims are waiting. 3 When Taro Arai (claimant) opens the Approvals URL, it says he doesn't have permission
1 a wrong password, 2 signed in as an approver, 3 a claimant typing the Approvals URL. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The accounts are made up for this demo. The browser frame was drawn in for illustration.)
# Values recorded while actually using it (Chromium; practice server)
action ended up at header page
open My claims, not signed in /login.html?next=%2Findex.html - -
wrong password /login.html?next=%2Findex.html - The email address or password is incorrect.
wrong email /login.html?next=%2Findex.html - The email address or password is incorrect.
sign in as Arai /index.html Taro Arai (claimant) 5 rows, awaiting ¥79,251, sent back 1
type the Approvals URL /approve.html Taro Arai (claimant) You don't have permission to see this page (approvers only).
Save draft (sign-in expired) /form.html Taro Arai (claimant) Your sign-in has expired. Please sign in again (what you typed is still on this page).
open Approvals (still expired) /login.html?next=%2Fapprove.html - -
sign in as Furui /approve.html Ichiro Furui (approver) 3 waiting
Sign out /login.html - -
  • Opening My claims without signing in led to the sign-in page with next=%2Findex.html; after signing in it came back to My claims
  • Arai's list has only his five claims. Yamada's never left the server
  • When Arai typed the Approvals URL the page opened, but the data was a 403, and the page said he isn't allowed
  • Signed in as Furui, the Approvals menu appeared with three waiting claims (two from Arai, one from Yamada)
POINT

The real work of signing in is the server's job. To make it production-ready you'd add things like not storing passwords as they are (hash them), slowing down repeated failures, and https with Secure. The page's job is to show who is using it, guide people away from places they can't go, and explain failures in plain words. The server protects; the page guides.

§ — On one page, and what is next

  1. One HTML file per screen, switched with links. Anyone can fetch the HTML; protect the data.
  2. After sign-in, the server hands over the session in an HttpOnly cookie. The browser sends it; JavaScript doesn't touch it.
  3. Every page starts with requireLogin(). On 401, go to the sign-in page with next, and come back with location.replace.
  4. Check "go back to" by parsing with new URL() and comparing origins. "Starts with /" is fooled by // and /\.
  5. Hiding the menu is a courtesy; refusing is the server's job. 401: who are you? 403: you can't do that.

AraiMy New York trip is on your screen, Furui! Please approve it!

FuruiThere's no button yet. Next time: the approval flow. Approving and sending back, comments, and how a claim's status changes. We'll also see what happens when two people approve the same claim at the same moment.

References

The page photos show the author's demo pages, served by a practice server (Node.js) and captured on 17 October 2026 in Chromium (Playwright) in the cloud. Segoe UI isn't available there, so the screenshots fall back to other system fonts. The accounts, passwords and claims are made-up data on the practice server. The URLs reached, what the header showed, the messages, status codes, the "go back to" checks, the cookie flags, the localStorage experiment and the Back history are real values recorded by driving and running the same Chromium with Playwright. Every page's HTML was checked with the Nu Html Checker (vnu) and showed no errors. The characters are fictional.

Old-School Front-End, part 11: signing in and moving between screens / 2026.10

← Back to all posts