Build a React Dashboard for Django

Build a React dashboard that authenticates with Django REST Framework using JWT.

Introduction

30 Second Summary

Important asset details are hard to use when they arrive as a wall of raw text. A clear screen gives people the answers they need without making them decode the data first.

In this project, you will build a separate React dashboard that signs in to an existing Django REST Framework API through Simple JWT. The finished screen adapts every live backend response into a readable dashboard without relying on fixed field names.

What You'll Build

After your backend accepts the credentials, you'll see live analytics as summary cards above a responsive table that adapts to every asset field returned.

By the end of this project, you'll have:

  • A branded sign-in flow that works with the backend's default username or a custom email identifier.
  • A resilient authenticated session that survives an expired access token by refreshing it before one automatic retry.
  • A schema-adaptive live dashboard where unknown analytics keys become cards while returned asset fields become table columns.
  • Secret Mission: Add one search box that filters loaded assets across every returned field.

Are there any prerequisites?

You need a running Django REST Framework backend mounted under /api/ plus a valid backend user account. Step 1 installs Node.js with its bundled npm before you create the frontend workspace.

Before We Start

Before any hands-on work begins, this checkpoint locks in the purpose of your Asset Operations dashboard. Your answer connects its users to the live data they need from the existing Django REST Framework API.

Set Up the React Workspace

Your Django REST Framework backend already exposes the data for the dashboard. A separate browser interface still needs a workspace where its frontend tools can run.

This step prepares Node.js, React, and Vite inside Visual Studio Code. You will finish with a starter page that proves the frontend runs independently from Django.

In this step, get ready to:
  • Install Node.js 24.20.0 with its bundled npm package manager.
  • Create the asset-dashboard-frontend workspace with pinned packages.
  • Start the Vite development server and display the starter page.
Install Node.js and verify npm

Node.js runs Vite's development tools. The selected LTS release includes npm 11.19.0 for installing the pinned project packages.

  • Press the Windows key to open Windows Search.
  • Type Visual Studio Code and press Enter.
  • Press Ctrl+` to open the integrated terminal.

The terminal panel opens with PowerShell in the current VS Code window.

  • Check the installed Node.js and npm versions by running these commands:
node --version
npm --version

What do these commands check?

  • The first command prints the Node.js version available to the terminal.
  • The second command prints the npm version bundled with the installed runtime.

✔️ I see the required versions

Your terminal should show v24.20.0 for Node.js. It should show 11.19.0 for npm.

Your runtime is ready. The frontend workspace can now use the exact tool versions selected for this project.

ⓧ I see an older version

The installed runtime is below the project pin. Updating it gives Vite the Node.js version it requires.

  • Open the official Node.js release page for version 24.20.0.
  • Download the Windows 64-bit Installer.
  • Run the downloaded installer.
  • Keep npm enabled during the installation.

The installer updates the runtime used by new terminal sessions.

  • Close Visual Studio Code after the installer finishes.
  • Press the Windows key to open Windows Search.
  • Type Visual Studio Code and press Enter.
  • Press Ctrl+` to open a fresh integrated terminal.
  • Verify the updated versions by running these commands:
node --version
npm --version

What should I see?

The fresh terminal should print v24.20.0 followed by 11.19.0.

Still seeing the older version?

Confirm you restarted Visual Studio Code after installation. An existing terminal can keep the previous PATH until the editor restarts.

If the older version remains, help me check which Node.js installation PowerShell is using.

ⓧ Command not found

The missing command means the Node.js runtime is unavailable to this terminal. Installing the pinned LTS release adds Node.js and npm to your Windows environment.

  • Open the official Node.js release page for version 24.20.0.
  • Download the Windows 64-bit Installer.
  • Run the downloaded installer.
  • Keep npm enabled during the installation.

The installer makes both commands available to newly opened terminals.

  • Close Visual Studio Code after the installer finishes.
  • Press the Windows key to open Windows Search.
  • Type Visual Studio Code and press Enter.
  • Press Ctrl+` to open a fresh integrated terminal.
  • Verify the installation by running these commands:
node --version
npm --version

What should I see?

The fresh terminal should print v24.20.0 followed by 11.19.0.

Are the commands still missing?

Confirm the installer completed before you restarted Visual Studio Code. The editor must start after installation to receive the updated PATH.

If both commands remain unavailable, help me troubleshoot my Node.js installation in the VS Code PowerShell terminal.

Create the workspace and install packages

A VS Code workspace keeps the frontend files and terminal in one named folder. The project manifest then pins every package so each install uses the same toolchain.

  • Click File in Visual Studio Code.
  • Click Open Folder....
  • Select your Desktop location.
  • Click New Folder.
  • Enter asset-dashboard-frontend and press Enter.
  • Select the asset-dashboard-frontend folder.
  • Click Select Folder.
  • Click Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer sidebar now shows asset-dashboard-frontend as the open workspace.

  • Select the Explorer view in the Activity Bar.
  • Select the New File... button.
  • Enter package.json and press Enter.
  • Add the pinned project manifest by pasting this code:
{
  "name": "asset-dashboard-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.2.2"
  }
}

What does this manifest define?

  • The scripts section gives npm commands for development, builds, and local build previews.
  • The dependencies section pins React and React DOM to 19.2.8.
  • The devDependencies section pins Vite to 8.2.2.
  • The React plugin is pinned to 6.1.1 for the selected Vite release.
  • Save package.json.
  • Press Ctrl+` to return to the integrated terminal.
  • Install the pinned dependencies by running this command:
npm install

What does this command do?

npm reads package.json and installs the listed dependencies inside the workspace. The version pins keep the project toolchain consistent.

The terminal returns to the PowerShell prompt after installation completes. You should see no installation errors.

Did the package installation fail?

Confirm the terminal is running inside the open asset-dashboard-frontend workspace. Check that package.json matches the manifest above.

If npm still cannot install the packages, help me debug the npm install failure in this Vite workspace.

Add the runnable Vite entry point

Vite needs a configuration file that activates React support. Its development proxy also forwards paths beginning with /api to the Django backend at http://localhost:8000.

  • Select the New File... button in the Explorer sidebar.
  • Enter vite.config.js and press Enter.
  • Add the Vite configuration by pasting this code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:8000',
    },
  },
})

What does this configuration do?

  • The React plugin lets Vite process the project's JSX files.
  • The proxy sends development requests beginning with /api to the local Django server.
  • The browser can call relative API paths while Vite handles the local forwarding.
  • Save vite.config.js.
  • Confirm vite.config.js appears beside package.json in the Explorer.

Is the configuration file missing?

Check that the file is named vite.config.js inside asset-dashboard-frontend. Make sure the name does not end with an extra file extension.

If the file still looks misplaced, help me check my Vite configuration file location.

The browser entry file provides a root element for React. It also loads the JavaScript module that mounts the application.

  • Select the New File... button in the Explorer.
  • Enter index.html and press Enter.
  • Add the browser entry document by pasting this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Authenticated asset operations dashboard" />
    <title>Asset Operations</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this document do?

  • The root element gives React a place to render the interface.
  • The module script loads src/main.jsx as the frontend entry point.
  • The page metadata identifies the interface as Asset Operations.
  • Save index.html.
  • Confirm index.html appears in the top level of the Explorer.

Is the entry document in the wrong place?

Place index.html directly inside asset-dashboard-frontend. It should sit beside package.json.

If the structure differs, help me place index.html correctly in my Vite workspace.

  • Right-click the asset-dashboard-frontend folder in the Explorer.
  • Select New Folder.
  • Enter src and press Enter.

The Explorer now shows a src folder for the frontend source files.

  • Right-click the src folder.
  • Select New File.
  • Enter App.jsx and press Enter.
  • Add the starter component by pasting this code:
export default function App() {
  return <h1>Asset Operations Frontend</h1>
}

What does this component do?

The App component returns the heading used for the first visible workspace check. This small component proves React can render through Vite.

  • Save src/App.jsx.
  • Confirm App.jsx appears inside the src folder.

Is the component outside src?

Move App.jsx into the src folder if it appears at the top level. The entry point uses that exact location.

If the file path is unclear, help me check the App.jsx location in my Vite workspace.

  • Right-click the src folder in the Explorer.
  • Select New File.
  • Enter main.jsx and press Enter.
  • Add the React entry point by pasting this code:
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'

const root = createRoot(document.getElementById('root'))
root.render(<App />)

What does this entry point do?

  • The imports load React DOM, the starter component, and the shared stylesheet.
  • The createRoot call connects React to the root element in index.html.
  • The final line renders the App component into the page.
  • Save src/main.jsx.
  • Confirm main.jsx appears beside App.jsx.

Does the entry point show an import warning?

Confirm App.jsx uses an uppercase A. Check that both source files are inside the same src folder.

If an import remains unresolved, help me debug the imports in src/main.jsx.

  • Right-click the src folder in the Explorer.
  • Select New File.
  • Enter styles.css and press Enter.
  • Add the starter page styles by pasting this code:
:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

body {
  margin: 0;
}

What do these styles change?

  • The root styles set the starter page's text colour and background colour.
  • The font stack uses available interface fonts from the learner's system.
  • The body rule removes the browser's default outer margin.
  • Save src/styles.css.
  • Confirm styles.css appears beside main.jsx in the src folder.

Are the starter styles missing?

Confirm the file is named styles.css inside src. Check that main.jsx imports the same filename.

If the stylesheet still cannot be found, help me check the CSS import in my Vite React workspace.

✔️ Awesome, I've got everything!

Great. Double check every file is saved inside asset-dashboard-frontend.

ⓧ I'd like to double check the full code

Compare each file with the complete workspace below.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Authenticated asset operations dashboard" />
    <title>Asset Operations</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
{
  "name": "asset-dashboard-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.2.2"
  }
}
export default function App() {
  return <h1>Asset Operations Frontend</h1>
}
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'

const root = createRoot(document.getElementById('root'))
root.render(<App />)
:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

body {
  margin: 0;
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:8000',
    },
  },
})

Before you start the development server, what heading do you expect the browser to display?

  • Start the Vite development server by running this command in the integrated terminal:
npm run dev

What does this command do?

npm runs the dev script from package.json. Vite starts a local development server and prints its Local URL.

The terminal prints a Local URL when the server is ready. The default is usually http://localhost:5173, although Vite can select another port when that one is occupied.

  • Select the printed Local URL in the terminal to open it in your browser.

