Lesson 11 Guide — Sign In, localStorage, and Context¶
Goal: by the end of this lesson you have a working Sign In page that posts to the login endpoint, stores the signed-in Staff object in localStorage, and shares it across the whole app through Context — so the header shows the current user, the nav/actions adapt to their role, and the Cancel Order button is disabled when the signed-in staff member didn't take the order.
The guide is a worked example — the lab builds something different. Sign In/Context is a named exception with no second TableServe entity to repeat it on, so you build it once here, alongside the instructor. It pairs conceptually with the Lesson 7 form (Sign In is a small react-hook-form) and completes the auth flow the whole app depends on. This lesson's lab then builds the Order create/edit form — the last missing CRUD screen — which applies the Context you build here.
The general pattern you're learning: there is no JWT/token in this app. Login
returns the Staff object as JSON; the front end strips the password, stores the rest in
localStorage, and puts it in Context. "Signed in" simply means the context
value is not null. Role flags on that object (isManager, isAdmin) drive conditional
UI — client-side only.
Security note — intentional simplification. Everything here is client-side. The API endpoints are wide open (no
[Authorize], no tokens) by design for this course. Don't add JWT or server-side auth — the goal is understanding the flow, not production security. Passwords are bcrypt-hashed server-side (seed password:test1234).How to use this guide. Sections headed ▶ Code along are ones you build into your project (each ends with a quick Save and check); unmarked sections are concept. Each code block carries its file name as a title bar.
Prerequisite: your API is running on the HTTP profile (
http, nothttps) with Staff seed data loaded — you need a real username/password to sign in (test1234).
1. What "signed in" means here¶
POST /api/staff/login → { id, username, firstName, lastName, isManager, isAdmin, … }
strip password ─┐
▼
store the rest in localStorage + set it in Context
signed in = context value is not null
signed out = context value is null / cleared
No token is issued or sent with later requests. The stored Staff object is the session. Refreshing the page reads it back from localStorage so the user stays signed in.
2. ▶ Code along — Context: sharing the user across the tree¶
(Read this first — Context is the lesson's big new idea.)
The signed-in staff member is needed all over the tree: the Header shows their name, the
nav hides admin-only links, the Order Detail page checks who took the order. Those components
are scattered, so passing the user down as a prop would mean threading it through every
component in between — even ones that don't use it. That's prop-drilling, and it gets
tedious and brittle fast.
React Context is the built-in fix: you wrap part of the component tree in a Provider
that holds a value, and any component inside — however deep — reads it directly with the
useContext hook, no props passed. Think of it as a value scoped to a branch of the tree
that all descendants can reach into. You'll make one context for the signed-in staff and
provide it at the very top (in App), so the whole app can read it. You'll build App and its
pieces first, then wire them into the router at the end of this section — so everything the
router references already exists and the edit compiles straight away.
Start with the Context, defined in App.tsx. Three pieces do the work: createContext
makes the context object; a Provider (added to App next) supplies the value; and
useContext reads it from the nearest Provider above. You'll wrap useContext in a small
useStaffContext() hook so components just call that. App.tsx has sat unused since
Lesson 5 — it still holds the Lesson-3 page render — so select all and replace the whole
file with this (the CSS imports come back with it):
import "bootstrap/dist/css/bootstrap.min.css"; // back in App now (it left the tree in Lesson 5)
import "./App.css";
import { Outlet } from "react-router-dom";
import { createContext, useContext, useState } from "react";
import { Toaster } from "react-hot-toast";
import { IStaff } from "./staff/IStaff";
export interface StaffContextType {
staff: IStaff | undefined;
setStaff: React.Dispatch<React.SetStateAction<IStaff | undefined>>;
}
const StaffContext = createContext<StaffContextType | undefined>(undefined);
export function useStaffContext(): StaffContextType {
const staffContext = useContext(StaffContext);
if (staffContext === undefined) throw new Error("context not found");
return staffContext;
}
- The
useStaffContextwrapper throws if used outside the Provider — a friendly guard so a missing Provider fails loudly instead of silently returningundefined. - The context holds the
staffobject and itssetStaffsetter, so any component can read or change who's signed in (Sign In sets it; Sign Out clears it).
Providing the value (and reading localStorage on startup)¶
App holds the state and wraps the app in the provider. It seeds state from
localStorage so a refresh keeps you signed in. Add this below the context code you just
wrote, in the same file:
... // the imports, StaffContextType, StaffContext, and useStaffContext from above
+ function getPersistedStaff() {
+ const staffAsJSON = localStorage.getItem("staff");
+ if (!staffAsJSON) return undefined;
+ return JSON.parse(staffAsJSON);
+ }
+
+ function App() {
+ const [staff, setStaff] = useState<IStaff | undefined>(getPersistedStaff());
+ return (
+ <StaffContext.Provider value={{ staff, setStaff }}>
+ <Toaster />
+ <Outlet />
+ </StaffContext.Provider>
+ );
+ }
+
+ export default App;
Everything under the provider (the whole app) can now call useStaffContext().
What's <Toaster /> doing here? It's react-hot-toast's render target — the one component
that actually draws toasts on screen. Every toast.success(…) / toast.error(…) call just adds
a message to the library's queue; without a <Toaster /> mounted somewhere in the tree, that
queue has nowhere to render and nothing appears. You've been writing those calls since
Lesson 7 (the Menu Item form's save and error handlers, then Lessons 8–10) and seeing no
pop-ups — this is why. Mount it once, near the root, and every call anywhere in the app
renders through it. Lesson 12 is the full treatment: it restyles this <Toaster /> in the
app's colors and pairs it with centralized fetch error handling.
App is about to be back in the route tree, so its CSS imports run again — take the two you
parked in Layout in Lesson 5 back out, or every page loads Bootstrap twice:
- import "bootstrap/dist/css/bootstrap.min.css";
- import "./App.css";
import Header from "./Header";
import AppNav from "./AppNav";
...
The index redirect¶
You'll wire an index route to an IndexPage that redirects by sign-in state — it needs
only the Context above, so build it now (at the app root):
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useStaffContext } from "./App";
function IndexPage() {
const navigate = useNavigate();
const { staff } = useStaffContext();
useEffect(() => {
if (!staff) navigate("/signin");
else navigate("/orders");
}, []);
return null;
}
export default IndexPage;
The router you wire next imports SignInPage, which you build for real in §3. Add a
one-line placeholder now so that edit — and the app — compiles and runs:
// Placeholder — you build the real Sign In form in §3.
function SignInPage() {
return <h1>Sign In</h1>;
}
export default SignInPage;
With App, IndexPage, and the Sign In placeholder all in place, wire them into the
router — every element it references now exists, so the edit compiles straight away. In Lesson
5 the tree was rooted at Layout with a single Outlet; wrap that in an outer App route
so the Provider is live and Sign In can sit outside the shell:
const router = createBrowserRouter([
+ {
+ path: "/",
+ element: <App />, // outer wrapper — holds Context + Toaster
+ errorElement: <ErrorPage />, // moved up here from the Layout route
+ children: [
+ { path: "signin", element: <SignInPage /> }, // sibling of Layout → no shell
{
element: <Layout />,
- errorElement: <ErrorPage />,
children: [
+ { index: true, element: <IndexPage /> },
{ path: "orders", element: <OrdersPage /> },
{ path: "menuitems", element: <MenuItemsPage /> },
...
],
},
+ ],
+ },
]);
Now there are two Outlets, each owned by a different route level: App's Outlet holds
either SignInPage (no shell) or Layout; Layout's Outlet holds the active page (with the
shell). errorElement moves up to the App route.
Save and check
- Open
/signed out —IndexPageredirects you to/signin. - The Sign In placeholder renders with no header or nav — it's outside
Layout. - Open
/ordersand/menuitems— both still load inside the shell. - Console is clean — no "context not found" error.
- Edit a menu item and Save — the green "Successfully saved." toast finally appears. That's
the Lesson 7
toast.successcall, rendering for the first time now that<Toaster />exists.
Not yet: the signed-in → /orders redirect — you'll see that in §3, once you can sign in.
3. ▶ Code along — The Sign In page¶
Sign In sits outside the Layout (no header/nav) — the signin route you added to
the router in §2 is a sibling of the Layout route, so it renders in App's Outlet
without the shell. That's this line, already in your main.tsx — nothing to add:
Sign In needs a login call, so add findByAccount to StaffAPI first — the page you build
next calls it (plain fetch with an inline status guard until Lesson 12):
export const staffAPI = {
... // list, find, post, put (earlier lessons)
+ findByAccount(username: string, password: string): Promise<IStaff> {
+ return fetch(`${url}/login`, {
+ method: "POST",
+ body: JSON.stringify({ username, password }),
+ headers: { "Content-Type": "application/json" },
+ }).then((response) => {
+ if (!response.ok) throw new Error("Login failed"); // wrong credentials → reject
+ return response.json();
+ });
+ },
};
Why the explicit
if (!response.ok) throw?fetchonly rejects on a network failure, not on a 400/401 — so without this check a wrong password would resolve, not throw, and thesigninhandler'scatchwould never run. You've now written this same guard in a few modules; Lesson 12 extracts it into a sharedcheckStatushelper and drops it into every API module at once.
Sign In is a centered card on a soft gradient background — and that gradient is the only
thing on the page Bootstrap has no utility for, so it's a custom class. Add it to App.css
next to the .skeleton rules from Lesson 6:
.skeleton { /* … Lesson 6 … */ }
.skeleton-text { /* … Lesson 6 … */ }
+ .signin {
+ min-height: 100vh;
+ background: radial-gradient(
+ ellipse at center,
+ rgba(255, 243, 230, 1) 0%,
+ rgba(255, 250, 245, 0.5) 70%,
+ rgba(255, 255, 255, 1) 100%
+ );
+ }
Now build the real Sign In page — a small react-hook-form (Lesson 7 pattern) inside that
card. Replace the whole §2 placeholder file with this. The logo <svg> is the same one
already converted for you in the Lesson-5 Header (note the JSX conversion: viewBox keeps its
camelCase, width={100} is a number in braces, and xmlns stays as-is):
import { useForm, SubmitHandler } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { staffAPI } from "../staff/StaffAPI";
import { useStaffContext } from "../App";
import { IStaff } from "../staff/IStaff";
interface IAccount { username: string; password: string; }
function persistStaff(staff: IStaff) {
localStorage.setItem("staff", JSON.stringify(staff));
}
function SignInPage() {
const navigate = useNavigate();
const { setStaff } = useStaffContext();
const { register, handleSubmit, formState: { errors } } = useForm<IAccount>({
defaultValues: async () => ({ username: "", password: "" }),
});
const signin: SubmitHandler<IAccount> = async (account) => {
try {
const { password: _, ...safeStaff } = await staffAPI.findByAccount(
account.username, account.password
);
persistStaff(safeStaff as IStaff); // localStorage
setStaff(safeStaff as IStaff); // context
navigate("/orders");
} catch (error: any) {
toast.error("Unsuccessful sign in. Please try again.");
}
};
return (
<main className="signin d-flex flex-column gap-4 justify-content-center align-items-center">
<svg width={100} height={78} viewBox="0 0 78 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M55.5 0H77.5L58.5 32H36.5L55.5 0Z" fill="#FF7A00" />
<path d="M35.5 0H51.5L32.5 32H16.5L35.5 0Z" fill="#FF9736" />
<path d="M19.5 0H31.5L12.5 32H0.5L19.5 0Z" fill="#FFBC7D" />
</svg>
<span className="mx-2 fw-semibold" style={{ color: "#FF7A00" }}>TableServe</span>
<div className="card w-25 p-4">
<h4 className="card-title">Sign in</h4>
<form className="d-flex flex-column" onSubmit={handleSubmit(signin)}>
<div className="mb-3">
<label htmlFor="username" className="form-label">Username</label>
<input id="username" type="text"
{...register("username", { required: "Username is required" })}
className={`form-control ${errors?.username && "is-invalid"}`} />
<div className="invalid-feedback">{errors?.username?.message}</div>
</div>
<div className="mb-1">
<label htmlFor="password" className="form-label">Password</label>
<input id="password" type="password"
{...register("password", { required: "Password is required" })}
className={`form-control ${errors?.password && "is-invalid"}`} />
<div className="invalid-feedback">{errors?.password?.message}</div>
</div>
<div className="mb-4 form-text">
<a href="#">Forgot It?</a>
</div>
<div className="d-grid gap-2">
<button className="btn btn-lg btn-primary">Sign in</button>
</div>
</form>
</div>
</main>
);
}
export default SignInPage;
const { password: _, ...safeStaff } = …— this is destructuring (the{ }is on the left of=, so you're pulling values out of the returned object, not building one). It strips the password:passwordis pulled off into a throwaway variable named_(a conventional "ignore this"), and the rest element...safeStaffgathers everything else into a new object —safeStaffis created right here, equal to the staff record minus its password. That safe copy is what you store; never store or display the password. (The...restmust come last — a JS syntax rule, not an override order.)persistStaffwrites to localStorage;setStaffupdates context;navigategoes to the app. A failed login toasts the error and stays put.- The page shell is all Bootstrap you already know:
.signin(your new gradient) plus flex utilities center the logo and card, andd-gridmakes the button full-width. Forgot It? is a placeholder link — no functionality required.
Save and check
- Go to
/signin— the centered card on the orange gradient, logo above it, and the real form in place of the §2 placeholder. - Sign in with a seeded username and
test1234— you land on/orders. - Open DevTools → Application → Local Storage — a
staffentry with nopasswordfield. - Open
/—IndexPageforwards you to/orders(you're signed in now). - Enter a wrong password — the error toast shows and you stay on the page.
Not yet: the signed-out → /signin branch — §4's Sign out gives you that (or clear the
staff entry by hand).
4. ▶ Code along — The header: reading context and signing out¶
Extend the logo-only Header provided in Lesson 5 to read the context: show the current
user in a react-bootstrap Dropdown (the same menu component as Lesson 6's 3-dots
actions) when signed in, or a Sign in button when signed out:
- import { Link } from "react-router-dom";
+ import { Link, useNavigate } from "react-router-dom";
+ import Dropdown from "react-bootstrap/Dropdown";
+ import { useStaffContext } from "./App";
function Header() {
+ const { staff, setStaff } = useStaffContext();
+ const navigate = useNavigate();
+
+ function signout() {
+ localStorage.removeItem("staff"); // clear storage
+ setStaff(undefined); // clear context → "signed out"
+ navigate("/signin");
+ }
return (
<header>
<div className="navbar bg-body-tertiary py-4 border-bottom">
<div className="container-fluid">
... {/* the logo Link — provided in Lesson 5 */}
+ {staff ? (
+ <Dropdown className="me-4">
+ <Dropdown.Toggle as="a" variant="light"
+ className="d-flex text-secondary align-items-center text-decoration-none">
+ <div
+ style={{ width: "3rem", height: "3rem" }}
+ className="d-flex bg-primary-subtle fs-5 text-secondary align-items-center justify-content-center rounded-circle me-2"
+ >
+ {staff?.firstName.substring(0, 1)}{staff?.lastName.substring(0, 1)}
+ </div>
+ <strong> {staff?.firstName} {staff?.lastName}</strong>
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item href="#">Settings</Dropdown.Item>
+ <Dropdown.Item href="#">Profile</Dropdown.Item>
+ <Dropdown.Divider />
+ <Dropdown.Item as="button" onClick={signout}>Sign out</Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ ) : (
+ <Link to="/signin" className="btn btn-primary">Sign in</Link>
+ )}
</div>
</div>
</header>
);
}
{staff ? (…user dropdown…) : (…Sign in button…)} — the whole header adapts to whether
the context value is null. Sign Out clears both localStorage and context; the ternary
then flips to the Sign In button.
Two details in the toggle worth naming. as="a" renders the toggle as a link rather than
react-bootstrap's default btn btn-primary — you want the user's name, not a blue button. And
the avatar circle is a fixed-size flex box (3rem square, rounded-circle,
bg-primary-subtle) centering the two initials, which substring(0, 1) pulls off the first and
last name. (Making that same avatar work on the Staff cards is a stretch challenge — the
card version is yours to solve.)
Save and check
- Signed in — the header shows your initials in a circle beside your name, with a dropdown.
- Click Sign out — the header flips to the Sign in button, the
staffentry disappears from Local Storage, and you land on/signin.
5. ▶ Code along — Role-based conditional UI¶
The role flags on the Staff object drive what the UI offers — the same conditional
rendering you've used all pass, keyed on staff.isManager / staff.isAdmin. TableServe's
rules, in full:
| Signed-in staff member | Can reach / do |
|---|---|
| Anyone signed in | The Orders nav link, order detail, and the workflow buttons; can Cancel an order they took |
isManager |
Additionally: Cancel any order, not just their own |
isAdmin |
Additionally: the three maintenance nav links — Menu Items, Categories, Staff |
Two different jobs, and it's worth seeing the split: isAdmin gates navigation (whole
screens disappear from the nav), while isManager gates one action on a screen everyone
can already see. That's the same split PRS uses — isAdmin hides the Vendors/Products/Users
maintenance pages, isReviewer controls the Approve/Reject buttons on a Request everyone can
open.
Start with the nav. In AppNav, read the context and wrap an admin-only item in
{staff?.isAdmin && …}. The guide wires one link — Staff — so you see the shape; the
lab's stretch challenge repeats the identical wrap on Menu Items and Categories
(and on those pages' Add/Edit buttons):
+ import { useStaffContext } from "./App";
...
function AppNav() {
const location = useLocation();
+ const { staff } = useStaffContext();
...
return (
<Nav /* … */>
... // Orders — visible to everyone
+ {staff?.isAdmin && (
<Nav.Item as="li">
<Nav.Link eventKey="/staff" as={Link} to="/staff">
... // icon + "Staff"
</Nav.Link>
</Nav.Item>
+ )}
...
</Nav>
);
}
The Cancel ownership check¶
The headline role rule: you can cancel an order you took — and a manager can cancel
anyone's. Two conditions, one button. On the Order Detail page, read the context, compute
ownership, and add disabled to the Cancel Order button you built in Lesson 9:
... // the Lesson 8–10 imports
+ import { useStaffContext } from "../App";
function OrderDetailPage() {
+ const { staff } = useStaffContext(); // the signed-in user
...
+ const isOwnOrder = order?.staffId === staff?.id;
+ const canCancel = isOwnOrder || staff?.isManager;
return (
...
<button
className="btn btn-outline-danger"
onClick={openCancel}
+ disabled={!canCancel}
>
Cancel Order
</button>
...
);
}
disabled={!canCancel} greys the button out unless the order's staffId matches the
signed-in staff's id or that staff member is a manager. Signed out, both are false, so
it's disabled.
This is the same mechanism PRS uses for Approve/Reject — compare the record's user id to the signed-in user's, combine with a role flag — but watch what the answer is used for: PRS disables Approve/Reject on your own request (a reviewer can't approve their own spending), while TableServe enables Cancel on your own order (you clean up your own mistakes). Identical comparison, opposite effect: the pattern transfers, the business rule doesn't.
Save and check
- Open a Preparing order you took — Cancel Order is enabled.
- Open one someone else took, signed in as a non-manager — Cancel Order is greyed out.
- Sign in as a manager and open that same order — Cancel Order is enabled again.
- The Staff nav link shows for an admin, and is gone for a non-admin.
6. Verifying in the browser¶
You've checked each piece as you built it; this is the full pass. With your API running and
npm run dev up:
- Open the app signed out — you land on
/signin(the index redirect). The header shows a Sign in button. - Sign in with a seeded username and
test1234. You go to/orders; the header now shows the user's initials + name. - Open DevTools → Application → Local Storage — there's a
staffentry with the user object and nopasswordfield (it was stripped). Refresh the page — you stay signed in (state seeded from localStorage). - Open a Preparing order you took — Cancel Order is enabled. Open one someone else took — it's disabled. Sign in as a manager and open that same order — it's enabled again.
- Sign in as an admin vs. a non-admin and confirm the Staff nav link appears only for the admin.
- Click Sign out — the header flips to Sign in, localStorage's
staffis gone, and you're back at/signin. - Try a wrong password — the error toast shows and you stay on Sign In.
The General Pattern (what to take away)¶
- No JWT — login returns the user object; strip the password, store the rest in localStorage, and put it in Context. Signed in = context value not null.
- Context (
createContext+ auseStaffContexthook + aProviderinApp) shares the user across the tree without prop-drilling; seed it from localStorage so refresh persists. - Sign Out clears localStorage and context.
- Role flags drive conditional UI, at two different scopes: one flag hides whole
screens from the nav (
{staff?.isAdmin && …}), the other gates a single action on a shared screen (staff?.isManager). - An ownership check (
order.staffId === staff.id) decides what you may do to your own records — combined with the role flag, not instead of it.
On PRS: login stores the User object the same way; isAdmin gates the maintenance pages
(Vendors, Products, Users); isReviewer gates Approve/Reject on a Request page anyone can
open; and the same ownership comparison appears again — there to disable Approve/Reject on
a reviewer's own request, the mirror image of this Cancel rule.
Build Steps¶
- Replace all of
App.tsx: the CSS imports (Bootstrap +App.css, moved back fromLayout),StaffContext, theuseStaffContexthook,getPersistedStaff(reads localStorage), and anAppthat wraps<Outlet />in<StaffContext.Provider>seeded from localStorage — with<Toaster />mounted alongside theOutlet, which is what finally renders thetoastcalls you've written since Lesson 7. Delete those same two CSS imports fromLayout.tsxso they don't load twice. - Build
IndexPage(app root) — redirect by sign-in state (signed out →/signin, signed in →/orders) — and a one-lineSignInPageplaceholder, so everything the router will reference already exists. - Now wire the router in
main.tsx: wrap the Lesson-5 tree in{ path: "/", element: <App />, errorElement: <ErrorPage />, children: [...] }, moveerrorElementup to it, addsigninas a sibling ofLayout, and anindexroute →IndexPageunderLayout. Everything it references (App,IndexPage, theSignInPageplaceholder) now exists, so it compiles. - Add
findByAccount(username, password)toStaffAPI.ts(POST/api/staff/login). - Add the
.signingradient rule toApp.css, then replace the placeholderSignInPage(outsideLayout) with the real page — logo, centered card, and a react-hook-form that logs in, strips the password via destructuring,persistStaff+setStaff, thennavigate("/orders"); error toast on failure. - Update
Headerto read context: user dropdown with an initials avatar + Sign out (clears localStorage + context) when signed in, else a Sign inLink. - Wrap the Staff nav item in
AppNavin{staff?.isAdmin && …}, and onOrderDetailPagecomputecanCancel = isOwnOrder || staff?.isManagerand setdisabled={!canCancel}on Cancel Order. - Verify in the browser using section 6 — sign in/out, password-free localStorage, refresh persistence, the admin-only Staff link, and Cancel enabled for your own orders plus any order when you're a manager.