React Task Manager with Role-Based Access
Build a React SPA with JWT auth, CRUD operations, and role-based UI.
Introduction
30 Second Summary
Think about the apps you use every day to manage tasks. They have login screens, dashboards, and forms that let you create and edit things. Behind the scenes, these apps are split into two parts: a backend that handles data and logic, and a frontend that handles what you see and interact with.
In this project, you will build a React frontend that connects to your existing Spring Boot backend, authenticates users with JWT tokens, and provides a full task management interface with create, read, update, and delete operations.
What You'll Build
A React single-page application with login, protected routes, and CRUD task management, powered by Ant Design components and connected to your Spring Boot REST APIs.
By the end of this project, you'll have:
- A Vite + React project with Ant Design components for a professional admin interface.
- An authentication system using JWT tokens with automatic injection via Axios interceptors.
- Protected routes using React Router that redirect unauthenticated users to login, with a sidebar navigation layout.
- Secret Mission: Role-based UI gating that shows or hides actions depending on the logged-in user's role.
Are there any prerequisites?
This project assumes you have a Spring Boot backend with JWT authentication and Task Management APIs already running locally on port 8080. Your backend should expose endpoints for login, and for creating, reading, updating, and deleting tasks.
You also need Node.js installed on your machine. The project uses Vite to scaffold the React app, and you will install all other dependencies in Step 1.
Scaffold the React Project
Your Spring Boot backend handles the business logic, data persistence, and authentication. Now you need a frontend that gives users an interface to interact with those APIs.
In this step, you'll scaffold a React project using Vite and install the libraries that power your UI, routing, and API communication.
In this step, get ready to:
- Scaffold a new React project with Vite.
- Install Ant Design, React Router, and Axios.
- Configure the dev server proxy to connect your frontend to your backend.
Create the React project and install dependencies
Vite is a modern build tool that scaffolds a ready-to-run React project in seconds. You'll use it to generate the project structure, then add the libraries your app needs.
- Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the React project by running this command:
npm create vite@latest task-manager-frontend -- --template react
What will you see in the terminal?
This command triggers a few interactive prompts. First, npm may ask you to confirm installing the create-vite package. Press y then Enter to proceed.
Vite will then ask about rolldown-vite (Experimental). Select No (the default). It will also ask about installing dependencies and starting now. Select No here so you can install additional packages first.
- Move into the new project folder by running this command:
cd task-manager-frontend
- Install the base project dependencies by running:
npm install
- Install Ant Design (UI components), React Router (page navigation), Axios (HTTP client), and the Ant Design icons package by running:
npm install antd react-router axios @ant-design/icons
What do these packages do?
- antd provides pre-built UI components like Tables, Forms, Modals, and Layouts.
- react-router handles client-side page navigation without full page reloads.
- axios makes HTTP requests to your backend and supports interceptors for attaching tokens.
- @ant-design/icons provides icon components used in navigation menus and buttons.
- Confirm the packages installed successfully by running:
ls node_modules/antd node_modules/react-router node_modules/axios
You should see the folder contents listed for each package without any errors.
Seeing permission errors or install failures?
- Make sure you ran npm install before the second install command. The base install sets up node_modules first.
- Check that you are inside the task-manager-frontend folder. Run pwd to confirm your current directory.
Help me troubleshoot my npm install errors.
Clean out the default template files
The Vite template comes with demo content you don't need. You'll replace it with a blank canvas so you can build your own components from scratch.
- Open this project in VS Code by running:
code .
What does this command do?
The code . command opens the current folder in VS Code. If this doesn't work, open VS Code manually, click File, then Open Folder, and select the task-manager-frontend folder on your Desktop.
- In the VS Code Explorer sidebar, expand the src folder.
- Open src/App.css and select all the content (Cmd+A on macOS or Ctrl+A on Windows).
- Delete everything so the file is completely empty.
- Save src/App.css.
- Open src/App.jsx and replace all of its contents with the following code:
function App() {
return <div></div>;
}
export default App;
What does this code do?
This is the simplest possible React component. It renders an empty <div> so you start with a blank page. You'll replace this with real components in later steps.
- Save src/App.jsx.
- Verify the dev server works by opening the VS Code terminal (Ctrl+`) and running:
npm run dev
You should see Vite print a local URL at http://localhost:5173. Open that URL in your browser. The page should be completely blank with no console errors.
Page not loading or seeing errors?
- If the terminal says the port is in use, stop any other dev servers and retry.
- If you see a syntax error, double-check that src/App.jsx matches the code above exactly.
- Make sure your Spring Boot backend is still running on port 8080. You'll need it for the proxy in the next substep.
Help me debug why my Vite dev server isn't starting.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/App.jsx and the dev server is running at http://localhost:5173.
ⓧ I'd like to double check the full code
Here is what src/App.jsx should look like at this point:
function App() {
return <div></div>;
}
export default App;
Configure the Vite dev server proxy
Your React frontend runs on port 5173, but your Spring Boot backend runs on port 8080. Browsers block cross-origin requests by default due to CORS policies.
Vite's proxy solves this during development by forwarding requests from your frontend to the backend behind the scenes.
- Stop the dev server by pressing Ctrl+C in the terminal.
- Open vite.config.js in the project root (not inside src).
- Replace all of its contents with the following code:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
What does this code do?
- plugins: [react()] enables React support with JSX transformation.
- server.proxy tells Vite to intercept any request starting with /api and forward it to your Spring Boot backend at http://localhost:8080.
- changeOrigin: true modifies the request's origin header so the backend accepts it as a same-origin request.
Why use a proxy instead of hitting the backend URL directly?
Without the proxy, your browser would block requests from localhost:5173 to localhost:8080 because they are different origins. The proxy makes both appear to come from the same origin, eliminating CORS errors during development.
- Save vite.config.js.
- Restart the dev server by running:
npm run dev
Open http://localhost:5173 in your browser. The page should be blank with no console errors.
Seeing a syntax error on startup?
- Check that your vite.config.js uses semicolons after each import and after the closing });. Missing semicolons can cause parse errors.
- Make sure you are editing vite.config.js in the project root, not a file inside src/.
Help me fix my vite.config.js proxy setup.
✔️ Awesome, I've got everything!
Great. Make sure vite.config.js is saved and the dev server is running without errors.
ⓧ I'd like to double check the full code
Here is what vite.config.js should look like:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
Your React project is scaffolded and connected to your backend. Next up, you'll build the authentication layer that lets users log in and stay logged in across page refreshes.
Build the Auth Layer and Login Page
Your Spring Boot backend issues a JWT token when a user logs in successfully. The frontend needs a way to store that token and automatically attach it to every API request so the backend knows who is making the call.
In this step, you'll build the authentication layer for your React app. This includes an Axios HTTP client with token interceptors, a React Context for managing login state, and a login page built with Ant Design components.
In this step, get ready to:
- Create an Axios instance with JWT interceptors.
- Build an AuthContext for managing login state.
- Build a Login page with Ant Design components.
Create the Axios HTTP client with interceptors
Every API call your frontend makes needs the JWT token attached as a header. Instead of manually adding the token to each call, Axios interceptors let you do this automatically.
- In VS Code, create a new folder called api inside src.
- Create a new file called axios.js inside src/api/.
- Add the Axios instance and request interceptor by pasting this code:
import axios from 'axios';
// Create a pre-configured Axios instance pointing at our proxy
const api = axios.create({
baseURL: '/api',
});
// Automatically attach the JWT token to every outgoing request
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
What does this code do?
axios.create({ baseURL: '/api' }) creates an Axios instance where every request URL starts with /api. Thanks to the Vite proxy from Step 1, these get forwarded to your Spring Boot backend on port 8080.
The request interceptor runs before every API call. It reads the JWT token from localStorage and adds it as a Bearer token in the Authorization header. This means you never have to manually pass the token to each request.
- Add the response interceptor below the request interceptor in the same file:
// Redirect to login if any response returns 401 Unauthorized
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
What does the response interceptor do?
This interceptor watches every API response. If the backend returns a 401 status (meaning the token is expired or invalid), it automatically clears the stored token and redirects the user to the login page.
This handles session expiration gracefully. The user gets sent back to login instead of seeing broken API calls.
- Save src/api/axios.js.
- You won't see a change in the browser yet. The next chunk (AuthContext) uses this Axios instance.
Seeing a red underline on the import?
If VS Code shows a red squiggly line under import axios from 'axios', make sure you installed the axios package in Step 1. You can confirm by checking your package.json for an axios entry under dependencies.
help me fix import errors in my Axios file
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
Create the AuthContext for login state
The React Context API lets you share data (like the logged-in user) across your entire app without passing props through every component. Your AuthContext will store who is logged in, what role they have, and provide login/logout functions.
- Create a new folder called context inside src.
- Create a new file called AuthContext.jsx inside src/context/.
- Paste the following code into AuthContext.jsx:
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../api/axios';
// Create a context to hold auth state
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// On app load, check if a user session already exists in localStorage
useEffect(() => {
const storedUser = localStorage.getItem('user');
const token = localStorage.getItem('token');
if (storedUser && token) {
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
// Call the Spring Boot login endpoint and store the returned token
const login = async (username, password) => {
const response = await api.post('/auth/login', { username, password });
const { token, role, username: name } = response.data;
localStorage.setItem('token', token);
const userData = { username: name, role };
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
return userData;
};
// Clear stored credentials and reset state
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
// Custom hook for consuming auth state anywhere in the app
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
What does this code do?
- AuthProvider wraps your app and provides auth state (the current user, their role) to all child components.
- The useEffect restores a previous session by reading from localStorage on page load. This means refreshing the browser doesn't log you out.
- The login() function sends credentials to your Spring Boot /api/auth/login endpoint, stores the returned token and user data, then updates state.
- The useAuth() hook gives any component easy access to the user, login, and logout functions.
- Save src/context/AuthContext.jsx.
Red underline on the api import?
Make sure the relative path '../api/axios' matches your folder structure. The context folder and api folder should both be directly inside src.
help me fix the import path for my Axios instance in AuthContext
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../api/axios';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const storedUser = localStorage.getItem('user');
const token = localStorage.getItem('token');
if (storedUser && token) {
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
const login = async (username, password) => {
const response = await api.post('/auth/login', { username, password });
const { token, role, username: name } = response.data;
localStorage.setItem('token', token);
const userData = { username: name, role };
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
return userData;
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Now you need to wrap your entire app with the AuthProvider so every component can access auth state. You also need BrowserRouter from React Router to enable client-side navigation.
- Open src/main.jsx in VS Code.
- Replace the entire contents of the file with this code:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App';
import { AuthProvider } from './context/AuthContext';
// Wrap the app with BrowserRouter (for routing) and AuthProvider (for auth state)
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
Why wrap with both BrowserRouter and AuthProvider?
BrowserRouter enables React Router's client-side navigation. AuthProvider makes the user's login state available to every component in the tree.
Both wrap the entire <App /> so that any component at any depth can use routing hooks and auth state.
- Save src/main.jsx.
- Check your browser at http://localhost:5173. You should see a blank white page with no errors in the browser console.
Seeing errors in the browser?
If the page shows an error overlay, check that your import paths are correct. The AuthProvider import should be './context/AuthContext' and BrowserRouter should be imported from 'react-router' (not react-router-dom).
help me fix errors after updating main.jsx with AuthProvider and BrowserRouter
Build the Login page component
Ant Design provides ready-made Form, Input, and Button components that handle validation and styling. Your Login page will use these to collect credentials and call the login() function from AuthContext.
- Create a new folder called pages inside src.
- Create a new file called Login.jsx inside src/pages/.
- Paste the following code into Login.jsx:
import { Form, Input, Button, Card, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { useAuth } from '../context/AuthContext';
function Login() {
const { login } = useAuth();
const navigate = useNavigate();
// Handle form submission: call login, then navigate on success
const onFinish = async (values) => {
try {
await login(values.username, values.password);
message.success('Login successful');
navigate('/dashboard');
} catch (error) {
message.error(error.response?.data?.message || 'Login failed');
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: '#f0f2f5' }}>
<Card title="Task Manager Login" style={{ width: 400 }}>
<Form name="login" onFinish={onFinish} autoComplete="off">
<Form.Item name="username" rules={[{ required: true, message: 'Please enter your username' }]}>
<Input prefix={<UserOutlined />} placeholder="Username" size="large" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: 'Please enter your password' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block size="large">
Log In
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
export default Login;
What does this code do?
- useAuth() grabs the login function from your AuthContext.
- onFinish fires when the Ant Design Form passes validation. It calls login() with the username and password, then navigates to /dashboard on success.
- The rules prop on each Form.Item enables client-side validation. Ant Design shows error messages automatically if fields are empty.
- message.success() and message.error() display toast notifications from Ant Design to give the user feedback.
- Save src/pages/Login.jsx.
- You won't see the login page in the browser yet. You'll wire it up to a route in the next step.
Import errors on antd or @ant-design/icons?
Make sure you installed both antd and @ant-design/icons in Step 1. Check your package.json for both entries under dependencies.
help me fix import errors for Ant Design in my Login component
✔️ Awesome, I've got everything!
Great. Make sure you've saved all files before moving on.
ⓧ I'd like to double check the full code
import { Form, Input, Button, Card, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { useAuth } from '../context/AuthContext';
function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const onFinish = async (values) => {
try {
await login(values.username, values.password);
message.success('Login successful');
navigate('/dashboard');
} catch (error) {
message.error(error.response?.data?.message || 'Login failed');
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: '#f0f2f5' }}>
<Card title="Task Manager Login" style={{ width: 400 }}>
<Form name="login" onFinish={onFinish} autoComplete="off">
<Form.Item name="username" rules={[{ required: true, message: 'Please enter your username' }]}>
<Input prefix={<UserOutlined />} placeholder="Username" size="large" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: 'Please enter your password' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block size="large">
Log In
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
export default Login;
Why Axios interceptors instead of a custom fetch wrapper?
Axios interceptors give you a single place to handle token injection and auth failures. With the native fetch API, you'd need to write a wrapper function and remember to use it everywhere.
Interceptors run automatically on every request and response. If your token expires, the response interceptor catches the 401 and redirects to login without you writing any extra code in each component.
Your auth layer is ready. Next up, you'll create protected routes and the app layout so logged-in users see a sidebar and unauthenticated users get redirected to your new login page.
Set Up Protected Routes and App Layout
Your login page and auth context handle authentication, but right now every URL in the app is wide open. Anyone could type /tasks in the address bar and access it without logging in.
In this step, you'll build a route guard component that blocks unauthenticated users, wrap your pages in a professional sidebar layout using Ant Design's Layout system, and connect everything with React Router's nested route structure.
In this step, get ready to:
- Create a ProtectedRoute component that redirects unauthenticated users.
- Build a sidebar-based layout with Ant Design's Layout components.
- Wire up React Router with public and protected routes.
Create the ProtectedRoute component
A protected route is a wrapper component that sits between the router and your page components. It checks whether a user is logged in before rendering the page content. If no user is found, it redirects to the login page.
- Create a new folder called components inside your src directory.
- Create a new file called ProtectedRoute.jsx inside src/components/ and paste the following code:
import { Navigate } from 'react-router';
import { useAuth } from '../context/AuthContext';
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
// Show a loading state while checking authentication
if (loading) {
return <div>Loading...</div>;
}
// Redirect to login if no user is found
if (!user) {
return <Navigate to="/login" replace />;
}
// Render the protected content
return children;
}
export default ProtectedRoute;
What does this code do?
This component pulls user and loading from your AuthContext using the useAuth() hook you built in the previous step.
- While loading is true, it shows a placeholder so the app doesn't flash the login page before checking localStorage.
- If user is null, it renders the Navigate component which immediately redirects to /login. The replace prop prevents the protected URL from appearing in the browser's back history.
- If a user is found, it renders children (whatever component is wrapped inside ProtectedRoute).
- Save src/components/ProtectedRoute.jsx.
You won't see a visible change yet. The next substep uses this component to guard your routes.
Seeing an import error?
- Make sure the file is saved inside src/components/ (not src/pages/).
- Check that the import path '../context/AuthContext' has the correct relative path from the components folder.
Help me fix my ProtectedRoute import error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { Navigate } from 'react-router';
import { useAuth } from '../context/AuthContext';
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
export default ProtectedRoute;
Build the AppLayout component
Admin panels typically have a persistent sidebar for navigation. Ant Design provides a Layout component system with a Sider (sidebar), Header, and Content area designed for exactly this pattern.
- Create a new file called AppLayout.jsx inside src/components/ and paste the following code:
import { Layout, Menu } from 'antd';
import { DashboardOutlined, UnorderedListOutlined, LogoutOutlined } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router';
import { useAuth } from '../context/AuthContext';
const { Header, Sider, Content } = Layout;
function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Define sidebar navigation items
const menuItems = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: 'Dashboard',
},
{
key: '/tasks',
icon: <UnorderedListOutlined />,
label: 'Tasks',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
},
];
// Handle menu clicks for navigation and logout
const handleMenuClick = ({ key }) => {
if (key === 'logout') {
logout();
navigate('/login');
} else {
navigate(key);
}
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider>
<div style={{ color: 'white', padding: '16px', textAlign: 'center', fontWeight: 'bold' }}>
Task Manager
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<Layout>
<Header style={{ background: '#fff', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<span>Welcome, {user?.username} ({user?.role})</span>
</Header>
<Content style={{ margin: '16px' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
export default AppLayout;
What does this code do?
This component creates the main application shell that wraps all protected pages.
- The Sider renders a dark-themed sidebar with a title and a Menu containing Dashboard, Tasks, and Logout items.
- The selectedKeys prop uses location.pathname to highlight whichever page you're currently on.
- The handleMenuClick function either calls logout() and redirects to login, or navigates to the clicked route.
- The Outlet component is where React Router renders the active child route (Dashboard or Tasks page).
- Save src/components/AppLayout.jsx.
You won't see this layout yet. The next substep wires it into the router so it actually renders.
Seeing a missing module error for @ant-design/icons?
- The @ant-design/icons package is included automatically when you install antd. If you see an error, make sure you ran npm install antd --save in Step 1.
- Check that @ant-design/icons appears in your package.json dependencies. If it's missing, install it separately.
Help me fix my Ant Design icons import error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { Layout, Menu } from 'antd';
import { DashboardOutlined, UnorderedListOutlined, LogoutOutlined } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router';
import { useAuth } from '../context/AuthContext';
const { Header, Sider, Content } = Layout;
function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: 'Dashboard',
},
{
key: '/tasks',
icon: <UnorderedListOutlined />,
label: 'Tasks',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
},
];
const handleMenuClick = ({ key }) => {
if (key === 'logout') {
logout();
navigate('/login');
} else {
navigate(key);
}
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider>
<div style={{ color: 'white', padding: '16px', textAlign: 'center', fontWeight: 'bold' }}>
Task Manager
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<Layout>
<Header style={{ background: '#fff', padding: '0 16px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<span>Welcome, {user?.username} ({user?.role})</span>
</Header>
<Content style={{ margin: '16px' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
export default AppLayout;
Wire up React Router in App.jsx
With ProtectedRoute and AppLayout ready, you can now define your application's route structure. You'll set up a public route for the login page and nested protected routes for the Dashboard and Tasks pages.
First, create the page components that these routes will render. You already have Login from the previous step, so you need Dashboard and a placeholder for Tasks.
- Create a new file called Dashboard.jsx inside src/pages/ and paste the following code:
import { Card, Typography } from 'antd';
import { useAuth } from '../context/AuthContext';
const { Title, Paragraph } = Typography;
function Dashboard() {
const { user } = useAuth();
return (
<div>
<Title level={2}>Dashboard</Title>
<Card>
<Paragraph>Welcome to the Task Manager, {user?.username}!</Paragraph>
<Paragraph>Your role: {user?.role}</Paragraph>
<Paragraph>Use the sidebar to navigate to Tasks.</Paragraph>
</Card>
</div>
);
}
export default Dashboard;
What does this page do?
The Dashboard is a simple landing page that greets the user by name and shows their role. It uses Ant Design's Card and Typography components for a clean, consistent look. This confirms the auth context is working correctly once you log in.
- Save src/pages/Dashboard.jsx.
- Create another new file called Tasks.jsx inside src/pages/ and paste this placeholder:
function Tasks() {
return <div>Tasks page - coming soon</div>;
}
export default Tasks;
This placeholder prevents import errors. You'll build out the full Tasks page in the next step.
- Save src/pages/Tasks.jsx.
- Now open src/App.jsx and replace its entire contents with the following:
import { Routes, Route, Navigate } from 'react-router';
import Login from './pages/Login';
import Tasks from './pages/Tasks';
import Dashboard from './pages/Dashboard';
import AppLayout from './components/AppLayout';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
return (
<Routes>
{/* Public route - accessible without login */}
<Route path="/login" element={<Login />} />
{/* Protected routes - wrapped in ProtectedRoute and AppLayout */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/tasks" element={<Tasks />} />
</Route>
{/* Catch-all redirect to dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}
export default App;
What does this routing structure do?
- The /login route is public. Anyone can access the login page.
- The parent Route with no path wraps its children in ProtectedRoute and AppLayout. This means every child route (/dashboard, /tasks) must pass the auth check and renders inside the sidebar layout.
- The path="*" catch-all redirects any unrecognized URL to /dashboard. If someone visits the root / or a typo, they land on the dashboard.
React Router's nested route pattern means the Outlet in AppLayout renders whichever child route matches the current URL.
- Save src/App.jsx.
- Return to your browser at http://localhost:5173.
- You should be redirected to the login page since you're not authenticated.
- Log in with valid credentials from your Spring Boot backend.
- After logging in, you should see the sidebar layout with Dashboard and Tasks menu items, plus a welcome header showing your username and role.
Not seeing the sidebar after login?
- Check that your Spring Boot backend is running on port 8080. The login call goes through the Vite proxy to /api/auth/login.
- Open the browser console and look for errors. A 401 response means invalid credentials. A network error means the backend is not reachable.
- Make sure all files are saved. Check that src/App.jsx imports match the exact file paths: ./pages/Login, ./pages/Tasks, ./pages/Dashboard, ./components/AppLayout, ./components/ProtectedRoute.
Help me debug why my sidebar layout isn't showing after login.
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before moving on.
ⓧ I'd like to double check the full code
import { Routes, Route, Navigate } from 'react-router';
import Login from './pages/Login';
import Tasks from './pages/Tasks';
import Dashboard from './pages/Dashboard';
import AppLayout from './components/AppLayout';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/tasks" element={<Tasks />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}
export default App;
Your protected route system is locked down and the sidebar layout is live. Next up, you'll build the Tasks page with a full data table that fetches from your Spring Boot API.
Build the Task List with Data Fetching
Your app now has a working login flow and protected routes. Authenticated users land on the Dashboard and see the sidebar navigation.
The Tasks link in the sidebar points to /tasks, but nothing renders there yet. In this step, you will build the Tasks page that fetches data from your Spring Boot backend and displays it in a rich, interactive table.
In this step, get ready to:
- Create a Tasks page component that fetches task data from your backend API.
- Render tasks in a sortable Ant Design Table with action buttons.
- Add a Create Task modal that posts new tasks to your API.
Create the Tasks page with data fetching
Every page that displays backend data needs a way to request that data when it loads. The Tasks page uses your configured Axios instance to call the tasks endpoint, and React state to hold the response.
- Create a new file at src/pages/Tasks.jsx.
- Add the following imports, state declarations, and data-fetching logic:
import { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Popconfirm, message, Space, Typography } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../api/axios';
import { useAuth } from '../context/AuthContext';
const { Title } = Typography;
const { Option } = Select;
function Tasks() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingTask, setEditingTask] = useState(null);
const [form] = Form.useForm();
const { user } = useAuth();
const fetchTasks = async () => {
setLoading(true);
try {
const response = await api.get('/tasks');
setTasks(response.data);
} catch (error) {
message.error('Failed to fetch tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
What does this code do?
The component sets up five pieces of state to manage the page:
- tasks holds the array of task objects returned from your API.
- loading tracks whether a fetch is in progress, so the table can show a spinner.
- modalOpen and editingTask control the create/edit modal (used in the next substep).
- Form.useForm() gives Ant Design programmatic control over form fields.
The useEffect with an empty dependency array calls fetchTasks() exactly once when the component mounts. The Axios instance automatically attaches the JWT token to the request header.
Render tasks in an Ant Design Table
Ant Design's Table component takes a columns definition and a data source. Each column maps to a field in the task objects your API returns.
- Below the useEffect block you just wrote, add the handler functions and columns definition:
const handleEdit = (task) => {
setEditingTask(task);
form.setFieldsValue(task);
setModalOpen(true);
};
const handleDelete = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`);
message.success('Task deleted');
fetchTasks();
} catch (error) {
message.error('Failed to delete task');
}
};
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
sorter: (a, b) => a.title.localeCompare(b.title),
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
},
{
title: 'Assigned To',
dataIndex: 'assignedTo',
key: 'assignedTo',
},
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space>
<Button icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small">
Edit
</Button>
<Popconfirm title="Delete this task?" onConfirm={() => handleDelete(record.id)} okText="Yes" cancelText="No">
<Button icon={<DeleteOutlined />} danger size="small">
Delete
</Button>
</Popconfirm>
</Space>
),
},
];
What does this code do?
The columns array tells the Table which fields to display. The Title column includes a sorter function so users can click the header to sort alphabetically. The Description column uses ellipsis: true to truncate long text.
The Actions column uses a render function to output Edit and Delete buttons for each row. handleEdit pre-fills the modal form with the task's current values. handleDelete sends a DELETE request and refreshes the list.
Add the Create Task button and modal form
Users need a way to add new tasks. A button above the table opens a modal containing an Ant Design Form. On submission, the form data posts to your backend and the table refreshes.
- Below the columns array, add the create and submit handlers:
const handleCreate = () => {
setEditingTask(null);
form.resetFields();
setModalOpen(true);
};
const handleSubmit = async (values) => {
try {
if (editingTask) {
await api.put(`/tasks/${editingTask.id}`, values);
message.success('Task updated');
} else {
await api.post('/tasks', values);
message.success('Task created');
}
setModalOpen(false);
fetchTasks();
} catch (error) {
message.error(editingTask ? 'Failed to update task' : 'Failed to create task');
}
};
Why does handleSubmit check editingTask?
The same modal serves double duty for creating and editing tasks. When editingTask is null, the form posts a new task. When it holds an existing task object, the form sends a PUT request to update that task instead.
After either operation succeeds, fetchTasks() re-fetches the full list so the table stays in sync with the backend.
- Now add the component's return statement with the Table, Create button, and Modal. Paste this below handleSubmit:
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={2} style={{ margin: 0 }}>Tasks</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Create Task
</Button>
</div>
<Table columns={columns} dataSource={tasks} loading={loading} rowKey="id" />
<Modal
title={editingTask ? 'Edit Task' : 'Create Task'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Please enter a title' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true, message: 'Please select a status' }]}>
<Select placeholder="Select status">
<Option value="TODO">TODO</Option>
<Option value="IN_PROGRESS">In Progress</Option>
<Option value="DONE">Done</Option>
</Select>
</Form.Item>
<Form.Item name="assignedTo" label="Assigned To">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default Tasks;
What does this JSX render?
- A header row with the page title and a Create Task button aligned to the right.
- The Table component receives your columns, the tasks state as its data source, and rowKey="id" so React can track each row.
- The Modal wraps a vertical-layout Form with fields for title, description, status, and assigned user. Clicking the modal's OK button triggers form.submit(), which fires handleSubmit.
- Save src/pages/Tasks.jsx.
- Make sure your Spring Boot backend is running on port 8080.
- In your browser, log in and click Tasks in the sidebar. You should see a table with any tasks from your backend, plus a blue Create Task button in the top-right corner.
- Click Create Task, fill in the form fields, and click OK. The new task should appear in the table.
Table is empty or showing an error?
- Confirm your Spring Boot backend is running at http://localhost:8080. The Vite proxy forwards /api requests there.
- Open the browser DevTools Network tab and check the response from /api/tasks. A 401 means your token expired or is missing. Log out and log back in.
- If the table renders but the Create modal does nothing, check the browser console for form validation errors.
Help me debug my Tasks page data fetching.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/pages/Tasks.jsx and your table is displaying data.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Popconfirm, message, Space, Typography } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../api/axios';
import { useAuth } from '../context/AuthContext';
const { Title } = Typography;
const { Option } = Select;
function Tasks() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingTask, setEditingTask] = useState(null);
const [form] = Form.useForm();
const { user } = useAuth();
const fetchTasks = async () => {
setLoading(true);
try {
const response = await api.get('/tasks');
setTasks(response.data);
} catch (error) {
message.error('Failed to fetch tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
const handleCreate = () => {
setEditingTask(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (task) => {
setEditingTask(task);
form.setFieldsValue(task);
setModalOpen(true);
};
const handleDelete = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`);
message.success('Task deleted');
fetchTasks();
} catch (error) {
message.error('Failed to delete task');
}
};
const handleSubmit = async (values) => {
try {
if (editingTask) {
await api.put(`/tasks/${editingTask.id}`, values);
message.success('Task updated');
} else {
await api.post('/tasks', values);
message.success('Task created');
}
setModalOpen(false);
fetchTasks();
} catch (error) {
message.error(editingTask ? 'Failed to update task' : 'Failed to create task');
}
};
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
sorter: (a, b) => a.title.localeCompare(b.title),
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
},
{
title: 'Assigned To',
dataIndex: 'assignedTo',
key: 'assignedTo',
},
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space>
<Button icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small">
Edit
</Button>
<Popconfirm title="Delete this task?" onConfirm={() => handleDelete(record.id)} okText="Yes" cancelText="No">
<Button icon={<DeleteOutlined />} danger size="small">
Delete
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={2} style={{ margin: 0 }}>Tasks</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Create Task
</Button>
</div>
<Table columns={columns} dataSource={tasks} loading={loading} rowKey="id" />
<Modal
title={editingTask ? 'Edit Task' : 'Create Task'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Please enter a title' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true, message: 'Please select a status' }]}>
<Select placeholder="Select status">
<Option value="TODO">TODO</Option>
<Option value="IN_PROGRESS">In Progress</Option>
<Option value="DONE">Done</Option>
</Select>
</Form.Item>
<Form.Item name="assignedTo" label="Assigned To">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default Tasks;
Your task list is live and connected to your Spring Boot backend. Next up, you will wire up the edit and delete flows so users can modify existing tasks directly from the table.
Add Edit and Delete Operations
Your Tasks page can load and display tasks from your backend, and you can create new ones through the modal form.
A real task manager needs the full suite of CRUD operations. In this step, you'll walk through how the edit and delete handlers work in your existing code, then test them end-to-end against your Spring Boot backend.
In this step, get ready to:
- Test the edit flow by modifying an existing task through the pre-filled modal.
- Test the delete flow with confirmation before removing a task.
- Verify that success and error notifications appear for all CRUD operations.
Understand the edit flow
When a user clicks the Edit button on a table row, the app needs to open the same modal used for creating tasks, but pre-filled with that task's current data.
- In VS Code, open src/pages/Tasks.jsx and find the handleEdit function:
const handleEdit = (task) => {
setEditingTask(task);
form.setFieldsValue(task);
setModalOpen(true);
};
What does this code do?
- setEditingTask(task) stores the task object being edited. This lets handleSubmit know whether to send a PUT (update) or POST (create) request.
- form.setFieldsValue(task) pre-fills every form field with the task's current values, so the learner sees what they're editing.
- setModalOpen(true) opens the same modal used for creation, reusing the entire form.
- Now find the handleSubmit function and look at the conditional logic:
const handleSubmit = async (values) => {
try {
if (editingTask) {
await api.put(`/tasks/${editingTask.id}`, values);
message.success('Task updated');
} else {
await api.post('/tasks', values);
message.success('Task created');
}
setModalOpen(false);
fetchTasks();
} catch (error) {
message.error(editingTask ? 'Failed to update task' : 'Failed to create task');
}
};
How does one function handle both create and edit?
The key is the editingTask state variable. When it's null (set by handleCreate), the function sends a POST to create. When it holds a task object (set by handleEdit), it sends a PUT to update that specific task by ID.
After either operation succeeds, fetchTasks() re-fetches the full list from the backend so the table always reflects the latest state.
- Make sure your Spring Boot backend is running on port 8080.
- In your browser, navigate to http://localhost:5173/tasks and log in if prompted.
- Click the Edit button on any task row in the table.
- Verify that the modal opens with the task's existing title, description, status, and assigned user already filled in.
- Change the task's status to a different value (e.g. from TODO to IN_PROGRESS).
- Click OK to submit the form.
The modal closes, the table refreshes, and you should see the updated status reflected in the task row.
Modal opens but fields are empty?
- Check that your handleEdit function calls form.setFieldsValue(task) before setModalOpen(true). The form fields must be set before the modal renders.
- Confirm the task data from your backend includes the correct field names (title, description, status, assignedTo) matching your Form.Item name props.
Why isn't my edit modal pre-filling with the task data?
Test the delete flow with confirmation
Deleting data is destructive. A good UI never deletes something on a single click. Your code uses Ant Design's Popconfirm component to ask the user to confirm before proceeding.
- Find the Popconfirm usage inside the columns array's Actions render function:
<Popconfirm title="Delete this task?" onConfirm={() => handleDelete(record.id)} okText="Yes" cancelText="No">
<Button icon={<DeleteOutlined />} danger size="small">
Delete
</Button>
</Popconfirm>
What does Popconfirm do?
Popconfirm wraps the Delete button and intercepts the click. Instead of immediately running handleDelete, it shows a small popup asking "Delete this task?" with Yes/No buttons.
Only when the user clicks Yes does onConfirm fire, calling handleDelete(record.id). This prevents accidental data loss.
- Now find the handleDelete function:
const handleDelete = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`);
message.success('Task deleted');
fetchTasks();
} catch (error) {
message.error('Failed to delete task');
}
};
What does this code do?
- api.delete(`/tasks/${taskId}`) sends a DELETE request to your Spring Boot backend with the task's ID.
- On success, it shows a green notification and refreshes the task list. On failure, it shows a red error notification.
- In your browser, click the red Delete button on any task row.
- A small popup appears asking "Delete this task?" with Yes and No buttons.
- Click No first to confirm the popup closes without deleting anything.
- Click Delete again, then click Yes to confirm the deletion.
The task disappears from the table and a success notification appears at the top of the page.
Task not disappearing from the table?
- Open DevTools (F12) and check the Network tab. Confirm the DELETE request returns a 200 or 204 status code.
- If you see a 403 error, your logged-in user may not have permission to delete tasks. Try logging in with an ADMIN account.
- Verify your Spring Boot backend is still running on port 8080.
Help me debug why my delete request isn't working against my Spring Boot backend.
Verify user feedback notifications
Every CRUD operation in your app uses Ant Design's message component to give the user immediate feedback on what happened.
- Look at how message is used throughout Tasks.jsx:
// Success notifications
message.success('Task created');
message.success('Task updated');
message.success('Task deleted');
// Error notifications
message.error('Failed to fetch tasks');
message.error('Failed to delete task');
message.error(editingTask ? 'Failed to update task' : 'Failed to create task');
Why use message instead of alert?
Ant Design's message component shows a non-blocking notification that appears briefly at the top of the screen and fades away automatically. Unlike browser alert() dialogs, these don't interrupt the user's workflow.
message.success() shows a green checkmark notification. message.error() shows a red error notification. Both disappear after a few seconds without any user action.
- Test the success flow: create a new task by clicking Create Task, filling out the form, and clicking OK.
- Watch for the green "Task created" notification at the top of the page.
- Test the error flow: stop your Spring Boot backend temporarily, then try to create another task.
- Watch for the red error notification that appears when the API call fails.
- Restart your Spring Boot backend after testing the error flow.
You should see green success notifications for create, edit, and delete operations. Red error notifications appear whenever the backend is unreachable or returns an error.
Not seeing any notifications?
- Check that message is imported from antd at the top of your file. It should be part of the destructured import: import { Table, Button, Modal, Form, Input, Select, Popconfirm, message, Space, Typography } from 'antd'.
- Make sure the notification area isn't hidden behind another element. Notifications appear at the very top center of the viewport.
Why aren't Ant Design message notifications showing in my React app?
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/pages/Tasks.jsx and confirmed all three CRUD operations work against your backend.
ⓧ I'd like to double check the full code
Here's the complete src/pages/Tasks.jsx file with all CRUD operations:
import { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Popconfirm, message, Space, Typography } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../api/axios';
import { useAuth } from '../context/AuthContext';
const { Title } = Typography;
const { Option } = Select;
function Tasks() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingTask, setEditingTask] = useState(null);
const [form] = Form.useForm();
const { user } = useAuth();
const fetchTasks = async () => {
setLoading(true);
try {
const response = await api.get('/tasks');
setTasks(response.data);
} catch (error) {
message.error('Failed to fetch tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
const handleCreate = () => {
setEditingTask(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (task) => {
setEditingTask(task);
form.setFieldsValue(task);
setModalOpen(true);
};
const handleDelete = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`);
message.success('Task deleted');
fetchTasks();
} catch (error) {
message.error('Failed to delete task');
}
};
const handleSubmit = async (values) => {
try {
if (editingTask) {
await api.put(`/tasks/${editingTask.id}`, values);
message.success('Task updated');
} else {
await api.post('/tasks', values);
message.success('Task created');
}
setModalOpen(false);
fetchTasks();
} catch (error) {
message.error(editingTask ? 'Failed to update task' : 'Failed to create task');
}
};
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
sorter: (a, b) => a.title.localeCompare(b.title),
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
},
{
title: 'Assigned To',
dataIndex: 'assignedTo',
key: 'assignedTo',
},
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space>
<Button icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small">
Edit
</Button>
<Popconfirm title="Delete this task?" onConfirm={() => handleDelete(record.id)} okText="Yes" cancelText="No">
<Button icon={<DeleteOutlined />} danger size="small">
Delete
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={2} style={{ margin: 0 }}>Tasks</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Create Task
</Button>
</div>
<Table columns={columns} dataSource={tasks} loading={loading} rowKey="id" />
<Modal
title={editingTask ? 'Edit Task' : 'Create Task'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Please enter a title' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true, message: 'Please select a status' }]}>
<Select placeholder="Select status">
<Option value="TODO">TODO</Option>
<Option value="IN_PROGRESS">In Progress</Option>
<Option value="DONE">Done</Option>
</Select>
</Form.Item>
<Form.Item name="assignedTo" label="Assigned To">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default Tasks;
Your task manager now supports full CRUD operations. You can create, read, update, and delete tasks with proper confirmation dialogs and user feedback. Next, you'll take this further by gating UI actions based on user roles.
Secret mission
Role-Based UI Gating
Your app authenticates users and protects routes, but every logged-in user sees the same buttons. In a real application, a regular user should not see admin controls. Can you conditionally render UI elements based on the user's role?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The React project folder containing your source code and node_modules.
- The Vite dev server process running in your terminal.
- Browser localStorage entries (token and user).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the app with more features.
- Your Vite dev server stays running and hot-reloads any code changes.
- Your Spring Boot backend continues serving API requests on port 8080.
- Your browser localStorage keeps your JWT token active until it expires.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk so you can resume later.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- Stop your Spring Boot backend if you do not need it running for other work.
- To resume later, navigate back into your project folder and run npm run dev to start the dev server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your React project files.
- Stop the Vite dev server by pressing Ctrl+C in its terminal.
- Delete the project folder by running this command from your Desktop (macOS/Linux):
cd ~/Desktop
rm -rf my-app
On Windows, run this instead:
cd ~\Desktop
rmdir /s /q my-app
- Clear your browser localStorage by opening DevTools (F12), navigating to the Application tab, selecting Local Storage, and clicking Clear All.
Nice Work!
Nice Work!
Nice work! You just built a complete React frontend that authenticates users, protects routes, and performs full CRUD operations against your Spring Boot REST APIs.
You've learned how to:
- Build a React single-page application with JWT authentication, including token storage in localStorage and automatic injection via Axios interceptors.
- Create protected routes using React Router and the React Context API that redirect unauthenticated users to a login page.
- Build a complete CRUD interface for task management using Ant Design Table, Modal, and Form components connected to your REST APIs.
- Secret Mission: Implemented role-based UI gating that conditionally shows or hides UI elements based on ADMIN, MANAGER, and USER roles.
Ready to quiz yourself?