You should see Asset Operations Frontend on a pale background. That confirms the React entry point is running through Vite.

Does the starter page fail to load?

Keep the terminal running while the browser is open. Confirm you selected the exact Local URL printed by Vite.

If the terminal reports a source error, compare the filenames and imports with the full-code tab. You can also ask for help debugging the Vite starter page.

Your separate React workspace is live and ready to call the existing Django API. Next, you will turn this starter page into the dashboard's login screen.

Build the Login Screen

Your React workspace can render a starter page. The dashboard now needs a clear entry point for the account credentials accepted by your existing Django REST Framework backend.

In this step, you will turn the starter page into a controlled login form. You will also give the screen a responsive layout that works on wide and narrow browsers.

In this step, get ready to:
  • Manage the identifier type and credential fields with useState.
  • Build an accessible login panel with connected labels and keyboard submission.
  • Style the panel as a responsive two-column layout that collapses on narrow screens.
Build the controlled form

A controlled form stores each input value in React state. The screen always reflects the current values held by the component.

  • In the Explorer sidebar in VS Code, select src/App.jsx.
  • Replace the start of the component above its existing return line with this state and form-handling code:
import { useState } from 'react'

export default function App() {
  const [form, setForm] = useState({
    loginField: 'username',
    identifier: '',
    password: '',
  })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  async function handleLogin(event) {
    event.preventDefault()
    setLoading(true)
    setError('')
  }

What does this code do?

  • The form object keeps the identifier type and both credential fields together.
  • The default loginField value is username. This matches the default Simple JWT login field.
  • The updateForm() function uses an input's name to update the matching state value.
  • The handleLogin() function prevents a browser reload and switches the form into its loading state. The next step connects this handler to the API.
  • Keep the existing starter return line and closing brace below the new code.
  • Save src/App.jsx.
  • Check the browser from the previous step. You should still see Asset Operations Frontend without a compile error.

Seeing a compile error?

  • Confirm the useState import is the first line in src/App.jsx.
  • Check that the new state declarations sit inside App() and above its existing return line.
  • Ask for help with the component structure: Help me find the syntax problem in my React App component after adding useState form data and event handlers.

The state now has a visible job to do. The selector lets the user match the identifier field expected by their backend.

  • Replace the starter return <h1>Asset Operations Frontend</h1> line in src/App.jsx with this first form view:
  return (
    <form className="login-card" onSubmit={handleLogin}>
      <label htmlFor="loginField">Identifier type</label>
      <select
        id="loginField"
        name="loginField"
        value={form.loginField}
        onChange={updateForm}
      >
        <option value="username">Username</option>
        <option value="email">Email</option>
      </select>

      <button className="primary-button" disabled={loading} type="submit">
        {loading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )

How does the selector work?

  • The value property reads the current loginField state.
  • The onChange handler sends each selection to updateForm().
  • The form's onSubmit handler supports clicking the button or pressing Enter.
  • Save src/App.jsx.
  • Check the running page. You should see an identifier selector with Username selected and a Sign in button below it.

Don't see the selector?

  • Confirm the browser is showing the Local URL printed by the running Vite server.
  • Check that the replacement starts with return ( and remains inside App().
  • Ask for help with the rendered form: Help me debug why my controlled React select is not appearing in the browser.

The selected identifier determines the key that authentication sends later. The user still needs controlled fields for the identifier value and password.

  • In src/App.jsx, find the closing </select> tag.
  • Add the credential inputs and error region below that tag:
      <label htmlFor="identifier">Identifier</label>
      <input
        id="identifier"
        name="identifier"
        value={form.identifier}
        onChange={updateForm}
        autoComplete="username"
        required
      />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        value={form.password}
        onChange={updateForm}
        autoComplete="current-password"
        required
      />

      {error && <p className="error-message">{error}</p>}

Why connect labels and inputs?

  • Each label's htmlFor value matches its input's id. Selecting the label focuses the correct field.
  • Each input's name matches a property in form. The shared change handler can therefore update either field.
  • The required attributes let the browser stop an empty submission before the handler runs.
  • The error paragraph renders only when error contains a message.
  • Save src/App.jsx.
  • Type a test value into the Identifier field.
  • Type a test value into the Password field.
  • Press Enter while the password field is focused.

You should see the button change to Signing in... without the browser reloading. This proves the controlled fields and keyboard submission are connected.

  • Refresh the browser to reset the temporary loading state before continuing.

Does the form reload the page?

  • Confirm the form includes onSubmit={handleLogin}.
  • Confirm handleLogin() starts with event.preventDefault().
  • Ask for help with form submission: Help me stop my React login form from reloading the page when I press Enter.
Add the visual hierarchy

The form works as an interaction, but it still lacks context. A brand panel explains what the dashboard does and names the development proxy target.

Wrapping the existing form requires matching opening and closing elements. Complete both edits below before saving the file.

  • In src/App.jsx, add this layout opening immediately below the return ( line and above the existing form:
    <main className="page-shell login-shell">
      <section className="auth-layout">
        <div className="brand-panel">
          <p className="eyebrow">Asset Operations</p>
          <h1>Turn your Django API into a working dashboard.</h1>
          <p>
            Sign in with Simple JWT, then load live analytics and asset data
            from the backend mounted under <code>/api/</code>.
          </p>
          <span className="endpoint-badge">Proxy target: localhost:8000</span>
        </div>

What does the brand panel communicate?

The panel names the dashboard's purpose and the API mount point. The proxy badge confirms that development requests are routed to the local backend.

  • Indent the existing <form> block so it sits beside the brand panel inside auth-layout.
  • Add these matching closing tags immediately below </form> and above the return's closing parenthesis:
      </section>
    </main>

Why do these tags matter?

The section groups the brand panel with the login form. The outer main element gives the page-level layout styles one clear target.

  • Save src/App.jsx after both wrapper edits are complete.
  • Check the browser. You should see the Asset Operations message above the form and a badge naming localhost:8000.

Seeing mismatched JSX tags?

  • Confirm <main> contains one <section>.
  • Confirm the section contains the complete brand-panel block followed by the complete login-card form.
  • Ask for help balancing the JSX: Help me balance the main, section, div, and form tags in my React login screen.

✔️ Awesome, I've got everything!

Great. Save src/App.jsx before you style the login screen.

ⓧ I'd like to double check the full code

Compare your current src/App.jsx with this complete login-screen component.

import { useState } from 'react'

export default function App() {
  const [form, setForm] = useState({
    loginField: 'username',
    identifier: '',
    password: '',
  })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  async function handleLogin(event) {
    event.preventDefault()
    setLoading(true)
    setError('')
  }

  return (
    <main className="page-shell login-shell">
      <section className="auth-layout">
        <div className="brand-panel">
          <p className="eyebrow">Asset Operations</p>
          <h1>Turn your Django API into a working dashboard.</h1>
          <p>
            Sign in with Simple JWT, then load live analytics and asset data
            from the backend mounted under <code>/api/</code>.
          </p>
          <span className="endpoint-badge">Proxy target: localhost:8000</span>
        </div>

        <form className="login-card" onSubmit={handleLogin}>
          <div>
            <p className="eyebrow">Secure access</p>
            <h2>Sign in</h2>
            <p className="muted">Use an account that already exists in Django.</p>
          </div>

          <label htmlFor="loginField">Identifier type</label>
          <select
            id="loginField"
            name="loginField"
            value={form.loginField}
            onChange={updateForm}
          >
            <option value="username">Username</option>
            <option value="email">Email</option>
          </select>

          <label htmlFor="identifier">Identifier</label>
          <input
            id="identifier"
            name="identifier"
            value={form.identifier}
            onChange={updateForm}
            autoComplete="username"
            required
          />

          <label htmlFor="password">Password</label>
          <input
            id="password"
            name="password"
            type="password"
            value={form.password}
            onChange={updateForm}
            autoComplete="current-password"
            required
          />

          {error && <p className="error-message">{error}</p>}

          <button className="primary-button" disabled={loading} type="submit">
            {loading ? 'Signing in...' : 'Sign in'}
          </button>
        </form>
      </section>
    </main>
  )
}

The page structure is complete. The stylesheet now turns those semantic sections into the intended visual hierarchy.

  • In the VS Code Explorer sidebar, select src/styles.css.
  • Replace the existing stylesheet with this page foundation:
:root {
  color: #172033;
  background: #eef2f7;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

What does the foundation control?

  • The root styles set the page colors and font stack.
  • The universal selector makes element dimensions include their borders and padding.
  • The body rules remove the browser margin and keep the page at least as tall as the viewport.
  • Save src/styles.css.
  • Check the browser. You should see the page use a pale background with dark text and no outer body margin.

Don't see the new page colors?

  • Confirm src/main.jsx still imports ./styles.css.
  • Ask for help tracing the stylesheet: Help me debug why my Vite React app is not applying src/styles.css.
  • Add the shared control and page-shell styles below the body block:
button,
input,
select {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

code {
  border-radius: 0.45rem;
  background: #e9eef7;
  color: #34405a;
  padding: 0.2rem 0.45rem;
}

.page-shell {
  min-height: 100vh;
  padding: 2rem;
}

How do these shared styles help?

  • The form controls inherit the same font as the page.
  • Disabled buttons use reduced opacity and a waiting cursor to communicate the loading state.
  • The page shell creates consistent space around the login layout.
  • Save src/styles.css.
  • Check the browser. You should see more space around the content and a styled /api/ badge inside the brand description.

Do the controls use a different font?

  • Check that the button, input, and select selectors appear together above font: inherit.
  • Ask for help with the selectors: Help me check the shared button, input, and select styles in src/styles.css.
  • Add the centered login shell and two-column card layout below the page-shell styles:
.login-shell {
  display: grid;
  place-items: center;
  background:
    radial-gradient(circle at top left, #dce8ff, transparent 38%),
    #eef2f7;
}

.auth-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
  width: min(1040px, 100%);
  overflow: hidden;
  border: 1px solid #d8dfeb;
  border-radius: 1.5rem;
  background: #ffffff;
  box-shadow: 0 24px 70px rgba(26, 41, 72, 0.14);
}

How does the layout take shape?

  • The login shell centers the complete panel inside the viewport.
  • The auth layout creates a wider brand column and a narrower form column.
  • The maximum width keeps the panel readable on large displays.
  • Save src/styles.css.
  • Check the browser. You should see one centered white panel divided into two columns.

Is the panel still stacked?

  • Confirm the browser is wider than 760px while checking the desktop layout.
  • Check that auth-layout matches the class on the section in src/App.jsx.
  • Ask for help with the grid: Help me debug why my auth-layout CSS grid is not showing two columns.
  • Add the dark brand-panel structure and heading alignment below the auth-layout block:
.brand-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 560px;
  padding: 4rem;
  background: #172033;
  color: #ffffff;
}

.brand-panel h1,
.dashboard-header h1,
.login-card h2,
.section-heading h2 {
  margin: 0;
}

What does the brand panel establish?

The panel uses a dark background and generous spacing to separate the product message from the form. The grouped heading rule removes default margins from headings used across the finished dashboard.

  • Save src/styles.css.
  • Check the browser. You should see a tall dark brand column on the left side of the white panel.

Is the brand panel still white?

  • Confirm the left content wrapper uses className="brand-panel" in src/App.jsx.
  • Ask for help matching the class: Help me find why the brand-panel CSS is not applying to my React login screen.
  • Add the brand headline, description, and proxy badge styles below the heading rule:
.brand-panel h1 {
  max-width: 14ch;
  font-size: clamp(2.35rem, 5vw, 4.4rem);
  line-height: 1;
}

.brand-panel > p:not(.eyebrow) {
  max-width: 54ch;
  color: #c9d4e8;
  line-height: 1.7;
}

.endpoint-badge {
  align-self: flex-start;
  margin-top: 1rem;
  border: 1px solid #51617d;
  border-radius: 999px;
  padding: 0.65rem 0.9rem;
  color: #dbe8ff;
}

Why style the proxy target as a badge?

The badge separates the development target from the surrounding explanation. You can confirm the frontend points at the intended local backend without opening configuration files.

  • Save src/styles.css.
  • Check the browser. You should see a large headline and an outlined proxy badge beneath the description.

Does the proxy badge look like plain text?

  • Confirm the badge uses className="endpoint-badge" in src/App.jsx.
  • Ask for help with the badge selector: Help me debug why the endpoint-badge styles are not appearing.
  • Add the form column and field spacing below the endpoint badge styles:
.login-card {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.7rem;
  min-height: 560px;
  padding: 3.25rem;
}

.login-card h2 {
  font-size: 2rem;
}

.login-card label {
  margin-top: 0.45rem;
  font-size: 0.88rem;
  font-weight: 700;
}

How is the form organized?

Flexbox stacks the form content vertically and keeps the spacing consistent. The form's minimum height aligns it with the brand panel on wide screens.

  • Save src/styles.css.
  • Check the browser. You should see the form centered vertically with clear spacing between each label and control.

Is the form pressed against the panel edge?

  • Confirm the form uses className="login-card".
  • Check that the padding rule sits inside the .login-card block.
  • Ask for help with the spacing: Help me debug the spacing inside my login-card form.
  • Add the input and focus styles below the login-card label block:
.login-card input,
.login-card select,
.search-input {
  width: 100%;
  border: 1px solid #cbd4e3;
  border-radius: 0.7rem;
  background: #ffffff;
  padding: 0.8rem 0.9rem;
  color: #172033;
}

.login-card input:focus,
.login-card select:focus,
.search-input:focus {
  border-color: #356ae6;
  outline: 3px solid rgba(53, 106, 230, 0.15);
}

Why make focus visible?

The blue outline shows which control currently receives keyboard input. This makes Tab-based navigation easier to follow.

  • Save src/styles.css.
  • Press Tab on the login page. You should see a blue focus outline move through the selector and credential fields.

Can't see the focus outline?

  • Confirm the focus selectors include input:focus and select:focus.
  • Ask for help with keyboard focus: Help me debug why my login input focus styles do not appear when I press Tab.
  • Add the label hierarchy and button colors below the focus styles:
.eyebrow {
  margin: 0 0 0.55rem;
  color: #356ae6;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.brand-panel .eyebrow {
  color: #8eb2ff;
}

.muted {
  color: #68758e;
}

.primary-button,
.secondary-button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.8rem 1rem;
  font-weight: 800;
}

.primary-button {
  background: #356ae6;
  color: #ffffff;
}

How does the hierarchy guide the eye?

The eyebrow labels identify each part of the screen without competing with the headings. The blue primary button gives the form one obvious submission action.

  • Save src/styles.css.
  • Check the browser. You should see uppercase section labels and a blue Sign in button.

Is the button still unstyled?

  • Confirm the button uses className="primary-button" in src/App.jsx.
  • Ask for help with the button class: Help me debug why my primary-button styles are not applying.
  • Add the final login-button spacing and error-region styles below the primary button block:
.login-card .primary-button {
  margin-top: 0.9rem;
}

.error-message {
  border: 1px solid #f1b8b8;
  border-radius: 0.7rem;
  background: #fff0f0;
  padding: 0.75rem;
  color: #9d2525;
}

What is the error region for?

The error region gives authentication failures a consistent place below the fields. Its red border and background separate backend feedback from normal form guidance.

  • Save src/styles.css.
  • Check the browser. You should see extra separation above the Sign in button.

Is the button touching the password field?

  • Confirm the margin rule uses both .login-card and .primary-button in the selector.
  • Ask for help with the spacing rule: Help me debug the spacing above the login-card primary button.
Make the login screen responsive

The two-column panel suits a wide browser. On a narrow screen, the same content needs one column with smaller padding so every control remains readable.

  • Add this mobile rule at the bottom of src/styles.css.
@media (max-width: 760px) {
  .page-shell {
    padding: 1rem;
  }

  .auth-layout {
    grid-template-columns: 1fr;
  }

  .brand-panel,
  .login-card {
    min-height: auto;
    padding: 2rem;
  }
}

What changes on a narrow screen?

  • The page shell uses less outer padding.
  • The auth layout switches from two columns to one.
  • The brand panel and form drop their fixed minimum height and use smaller inner padding.
  • Save src/styles.css.
  • Narrow the browser window until its content area is below 760px wide.

You should see the brand panel move above the login form. Every selector and input should remain inside the screen without horizontal scrolling.

Does the layout stay in two columns?

  • Confirm the media rule sits at the bottom of src/styles.css and has its final closing brace.
  • Check that the responsive selector is .auth-layout.
  • Ask for help with the mobile breakpoint: Help me debug why my auth-layout does not collapse below 760px.

✔️ Awesome, I've got everything!

Your login stylesheet is complete. Save src/styles.css before the final check.

ⓧ I'd like to double check the full code

Compare your current src/styles.css with this complete stylesheet for the login screen.

:root {
  color: #172033;
  background: #eef2f7;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input,
select {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

code {
  border-radius: 0.45rem;
  background: #e9eef7;
  color: #34405a;
  padding: 0.2rem 0.45rem;
}

.page-shell {
  min-height: 100vh;
  padding: 2rem;
}

.login-shell {
  display: grid;
  place-items: center;
  background:
    radial-gradient(circle at top left, #dce8ff, transparent 38%),
    #eef2f7;
}

.auth-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
  width: min(1040px, 100%);
  overflow: hidden;
  border: 1px solid #d8dfeb;
  border-radius: 1.5rem;
  background: #ffffff;
  box-shadow: 0 24px 70px rgba(26, 41, 72, 0.14);
}

.brand-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 560px;
  padding: 4rem;
  background: #172033;
  color: #ffffff;
}

.brand-panel h1,
.dashboard-header h1,
.login-card h2,
.section-heading h2 {
  margin: 0;
}

.brand-panel h1 {
  max-width: 14ch;
  font-size: clamp(2.35rem, 5vw, 4.4rem);
  line-height: 1;
}

.brand-panel > p:not(.eyebrow) {
  max-width: 54ch;
  color: #c9d4e8;
  line-height: 1.7;
}

.endpoint-badge {
  align-self: flex-start;
  margin-top: 1rem;
  border: 1px solid #51617d;
  border-radius: 999px;
  padding: 0.65rem 0.9rem;
  color: #dbe8ff;
}

.login-card {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.7rem;
  min-height: 560px;
  padding: 3.25rem;
}

.login-card h2 {
  font-size: 2rem;
}

.login-card label {
  margin-top: 0.45rem;
  font-size: 0.88rem;
  font-weight: 700;
}

.login-card input,
.login-card select,
.search-input {
  width: 100%;
  border: 1px solid #cbd4e3;
  border-radius: 0.7rem;
  background: #ffffff;
  padding: 0.8rem 0.9rem;
  color: #172033;
}

.login-card input:focus,
.login-card select:focus,
.search-input:focus {
  border-color: #356ae6;
  outline: 3px solid rgba(53, 106, 230, 0.15);
}

.eyebrow {
  margin: 0 0 0.55rem;
  color: #356ae6;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.brand-panel .eyebrow {
  color: #8eb2ff;
}

.muted {
  color: #68758e;
}

.primary-button,
.secondary-button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.8rem 1rem;
  font-weight: 800;
}

.primary-button {
  background: #356ae6;
  color: #ffffff;
}

.login-card .primary-button {
  margin-top: 0.9rem;
}

.error-message {
  border: 1px solid #f1b8b8;
  border-radius: 0.7rem;
  background: #fff0f0;
  padding: 0.75rem;
  color: #9d2525;
}

@media (max-width: 760px) {
  .page-shell {
    padding: 1rem;
  }

  .auth-layout {
    grid-template-columns: 1fr;
  }

  .brand-panel,
  .login-card {
    min-height: auto;
    padding: 2rem;
  }
}

Before you perform the final check, predict how the form changes when the browser crosses the mobile breakpoint.

  • Switch back to the VS Code terminal running Vite.
  • Stop the development server by pressing Ctrl+C.
  • Restart the Vite development server by running:
npm run dev

Vite should print a Local URL without a compile error. The server remains active while you complete the browser checks.

Does the development server fail to start?

  • Confirm the terminal is inside the open asset-dashboard-frontend folder.
  • Check the first error reported for either src/App.jsx or src/styles.css.
  • Ask for help with the startup error: Help me debug the Vite compile error in my Asset Operations login screen.
  • Open the Local URL printed in the restarted VS Code terminal.
  • Confirm the wide view shows the brand panel beside the login form.
  • Confirm the form shows the identifier selector and identifier field.
  • Confirm the form shows the password field and Sign in button.
  • Confirm the brand panel shows the /api/ endpoint and localhost:8000 proxy target.
  • Resize the browser below 760px wide.
  • Confirm the brand panel moves above the form without horizontal scrolling.

That is the login experience working as intended. The controlled form now has a clear desktop layout and a clean mobile layout.

Your login screen is ready to collect credentials. Next, you will connect the form to Django and reveal the first protected API response.

Connect Login and Inspect Assets

Your React form already captures the credentials that your Django backend expects. The missing bridge is an API client that sends those credentials without exposing the returned tokens.

This step proves that authentication works by loading the protected assets endpoint. You will deliberately show the response as raw JSON so the presentation problem is visible before you build the dashboard.

In this step, get ready to:
  • Create an API client that parses responses and stores the token pair for the current browser tab.
  • Connect the controlled login form to the Django login endpoint.
  • Load the protected assets response into a raw debug panel.
Create the API client

An API client keeps response parsing and authentication details outside your interface components. This version also uses sessionStorage so the token pair lasts for the current browser tab.

  • Select the asset-dashboard-frontend folder in the VS Code Explorer sidebar.
  • Create src/api.js by clicking the New File button in the Explorer toolbar.
  • Add the response parsing helpers by pasting this code into src/api.js:
const TOKEN_KEY = 'asset-dashboard-tokens'

async function readJson(response) {
  const text = await response.text()

  if (!text) {
    return null
  }

  try {
    return JSON.parse(text)
  } catch {
    return text
  }
}

function messageFromBody(body, fallback) {
  if (typeof body === 'string' && body.trim()) {
    return body
  }

  if (body && typeof body === 'object') {
    return body.detail || JSON.stringify(body)
  }

  return fallback
}

What do these helpers do?

  • The TOKEN_KEY constant gives the saved token pair one consistent browser storage key.
  • The readJson() helper safely handles JSON responses and empty response bodies.
  • The messageFromBody() helper turns backend response bodies into useful error messages.
  • Save src/api.js.
  • Check the VS Code editor for red syntax markers around the response parsing helpers.
  • Add the token storage functions below messageFromBody() in src/api.js:
export function readTokens() {
  const stored = sessionStorage.getItem(TOKEN_KEY)

  if (!stored) {
    return null
  }

  try {
    const tokens = JSON.parse(stored)
    return tokens?.access && tokens?.refresh ? tokens : null
  } catch {
    sessionStorage.removeItem(TOKEN_KEY)
    return null
  }
}

export function saveTokens(tokens) {
  sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens))
}

export function clearTokens() {
  sessionStorage.removeItem(TOKEN_KEY)
}

How is the session stored?

  • The readTokens() function accepts stored data only when both required token values exist.
  • The saveTokens() function stores the pair under asset-dashboard-tokens.
  • The clearTokens() function provides the cleanup path used by local sign-out later in the project.
  • Save src/api.js.
  • Confirm the editor shows matching braces for all three storage functions.
  • Add the login request below clearTokens() in src/api.js:
export async function login({ loginField, identifier, password }) {
  const response = await fetch('/api/auth/login/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      [loginField]: identifier,
      password,
    }),
  })

  const body = await readJson(response)

  if (!response.ok) {
    throw new Error(messageFromBody(body, 'Sign in failed.'))
  }

  if (!body?.access || !body?.refresh) {
    throw new Error('The login response did not include access and refresh tokens.')
  }

  return {
    access: body.access,
    refresh: body.refresh,
  }
}

What does the login request do?

  • The request posts the selected identifier field and password to /api/auth/login/.
  • The computed [loginField] key sends either username or email based on the form selection.
  • A successful response must contain both access and refresh before the app accepts it.
  • The function returns the token pair to the app without rendering either value.
  • Save src/api.js.
  • Confirm the editor shows no red syntax markers inside login().
  • Add the protected request helpers below login() in src/api.js:
async function sendRequest(path, options, access) {
  const headers = new Headers(options.headers || {})

  if (access) {
    headers.set('Authorization', `Bearer ${access}`)
  }

  return fetch(path, {
    ...options,
    headers,
  })
}

export async function apiFetch(path, options = {}) {
  let tokens = readTokens()
  let response = await sendRequest(path, options, tokens?.access)

  if (!response.ok) {
    const body = await readJson(response)
    throw new Error(messageFromBody(body, `Request failed with status ${response.status}.`))
  }

  return readJson(response)
}

How is the protected request authenticated?

  • The sendRequest() helper places the stored access token in the Authorization header using the Bearer type.
  • The apiFetch() function reads the current token pair before sending the request.
  • This first version reports a failed protected request immediately. Automatic access-token refresh arrives in the next step.
  • Save src/api.js.
  • Confirm the editor shows no red syntax markers in the completed API module.

Seeing an error in the API module?

  • Check that src/api.js sits inside the existing src folder.
  • Confirm each helper has its closing brace before the next function begins.
  • Check that the storage key is spelled asset-dashboard-tokens everywhere it appears.
  • Help me debug my API module.
Connect the form to Django

The form already owns its input values and loading state. Its submit handler now needs to call login() before switching the interface to an authenticated shell.

  • Return to src/App.jsx from the VS Code Explorer sidebar.
  • Replace the existing React import with these imports:
import { useState } from 'react'
import { apiFetch, login, readTokens, saveTokens } from './api.js'

What do these imports provide?

The API helpers keep request and storage details out of the component. readTokens() also lets the interface recognize an authenticated browser tab after a refresh.

  • Add the authenticated state near the existing state declarations inside App():
  const [isAuthenticated, setIsAuthenticated] = useState(
    () => Boolean(readTokens()?.access),
  )
  const [assets, setAssets] = useState([])

What does this state track?

  • The isAuthenticated state starts as true when the current tab already holds an access token.
  • The assets state holds the unmodified response returned by the protected endpoint.
  • Replace the current form submission handler with this authenticated version:
  async function handleLogin(event) {
    event.preventDefault()
    setLoading(true)
    setError('')

    try {
      const tokens = await login(form)
      saveTokens(tokens)
      setIsAuthenticated(true)
      setForm((current) => ({ ...current, password: '' }))
    } catch (requestError) {
      setError(requestError.message)
    } finally {
      setLoading(false)
    }
  }

How does sign-in change the interface?

  • The handler prevents the browser from reloading the page during form submission.
  • A successful request stores the validated token pair before setting isAuthenticated to true.
  • The password field is cleared after success so the credential does not remain visible in the form state.
  • A failed request places the backend message in the existing error region.
  • Add the asset-loading function below handleLogin() in src/App.jsx:
  async function loadAssets() {
    setLoading(true)
    setError('')

    try {
      const assetsPayload = await apiFetch('/api/assets/')
      setAssets(assetsPayload)
    } catch (requestError) {
      setError(requestError.message)
    } finally {
      setLoading(false)
    }
  }

What does the loader keep raw?

The loadAssets() function requests /api/assets/ through the authenticated client. It stores the response without assuming which serializer fields or pagination shape the backend uses.

  • Add this authenticated branch above the existing login-screen return statement:
  if (isAuthenticated) {
    return (
      <main className="page-shell dashboard-shell">
        <header className="dashboard-header">
          <div>
            <p className="eyebrow">Asset Operations</p>
            <h1>Authenticated API check</h1>
            <p className="muted">Inspect the protected assets response.</p>
          </div>
          <button
            className="primary-button"
            disabled={loading}
            onClick={loadAssets}
            type="button"
          >
            {loading ? 'Loading...' : 'Load assets'}
          </button>
        </header>

        {error && <p className="error-message dashboard-error">{error}</p>}

        <section className="content-section">
          <p className="eyebrow">Raw response</p>
          <pre className="debug-panel">{JSON.stringify(assets, null, 2)}</pre>
        </section>
      </main>
    )
  }

What does the authenticated branch show?

  • The branch replaces the login panel after the token pair has been saved.
  • The Load assets button starts the protected request while preserving keyboard-accessible button behavior.
  • The JSON.stringify() call formats the complete response for inspection without including stored token values.
  • Save src/App.jsx.
  • Return to the Vite Local URL in your browser.
  • Confirm the existing login panel still displays before you submit credentials.

Does the page turn blank?

  • Check that the authenticated branch sits inside App() before the existing login return statement.
  • Confirm the import path ends with ./api.js.
  • Check the browser console for the first file and line named in the error.
  • Help me fix the blank React page.
Load the protected assets response

The authenticated shell needs enough visual structure to separate request errors from the raw response. The debug panel preserves whitespace so nested JSON remains readable.

  • Return to src/styles.css from the VS Code Explorer sidebar.
  • Add these dashboard and debug-panel styles above the existing mobile media query:
.dashboard-shell {
  width: min(1200px, 100%);
  margin: 0 auto;
}

.dashboard-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.dashboard-error {
  margin-bottom: 1rem;
}

.content-section {
  border: 1px solid #d8dfeb;
  border-radius: 1rem;
  background: #ffffff;
  padding: 1.35rem;
  box-shadow: 0 12px 30px rgba(33, 48, 78, 0.06);
}

.debug-panel {
  overflow: auto;
  margin: 0;
  border-radius: 0.8rem;
  background: #172033;
  padding: 1rem;
  color: #dbe8ff;
}

What do these styles change?

  • The dashboard shell limits the width of the authenticated view on large screens.
  • The header keeps the endpoint action beside the page title when space allows.
  • The content section gives the response its own bordered panel.
  • The debug panel preserves readable contrast for the formatted JSON response.
  • Save src/styles.css.
  • Return to the login panel at the Vite Local URL.

Before you sign in, do you expect the browser to contact Django directly or send the request through the existing /api development proxy?

  • Keep Username selected if your backend uses the default identifier.
  • Select Email if your backend login accepts an email identifier.
  • Enter the identifier for your existing backend account in the Identifier field.
  • Enter the account password in the Password field.
  • Click Sign in.

You should see the authenticated API check shell. The browser stays on the Vite Local URL because the development proxy forwards the /api/auth/login/ request to the running Django backend.

That is the authentication bridge working. Your app can now exchange credentials for a tab-scoped token pair without showing either token on the page.

Before you load the assets, do you think the live API response will already feel like a dashboard?

  • Click Load assets.

You should see the live /api/assets/ response rendered as indented raw JSON. The response may be an array or an object containing a results array.

The raw response is the intended obstacle

The debug panel proves that login and protected requests work. It also exposes the limitation directly because serializer field names and response wrappers do not create a usable dashboard by themselves.

The next step replaces this diagnostic view with schema-adaptive cards and a table. It also adds automatic token refresh for expired access tokens.

Does sign-in or asset loading fail?

  • Confirm your Django backend is still reachable through the proxy target configured earlier.
  • Check that the selected identifier type matches the field accepted by your backend login.
  • Confirm the protected route includes the trailing slash in /api/assets/.
  • Help me debug the authenticated request.

✔️ Awesome, I've got everything!

Your login request succeeds and the protected assets response appears as formatted raw JSON. Confirm that src/api.js, src/App.jsx, and src/styles.css are saved.

ⓧ I'd like to double check the full code

Compare your completed src/api.js with this step's authenticated client:

const TOKEN_KEY = 'asset-dashboard-tokens'

async function readJson(response) {
  const text = await response.text()

  if (!text) {
    return null
  }

  try {
    return JSON.parse(text)
  } catch {
    return text
  }
}

function messageFromBody(body, fallback) {
  if (typeof body === 'string' && body.trim()) {
    return body
  }

  if (body && typeof body === 'object') {
    return body.detail || JSON.stringify(body)
  }

  return fallback
}

export function readTokens() {
  const stored = sessionStorage.getItem(TOKEN_KEY)

  if (!stored) {
    return null
  }

  try {
    const tokens = JSON.parse(stored)
    return tokens?.access && tokens?.refresh ? tokens : null
  } catch {
    sessionStorage.removeItem(TOKEN_KEY)
    return null
  }
}

export function saveTokens(tokens) {
  sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens))
}

export function clearTokens() {
  sessionStorage.removeItem(TOKEN_KEY)
}

export async function login({ loginField, identifier, password }) {
  const response = await fetch('/api/auth/login/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      [loginField]: identifier,
      password,
    }),
  })

  const body = await readJson(response)

  if (!response.ok) {
    throw new Error(messageFromBody(body, 'Sign in failed.'))
  }

  if (!body?.access || !body?.refresh) {
    throw new Error('The login response did not include access and refresh tokens.')
  }

  return {
    access: body.access,
    refresh: body.refresh,
  }
}

async function sendRequest(path, options, access) {
  const headers = new Headers(options.headers || {})

  if (access) {
    headers.set('Authorization', `Bearer ${access}`)
  }

  return fetch(path, {
    ...options,
    headers,
  })
}

export async function apiFetch(path, options = {}) {
  let tokens = readTokens()
  let response = await sendRequest(path, options, tokens?.access)

  if (!response.ok) {
    const body = await readJson(response)
    throw new Error(messageFromBody(body, `Request failed with status ${response.status}.`))
  }

  return readJson(response)
}

What should you compare?

Check the response helpers first. Confirm the token storage functions appear before login() and apiFetch().

Keep the existing login JSX in src/App.jsx unchanged. Confirm its imports, authenticated state, handleLogin(), loadAssets(), and authenticated branch match the snippets above.

Your frontend now authenticates against Django and reaches a protected endpoint through one reusable request path. Next, you will replace the raw response with dashboard components and teach the client to refresh expired access tokens.

Refresh Tokens and Render the Dashboard

Your login flow already proves that the React frontend can authenticate with the Django backend. The dashboard still exposes the protected assets response as raw JSON.

A short-lived JWT access token can also expire while someone uses the page. This step creates one authenticated request path that refreshes the session before rendering live analytics cards and asset rows.

In this step, get ready to:
  • Refresh an expired access token before retrying a protected request once.
  • Normalize unknown analytics and asset response shapes.
  • Replace the raw JSON panel with summary cards and a responsive table.
Upgrade the authenticated request path

Protected requests need the current access token. A single refresh attempt gives an expired session a chance to recover without creating an endless retry loop.

  • In the VS Code Explorer sidebar, select src/api.js.
  • Add the refresh helper below login() by pasting this code:
async function refreshTokens(refresh) {
  const response = await fetch('/api/auth/refresh/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ refresh }),
  })

  const body = await readJson(response)

  if (!response.ok || !body?.access) {
    clearTokens()
    throw new Error(messageFromBody(body, 'Your session expired. Sign in again.'))
  }

  const current = readTokens()
  const nextTokens = {
    access: body.access,
    refresh: body.refresh || current?.refresh || refresh,
  }

  saveTokens(nextTokens)
  return nextTokens
}

How does token refresh work?

  • The request posts the stored refresh value to /api/auth/refresh/.
  • A failed refresh clears the stored token pair so the page cannot keep using an invalid session.
  • A successful refresh keeps the new access value. It also saves a rotated refresh value when the backend returns one.
  • Save src/api.js.
  • Refresh the Vite page in your browser.

You should still see the authenticated dashboard shell without a compilation error overlay.

Seeing an error after saving?

  • Check that refreshTokens() sits outside login().
  • Confirm every opening brace in the helper has a matching closing brace.
  • Ask for help with the refresh helper.

The refresh helper manages replacement tokens. The public request wrapper now needs to attach the access token and invoke that helper after one 401 response.

  • Replace the naive protected-request helper at the bottom of src/api.js with this authenticated request path:
async function sendRequest(path, options, access) {
  const headers = new Headers(options.headers || {})

  if (access) {
    headers.set('Authorization', `Bearer ${access}`)
  }

  return fetch(path, {
    ...options,
    headers,
  })
}

export async function apiFetch(path, options = {}) {
  let tokens = readTokens()
  let response = await sendRequest(path, options, tokens?.access)

  if (response.status === 401 && tokens?.refresh) {
    tokens = await refreshTokens(tokens.refresh)
    response = await sendRequest(path, options, tokens.access)
  }

  if (!response.ok) {
    const body = await readJson(response)
    throw new Error(messageFromBody(body, `Request failed with status ${response.status}.`))
  }

  return readJson(response)
}

What does this request path do?

  • The sendRequest() helper preserves any existing request headers before adding the current access token.
  • The apiFetch() function sends the original request first.
  • A 401 response triggers one refresh attempt. The original request then runs once more with the replacement access token.
  • Any remaining unsuccessful response becomes an error that the dashboard can display.
  • Save src/api.js.
  • Return to the Vite terminal from earlier.

You should see the file update without a JavaScript syntax error.

Does the API module fail to compile?

  • Confirm apiFetch includes the export keyword.
  • Check that sendRequest() appears before apiFetch().
  • Ask for help with the authenticated wrapper.
Create schema-adaptive dashboard components

The backend serializer fields and analytics keys can evolve. These components discover labels and columns from each live response instead of depending crlf on a fixed schema.

  • In the VS Code Explorer sidebar, create src/SummaryGrid.jsx.
  • Build the analytics cards by pasting this code:
function labelFor(key) {
  return key
    .replace(/_/g, ' ')
    .replace(/\b\w/g, (character) => character.toUpperCase())
}

function displayValue(value) {
  if (value === null || value === undefined) {
    return 'Not provided'
  }

  if (typeof value === 'object') {
    return JSON.stringify(value)
  }

  return String(value)
}

export default function SummaryGrid({ data }) {
  const entries = Object.entries(data || {})

  if (!entries.length) {
    return <p className="empty-state">No analytics were returned.</p>
  }

  return (
    <div className="summary-grid">
      {entries.map(([key, value]) => (
        <article className="summary-card" key={key}>
          <span>{labelFor(key)}</span>
          <strong>{displayValue(value)}</strong>
        </article>
      ))}
    </div>
  )
}

How do the summary cards adapt?

  • The labelFor() helper turns keys such as active_assets into readable card labels.
  • The displayValue() helper safely converts missing values and nested objects into visible text.
  • The component maps every analytics entry into one card without requiring known key names.
  • Save src/SummaryGrid.jsx.
  • Confirm SummaryGrid.jsx appears inside the src folder in the Explorer sidebar.

Does SummaryGrid show a syntax error?

  • Check that the file extension is .jsx.
  • Confirm the mapped article has a closing tag.
  • Ask for help with the summary component.

Asset rows need the same flexibility. The table gathers unique keys across all returned objects so a field that appears in a later row can still become a column.

  • In the VS Code Explorer sidebar, create src/DataTable.jsx.
  • Add the value and label formatting helpers by pasting this code:
function formatValue(value) {
  if (value === null || value === undefined || value === '') {
    return 'Not provided'
  }

  if (typeof value === 'boolean') {
    return value ? 'Yes' : 'No'
  }

  if (typeof value === 'object') {
    return JSON.stringify(value)
  }

  return String(value)
}

function labelFor(key) {
  return key
    .replace(/_/g, ' ')
    .replace(/\b\w/g, (character) => character.toUpperCase())
}

How are cell values formatted?

  • Missing values and empty strings become Not provided.
  • Boolean values become readable Yes or No labels.
  • Nested objects become JSON text so the table never attempts to render an object directly.
  • Add the table component below labelFor() in src/DataTable.jsx by pasting this code:
export default function DataTable({ rows }) {
  if (!rows.length) {
    return <p className="empty-state">No assets were returned.</p>
  }

  const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))].slice(0, 8)

  return (
    <div className="table-scroll">
      <table>
        <thead>
          <tr>
            {columns.map((column) => (
              <th key={column}>{labelFor(column)}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, index) => (
            <tr key={row.id ?? index}>
              {columns.map((column) => (
                <td key={column}>{formatValue(row[column])}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

How does the table discover columns?

  • The component gathers keys from every row before removing duplicates with Set.
  • The slice(0, 8) limit keeps an evolving serializer from creating an unmanageable number of columns.
  • Each cell passes through formatValue() before React renders it.
  • Save src/DataTable.jsx.
  • Confirm DataTable.jsx appears inside the src folder in the Explorer sidebar.

Does DataTable fail to compile?

  • Confirm the file contains both helper functions above the exported component.
  • Check that each mapped table header and cell has a closing parenthesis.
  • Ask for help with the adaptive table.
Connect live responses to the dashboard

The page can now replace its single raw payload with two useful views. A small normalization function also accepts either a plain asset array or a paginated object containing results.

  • In the VS Code Explorer sidebar, select src/App.jsx.
  • Replace the imports at the top of src/App.jsx with these imports and the normalization helper:
import { useState } from 'react'
import { apiFetch, clearTokens, login, readTokens, saveTokens } from './api.js'
import DataTable from './DataTable.jsx'
import SummaryGrid from './SummaryGrid.jsx'

function normalizeCollection(payload) {
  if (Array.isArray(payload)) {
    return payload
  }

  if (Array.isArray(payload?.results)) {
    return payload.results
  }

  return []
}

Why normalize the asset response?

Django REST Framework can return a plain list or a paginated object. The normalizeCollection() helper gives DataTable one reliable array in both cases.

  • Replace the raw response state inside App() with these analytics and asset values:
  const [analytics, setAnalytics] = useState(null)
  const [assets, setAssets] = useState([])

What do these state values hold?

The analytics value holds the live key-value object. The assets value always holds the normalized row array.

  • Replace the old protected assets loader with this dashboard loader:
  async function loadDashboard() {
    setLoading(true)
    setError('')

    try {
      const analyticsPayload = await apiFetch('/api/dashboard/analytics/')
      const assetsPayload = await apiFetch('/api/assets/')
      setAnalytics(analyticsPayload || {})
      setAssets(normalizeCollection(assetsPayload))
    } catch (requestError) {
      setError(requestError.message)

      if (!readTokens()) {
        setIsAuthenticated(false)
      }
    } finally {
      setLoading(false)
    }
  }

What does the dashboard loader do?

  • The loader requests analytics before requesting assets through the same apiFetch() wrapper.
  • The analytics response stays as an arbitrary object. The asset response passes through normalizeCollection().
  • A failed refresh clears the session in src/api.js. The missing stored tokens tell App to return to the login screen.
  • Save src/App.jsx.
  • Refresh the Vite page in your browser.

You should still see the dashboard shell without a compilation error overlay.

Does App show an import error?

  • Confirm the component files are named DataTable.jsx and SummaryGrid.jsx with matching capitalization.
  • Check that src/api.js exports apiFetch.
  • Ask for help with the dashboard imports.
  • Replace the existing load button with this dashboard button:
          <button
            className="primary-button"
            disabled={loading}
            onClick={loadDashboard}
            type="button"
          >
            {loading ? 'Loading...' : 'Load dashboard'}
          </button>

What changes in the header?

The button now runs loadDashboard() and prevents another click while both requests are active. Its label gives the user a visible loading state.

  • Save src/App.jsx.
  • Refresh the browser page.

You should see Load dashboard in the dashboard header instead of the earlier assets-only action.

  • Replace the raw JSON debug panel with this analytics section:
      <section className="content-section" aria-labelledby="analytics-title">
        <div className="section-heading">
          <div>
            <p className="eyebrow">Overview</p>
            <h2 id="analytics-title">Analytics</h2>
          </div>
          <code>/api/dashboard/analytics/</code>
        </div>
        {analytics === null ? (
          <p className="empty-state">Load the dashboard to request analytics.</p>
        ) : (
          <SummaryGrid data={analytics} />
        )}
      </section>

How does the analytics section change?

The section starts with an instruction before the first request. It switches to SummaryGrid as soon as an analytics response arrives.

  • Add this assets section directly below the analytics section:
      <section className="content-section" aria-labelledby="assets-title">
        <div className="section-heading">
          <div>
            <p className="eyebrow">Inventory view</p>
            <h2 id="assets-title">Assets</h2>
          </div>
          <code>/api/assets/</code>
        </div>
        <DataTable rows={assets} />
      </section>

How does the assets section change?

The section passes the normalized asset array into DataTable. The table handles its own empty state and discovers columns only when rows exist.

  • Save src/App.jsx.
  • Refresh the browser page.

You should see separate Analytics and Assets sections. The formatted raw JSON panel should be gone.

Do you still see the raw JSON panel?

  • Remove the old element that renders the response through JSON.stringify.
  • Confirm both new sections sit inside the authenticated dashboard return block.
  • Ask for help replacing the debug panel.

The new components need layout rules for cards and wide tables. These styles keep the dashboard readable while preserving every discovered column.

  • In the VS Code Explorer sidebar, select src/styles.css.
  • Add the summary card layout by pasting this code:
.summary-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 0.85rem;
}

.summary-card {
  min-width: 0;
  border: 1px solid #e0e6f0;
  border-radius: 0.85rem;
  background: #f8faff;
  padding: 1rem;
}

.summary-card span {
  display: block;
  margin-bottom: 0.45rem;
  color: #68758e;
  font-size: 0.8rem;
  font-weight: 700;
}

.summary-card strong {
  display: block;
  overflow-wrap: anywhere;
  font-size: 1.35rem;
}

What do the card styles do?

  • The responsive grid creates as many card columns as the available width can hold.
  • Each card keeps long analytics values inside its border with overflow-wrap.
  • Add the responsive table styles below the summary card rules:
.table-scroll {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  border-bottom: 1px solid #e5e9f1;
  padding: 0.8rem;
  text-align: left;
  vertical-align: top;
}

th {
  color: #53617a;
  font-size: 0.75rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

td {
  max-width: 280px;
  overflow-wrap: anywhere;
}

What do the table styles do?

The table keeps its full width on large screens. The table-scroll container allows horizontal scrolling when discovered columns exceed a smaller screen.

  • Save src/styles.css.
  • Refresh the browser page.

You should see the analytics area prepared for a responsive card grid. The assets area should keep wide content inside a horizontally scrollable table region.

  • Add the shared empty-state style below the table rules:
.empty-state {
  border: 1px dashed #cbd4e3;
  border-radius: 0.8rem;
  margin: 0;
  padding: 1rem;
  color: #68758e;
}

What does the empty state show?

The dashed panel makes missing analytics or assets visible without making the state look like an error. It also frames the initial instruction before the first dashboard request.

  • Save src/styles.css.
  • Refresh the browser page.

You should see the initial analytics instruction inside a dashed panel.

Do the cards or table look unstyled?

  • Confirm src/main.jsx still imports ./styles.css.
  • Check that the CSS class names match summary-grid, summary-card, table-scroll, and empty-state.
  • Ask for help with the dashboard styles.

✔️ Awesome, I've got everything!

Great. Double check that you saved src/api.js, src/App.jsx, src/SummaryGrid.jsx, src/DataTable.jsx, and src/styles.css.

ⓧ I'd like to double check the full code

Compare the authenticated client in src/api.js with this complete file.

const TOKEN_KEY = 'asset-dashboard-tokens'

async function readJson(response) {
  const text = await response.text()

  if (!text) {
    return null
  }

  try {
    return JSON.parse(text)
  } catch {
    return text
  }
}

function messageFromBody(body, fallback) {
  if (typeof body === 'string' && body.trim()) {
    return body
  }

  if (body && typeof body === 'object') {
    return body.detail || JSON.stringify(body)
  }

  return fallback
}

export function readTokens() {
  const stored = sessionStorage.getItem(TOKEN_KEY)

  if (!stored) {
    return null
  }

  try {
    const tokens = JSON.parse(stored)
    return tokens?.access && tokens?.refresh ? tokens : null
  } catch {
    sessionStorage.removeItem(TOKEN_KEY)
    return null
  }
}

export function saveTokens(tokens) {
  sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens))
}

export function clearTokens() {
  sessionStorage.removeItem(TOKEN_KEY)
}

export async function login({ loginField, identifier, password }) {
  const response = await fetch('/api/auth/login/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      [loginField]: identifier,
      password,
    }),
  })

  const body = await readJson(response)

  if (!response.ok) {
    throw new Error(messageFromBody(body, 'Sign in failed.'))
  }

  if (!body?.access || !body?.refresh) {
    throw new Error('The login response did not include access and refresh tokens.')
  }

  return {
    access: body.access,
    refresh: body.refresh,
  }
}

async function refreshTokens(refresh) {
  const response = await fetch('/api/auth/refresh/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ refresh }),
  })

  const body = await readJson(response)

  if (!response.ok || !body?.access) {
    clearTokens()
    throw new Error(messageFromBody(body, 'Your session expired. Sign in again.'))
  }

  const current = readTokens()
  const nextTokens = {
    access: body.access,
    refresh: body.refresh || current?.refresh || refresh,
  }

  saveTokens(nextTokens)
  return nextTokens
}

async function sendRequest(path, options, access) {
  const headers = new Headers(options.headers || {})

  if (access) {
    headers.set('Authorization', `Bearer ${access}`)
  }

  return fetch(path, {
    ...options,
    headers,
  })
}

export async function apiFetch(path, options = {}) {
  let tokens = readTokens()
  let response = await sendRequest(path, options, tokens?.access)

  if (response.status === 401 && tokens?.refresh) {
    tokens = await refreshTokens(tokens.refresh)
    response = await sendRequest(path, options, tokens.access)
  }

  if (!response.ok) {
    const body = await readJson(response)
    throw new Error(messageFromBody(body, `Request failed with status ${response.status}.`))
  }

  return readJson(response)
}

This file keeps login, storage, refresh, authorization, retry, parsing, and error handling in one request module.

Compare src/SummaryGrid.jsx with this complete file.

function labelFor(key) {
  return key
    .replace(/_/g, ' ')
    .replace(/\b\w/g, (character) => character.toUpperCase())
}

function displayValue(value) {
  if (value === null || value === undefined) {
    return 'Not provided'
  }

  if (typeof value === 'object') {
    return JSON.stringify(value)
  }

  return String(value)
}

export default function SummaryGrid({ data }) {
  const entries = Object.entries(data || {})

  if (!entries.length) {
    return <p className="empty-state">No analytics were returned.</p>
  }

  return (
    <div className="summary-grid">
      {entries.map(([key, value]) => (
        <article className="summary-card" key={key}>
          <span>{labelFor(key)}</span>
          <strong>{displayValue(value)}</strong>
        </article>
      ))}
    </div>
  )
}

This component converts every analytics entry into a labeled card.

Compare src/DataTable.jsx with this complete file.

function formatValue(value) {
  if (value === null || value === undefined || value === '') {
    return 'Not provided'
  }

  if (typeof value === 'boolean') {
    return value ? 'Yes' : 'No'
  }

  if (typeof value === 'object') {
    return JSON.stringify(value)
  }

  return String(value)
}

function labelFor(key) {
  return key
    .replace(/_/g, ' ')
    .replace(/\b\w/g, (character) => character.toUpperCase())
}

export default function DataTable({ rows }) {
  if (!rows.length) {
    return <p className="empty-state">No assets were returned.</p>
  }

  const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))].slice(0, 8)

  return (
    <div className="table-scroll">
      <table>
        <thead>
          <tr>
            {columns.map((column) => (
              <th key={column}>{labelFor(column)}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, index) => (
            <tr key={row.id ?? index}>
              {columns.map((column) => (
                <td key={column}>{formatValue(row[column])}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

This component discovers up to eight columns before formatting every returned value.

Compare src/App.jsx with this cumulative file for the current step.

import { useState } from 'react'
import { apiFetch, clearTokens, login, readTokens, saveTokens } from './api.js'
import DataTable from './DataTable.jsx'
import SummaryGrid from './SummaryGrid.jsx'

function normalizeCollection(payload) {
  if (Array.isArray(payload)) {
    return payload
  }

  if (Array.isArray(payload?.results)) {
    return payload.results
  }

  return []
}

export default function App() {
  const [isAuthenticated, setIsAuthenticated] = useState(
    () => Boolean(readTokens()?.access),
  )
  const [form, setForm] = useState({
    loginField: 'username',
    identifier: '',
    password: '',
  })
  const [analytics, setAnalytics] = useState(null)
  const [assets, setAssets] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  async function handleLogin(event) {
    event.preventDefault()
    setLoading(true)
    setError('')

    try {
      const tokens = await login(form)
      saveTokens(tokens)
      setIsAuthenticated(true)
      setForm((current) => ({ ...current, password: '' }))
    } catch (requestError) {
      setError(requestError.message)
    } finally {
      setLoading(false)
    }
  }

  async function loadDashboard() {
    setLoading(true)
    setError('')

    try {
      const analyticsPayload = await apiFetch('/api/dashboard/analytics/')
      const assetsPayload = await apiFetch('/api/assets/')
      setAnalytics(analyticsPayload || {})
      setAssets(normalizeCollection(assetsPayload))
    } catch (requestError) {
      setError(requestError.message)

      if (!readTokens()) {
        setIsAuthenticated(false)
      }
    } finally {
      setLoading(false)
    }
  }

  if (!isAuthenticated) {
    return (
      <main className="page-shell login-shell">
        <section className="auth-layout">
          <div className="brand-panel">
            <p className="eyebrow">Asset Operations</p>
            <h1>Turn your Django API into a working dashboard.</h1>
            <p>
              Sign in with Simple JWT, then load live analytics and asset data
              from the backend mounted under <code>/api/</code>.
            </p>
            <span className="endpoint-badge">Proxy target: localhost:8000</span>
          </div>

          <form className="login-card" onSubmit={handleLogin}>
            <div>
              <p className="eyebrow">Secure access</p>
              <h2>Sign in</h2>
              <p className="muted">Use an account that already exists in Django.</p>
            </div>

            <label htmlFor="loginField">Identifier type</label>
            <select
              id="loginField"
              name="loginField"
              value={form.loginField}
              onChange={updateForm}
            >
              <option value="username">Username</option>
              <option value="email">Email</option>
            </select>

            <label htmlFor="identifier">Identifier</label>
            <input
              id="identifier"
              name="identifier"
              value={form.identifier}
              onChange={updateForm}
              autoComplete="username"
              required
            />

            <label htmlFor="password">Password</label>
            <input
              id="password"
              name="password"
              type="password"
              value={form.password}
              onChange={updateForm}
              autoComplete="current-password"
              required
            />

            {error && <p className="error-message">{error}</p>}

            <button className="primary-button" disabled={loading} type="submit">
              {loading ? 'Signing in...' : 'Sign in'}
            </button>
          </form>
        </section>
      </main>
    )
  }

  return (
    <main className="page-shell dashboard-shell">
      <header className="dashboard-header">
        <div>
          <p className="eyebrow">Asset Operations</p>
          <h1>Dashboard</h1>
          <p className="muted">Live data from your Django REST API.</p>
        </div>
        <div className="header-actions">
          <button
            className="primary-button"
            disabled={loading}
            onClick={loadDashboard}
            type="button"
          >
            {loading ? 'Loading...' : 'Load dashboard'}
          </button>
        </div>
      </header>

      {error && <p className="error-message dashboard-error">{error}</p>}

      <section className="content-section" aria-labelledby="analytics-title">
        <div className="section-heading">
          <div>
            <p className="eyebrow">Overview</p>
            <h2 id="analytics-title">Analytics</h2>
          </div>
          <code>/api/dashboard/analytics/</code>
        </div>
        {analytics === null ? (
          <p className="empty-state">Load the dashboard to request analytics.</p>
        ) : (
          <SummaryGrid data={analytics} />
        )}
      </section>

      <section className="content-section" aria-labelledby="assets-title">
        <div className="section-heading">
          <div>
            <p className="eyebrow">Inventory view</p>
            <h2 id="assets-title">Assets</h2>
          </div>
          <code>/api/assets/</code>
        </div>
        <DataTable rows={assets} />
      </section>
    </main>
  )
}

This cumulative file keeps the existing login form while replacing the raw response with live dashboard components.

Before you load the dashboard, what analytics labels and asset columns do you expect your backend responses to create?

  • Switch back to the Local URL in your browser.
  • Click Load dashboard.

You should see live values from /api/dashboard/analytics/ as summary cards. You should see the /api/assets/ response as a table whether the backend returns an array or an object containing results.

That is the raw API limitation solved. Your backend responses now become a dashboard that adapts to the fields they actually contain.

Does the dashboard request fail?

  • Confirm the Django backend from earlier is still running behind the existing /api proxy.
  • Sign in again if the page returned to the login screen after a failed refresh.
  • Check the displayed backend error without copying any token values.
  • Ask for help with the dashboard requests.

Your dashboard now refreshes authenticated requests and turns changing API responses into usable views. Next, you will make session exit predictable and prove the frontend can produce a production build.

Finish Session Controls and Build

Your React dashboard now loads protected data from Django. Its JWT session still needs a predictable exit when someone finishes.

The custom /api/auth/logout/ contract is undocumented. Local sign-out removes the current tab's token pair without claiming server-side revocation.

You'll also make every request state visible. Then you'll prove the source compiles with the pinned Vite build.

In this step, get ready to:
  • Add a local sign-out path that clears the tab session.
  • Make loading and response states explicit.
  • Produce the frontend bundle with the pinned Vite build script.
Add predictable local sign-out

The token pair lives in sessionStorage under asset-dashboard-tokens. Local sign-out clears that tab-scoped session plus every piece of loaded dashboard state.

  • In src/App.jsx, locate the end of loadDashboard().
  • Add the local sign-out function directly below it by pasting this code:
  function handleLocalLogout() {
    clearTokens()
    setAnalytics(null)
    setAssets([])
    setError('')
    setIsAuthenticated(false)
  }

What does this function clear?

  • The clearTokens() call removes the access and refresh tokens from the current browser tab.
  • The analytics and asset setters discard data loaded during the authenticated session.
  • The error setter removes any message left by the previous request.
  • The authentication setter returns the interface to the login screen.
  • In the dashboard header inside src/App.jsx, replace the existing action controls with this block:
        <div className="header-actions">
          <button
            className="primary-button"
            disabled={loading}
            onClick={loadDashboard}
            type="button"
          >
            {loading ? 'Loading...' : 'Load dashboard'}
          </button>
          <button className="secondary-button" onClick={handleLocalLogout} type="button">
            Sign out locally
          </button>
        </div>

How do these controls protect the session?

The primary button uses loading to prevent duplicate dashboard requests. Its label also shows when a request is active.

The secondary button calls handleLocalLogout() immediately. It makes no request to the undocumented server logout endpoint.

  • Save src/App.jsx.

Before you click Sign out locally, which parts of the authenticated dashboard do you expect to remain?

  • Click Sign out locally in the dashboard header.

You'll see the login screen return. The loaded analytics, assets, error message, and tab-scoped token pair are cleared.

Does the dashboard stay open?

  • Confirm the button uses onClick={handleLocalLogout} with the same capitalization as the function.
  • Check that setIsAuthenticated(false) remains inside handleLocalLogout().
  • Make sure the new function sits inside App() before the login-screen return block.
  • Help me debug local sign-out in my React dashboard.

The form uses the login request you already tested. It clears the password field after a successful sign-in.

  • Sign in again with your valid backend account.
Finish request states

A protected request can succeed or fail after the dashboard begins loading. The application needs to stop its loading state in both cases.

A refresh failure has one extra consequence. The API client clears the stored token pair, so the interface must return to the login screen.

  • In src/App.jsx, replace the existing loadDashboard() function with this completed version:
  async function loadDashboard() {
    setLoading(true)
    setError('')

    try {
      const analyticsPayload = await apiFetch('/api/dashboard/analytics/')
      const assetsPayload = await apiFetch('/api/assets/')
      setAnalytics(analyticsPayload || {})
      setAssets(normalizeCollection(assetsPayload))
    } catch (requestError) {
      setError(requestError.message)

      if (!readTokens()) {
        setIsAuthenticated(false)
      }
    } finally {
      setLoading(false)
    }
  }

How does request recovery work?

  • The function enables loading before either protected request begins.
  • The try block stores the analytics response plus normalized asset rows after both requests succeed.
  • The catch block exposes the API error through the existing error region.
  • The readTokens() check returns the interface to login after the API client clears a failed session.
  • The finally block re-enables the controls after either outcome.
  • Save src/App.jsx.
  • Return to the authenticated dashboard in your browser.
  • Click Load dashboard.

You'll see the button switch to Loading... while the requests run. A successful request leaves you on the dashboard with live cards plus asset rows.

Does loading stay active?

  • Confirm setLoading(false) is inside the finally block.
  • Check that both apiFetch() calls remain inside the same try block.
  • Help me fix a dashboard request that stays in its loading state.
  • In the login form inside src/App.jsx, replace the submit button with this state-aware version:
            <button className="primary-button" disabled={loading} type="submit">
              {loading ? 'Signing in...' : 'Sign in'}
            </button>

Why disable the sign-in control?

The disabled state prevents repeated login submissions while one request is active. The changing label gives the user immediate feedback that the request is running.

  • Save src/App.jsx.
  • Click Sign out locally.
  • Submit the login form with your valid backend account.

You'll see Signing in... while the request runs. The button remains disabled until the request finishes.

Does the sign-in button stay enabled?

  • Confirm the button includes disabled={loading}.
  • Check that handleLogin() sets loading to true before the request.
  • Make sure handleLogin() sets loading to false in its finally block.
  • Help me debug the loading state on my React sign-in button.

An unloaded analytics section has not made a request yet. An empty analytics section has completed a request that returned no entries.

  • In the analytics section inside src/App.jsx, replace its current result rendering with this conditional block:
        {analytics === null ? (
          <p className="empty-state">Load the dashboard to request analytics.</p>
        ) : (
          <SummaryGrid data={analytics} />
        )}

How are empty states separated?

A null analytics value shows the unloaded message. An empty response reaches SummaryGrid and shows No analytics were returned.

The existing DataTable handles an empty asset array with No assets were returned. This keeps each state tied to the component that owns it.

  • Save src/App.jsx.

Before you reset the session, what should the analytics section show after a fresh sign-in but before its first request?

  • Click Sign out locally.
  • Sign in again with your valid backend account.

You'll see Load the dashboard to request analytics. in the analytics section. This confirms the unloaded state is distinct from a completed empty response.

  • Click Load dashboard to request the live responses.

You'll see analytics cards when values exist. Empty responses instead show the dedicated analytics or asset message.

Do you see the wrong empty message?

  • Confirm the initial analytics state is useState(null).
  • Check that a successful request uses setAnalytics(analyticsPayload || {}).
  • Make sure local sign-out resets analytics with setAnalytics(null).
  • Help me distinguish unloaded and empty analytics states in my React dashboard.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/App.jsx.

ⓧ I'd like to double check the full code

Compare your src/App.jsx with this completed file:

import { useState } from 'react'
import { apiFetch, clearTokens, login, readTokens, saveTokens } from './api.js'
import DataTable from './DataTable.jsx'
import SummaryGrid from './SummaryGrid.jsx'

function normalizeCollection(payload) {
  if (Array.isArray(payload)) {
    return payload
  }

  if (Array.isArray(payload?.results)) {
    return payload.results
  }

  return []
}

export default function App() {
  const [isAuthenticated, setIsAuthenticated] = useState(
    () => Boolean(readTokens()?.access),
  )
  const [form, setForm] = useState({
    loginField: 'username',
    identifier: '',
    password: '',
  })
  const [analytics, setAnalytics] = useState(null)
  const [assets, setAssets] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  async function handleLogin(event) {
    event.preventDefault()
    setLoading(true)
    setError('')

    try {
      const tokens = await login(form)
      saveTokens(tokens)
      setIsAuthenticated(true)
      setForm((current) => ({ ...current, password: '' }))
    } catch (requestError) {
      setError(requestError.message)
    } finally {
      setLoading(false)
    }
  }

  async function loadDashboard() {
    setLoading(true)
    setError('')

    try {
      const analyticsPayload = await apiFetch('/api/dashboard/analytics/')
      const assetsPayload = await apiFetch('/api/assets/')
      setAnalytics(analyticsPayload || {})
      setAssets(normalizeCollection(assetsPayload))
    } catch (requestError) {
      setError(requestError.message)

      if (!readTokens()) {
        setIsAuthenticated(false)
      }
    } finally {
      setLoading(false)
    }
  }

  function handleLocalLogout() {
    clearTokens()
    setAnalytics(null)
    setAssets([])
    setError('')
    setIsAuthenticated(false)
  }

  if (!isAuthenticated) {
    return (
      <main className="page-shell login-shell">
        <section className="auth-layout">
          <div className="brand-panel">
            <p className="eyebrow">Asset Operations</p>
            <h1>Turn your Django API into a working dashboard.</h1>
            <p>
              Sign in with Simple JWT, then load live analytics and asset data
              from the backend mounted under <code>/api/</code>.
            </p>
            <span className="endpoint-badge">Proxy target: localhost:8000</span>
          </div>

          <form className="login-card" onSubmit={handleLogin}>
            <div>
              <p className="eyebrow">Secure access</p>
              <h2>Sign in</h2>
              <p className="muted">Use an account that already exists in Django.</p>
            </div>

            <label htmlFor="loginField">Identifier type</label>
            <select
              id="loginField"
              name="loginField"
              value={form.loginField}
              onChange={updateForm}
            >
              <option value="username">Username</option>
              <option value="email">Email</option>
            </select>

            <label htmlFor="identifier">Identifier</label>
            <input
              id="identifier"
              name="identifier"
              value={form.identifier}
              onChange={updateForm}
              autoComplete="username"
              required
            />

            <label htmlFor="password">Password</label>
            <input
              id="password"
              name="password"
              type="password"
              value={form.password}
              onChange={updateForm}
              autoComplete="current-password"
              required
            />

            {error && <p className="error-message">{error}</p>}

            <button className="primary-button" disabled={loading} type="submit">
              {loading ? 'Signing in...' : 'Sign in'}
            </button>
          </form>
        </section>
      </main>
    )
  }

  return (
    <main className="page-shell dashboard-shell">
      <header className="dashboard-header">
        <div>
          <p className="eyebrow">Asset Operations</p>
          <h1>Dashboard</h1>
          <p className="muted">Live data from your Django REST API.</p>
        </div>
        <div className="header-actions">
          <button
            className="primary-button"
            disabled={loading}
            onClick={loadDashboard}
            type="button"
          >
            {loading ? 'Loading...' : 'Load dashboard'}
          </button>
          <button className="secondary-button" onClick={handleLocalLogout} type="button">
            Sign out locally
          </button>
        </div>
      </header>

      {error && <p className="error-message dashboard-error">{error}</p>}

      <section className="content-section" aria-labelledby="analytics-title">
        <div className="section-heading">
          <div>
            <p className="eyebrow">Overview</p>
            <h2 id="analytics-title">Analytics</h2>
          </div>
          <code>/api/dashboard/analytics/</code>
        </div>
        {analytics === null ? (
          <p className="empty-state">Load the dashboard to request analytics.</p>
        ) : (
          <SummaryGrid data={analytics} />
        )}
      </section>

      <section className="content-section" aria-labelledby="assets-title">
        <div className="section-heading">
          <div>
            <p className="eyebrow">Inventory view</p>
            <h2 id="assets-title">Assets</h2>
          </div>
          <code>/api/assets/</code>
        </div>
        <DataTable rows={assets} />
      </section>
    </main>
  )
}

How does the completed file fit together?

This file combines controlled authentication with tab-scoped token checks. It also connects loading, errors, empty responses, refresh failure, and local sign-out to visible interface states.

Produce the frontend build

The development server proves the dashboard works interactively. A production build checks that the complete source can be compiled as a deployable frontend.

Before you run the final check, do you expect the source to compile cleanly after these session-control edits?

  • Return to the integrated PowerShell terminal in Visual Studio Code.
  • Build the frontend by running this command:
npm run build

What does this build check?

This command invokes the build script from package.json. Vite compiles the source you already tested into production assets.

The terminal should complete the build and return to PowerShell without an error. That confirms the dashboard source packages successfully.

Does the build fail?

  • Check src/App.jsx for a missing closing parenthesis or JSX tag near the new controls.
  • Confirm handleLocalLogout() appears once inside App().
  • Make sure the dashboard buttons remain inside the header-actions container.
  • Help me diagnose a failed production build in my Vite React dashboard.

That's the final build check complete. Your dashboard now leaves its tab-scoped session predictably and compiles without errors.

Secret mission

Search Every Asset Field

Your table adapts to changing serializer fields, but a growing asset list still takes time to scan. Add one search box that checks every formatted value and instantly narrows the visible rows.

Clean Up Your Resources

Clean Up Your Resources

Your React frontend runs locally, so there are no ongoing costs or cloud resources to remove. Choose whether to keep the workspace, pause its local processes, or delete the frontend files while leaving your existing Django backend untouched.

Resources you used:

  • The frontend workspace containing index.html, package.json, vite.config.js, and the src/ folder.
  • The Vite development server process, if it is still running in the integrated terminal.
  • The browser-tab token pair stored under the sessionStorage key asset-dashboard-tokens while the dashboard is signed in.

Keep everything running

This option keeps the workspace available for active development. Your authenticated session remains scoped to the current browser tab.

  • Keep the frontend workspace on your computer.
  • Leave the Vite development server running while you are actively testing the dashboard.
  • Click Sign out locally whenever you want to end the authenticated browser session.

Pause - I'll come back to this later

Pausing stops the local development process while preserving every frontend file. You can return to the same workspace later.

  • Click Sign out locally in the dashboard header.
  • Switch back to the integrated terminal from earlier.
  • Press Ctrl+C to stop the Vite development server.
  • Keep the frontend workspace in its current location.

Your dashboard is now paused. The source files remain ready for another session.

Delete - I don't want to use this again

Deleting the frontend workspace becomes permanent after you empty the Recycle Bin. Your existing Django backend remains untouched because it is outside these files.

  • Click Sign out locally in the dashboard header.
  • Switch back to the integrated terminal from earlier.
  • Press Ctrl+C to stop the Vite development server.
  • Launch Windows File Explorer through Windows search.
  • Locate the frontend workspace by finding the folder that contains index.html, package.json, vite.config.js, and src/.
  • Delete that folder.
  • Empty the Recycle Bin.

The dashboard tab should show the login screen before you close it. The frontend workspace should no longer appear in its former parent location.

That completes the cleanup. The frontend session, process, and files are gone.

Nice Work!

Nice Work!

You did it! You built an authenticated React dashboard that turns live Django REST Framework API responses into searchable dashboard content.

You've learned how to:

  • Build a separate React frontend with a controlled sign-in form for the Django REST Framework API. Keep the Simple JWT access token plus refresh token in sessionStorage for the current browser tab.
  • Create one authenticated request path that adds an access token to protected requests. Use automatic token refresh to retry the original request once.
  • Render arbitrary analytics keys as readable summary cards. Convert plain arrays or results-wrapped responses into a schema-adaptive asset table. Clear the local session predictably. Produce a successful production build with Vite.
  • Complete the optional Secret Mission by adding full-row client-side search across every returned asset field.

Ready to quiz yourself?