Build RoleGate Assignments
Build a React portal with Supabase roles, authentication, and RLS.
Introduction
30 Second Summary
A dashboard can hide its most sensitive controls from students. Dependable permissions must still hold when someone sends the same request behind the screen.
In this project, you will build RoleGate Assignments, an authenticated React portal with separate student and facilitator dashboards. Supabase will protect assignment publishing with PostgreSQL Row Level Security.
What You'll Build
You will demo one shared assignment from two accounts: the facilitator publishes it, while the student sees it without gaining permission to create another.
By the end of this project, you'll have:
- An email and password sign-in flow that shows each user's stored role on a role-specific dashboard.
- A facilitator publishing workflow where each new assignment appears as a shared card with a calculated Open or Overdue badge.
- A student permission probe that first exposes the broad policy, then proves the secured database blocks the same write.
- Secret Mission: Add All, Open, and Overdue filters while preserving the database permissions.
Do I need any experience before starting?
Comfort with basic JavaScript and React will help. The guide walks you through creating your Supabase account and project.
Before We Start
Before any setup, this step fixes the scope of RoleGate Assignments: a local React portal with authentication, role-based assignment viewing, facilitator-only creation, and a student permission probe. Its focused scope uses PostgreSQL Row Level Security to enforce assignment-creation permissions without adding submissions, grading, feedback, or deployment.
Set Up the React Project
RoleGate needs a dependable local foundation before its authentication or authorization code can run. A mismatched runtime can stop the app before you reach the database lesson.
Node.js provides the runtime for your pinned React dependencies. Vite then serves the starter inside Visual Studio Code.
In this step, get ready to:
- Confirm that Node.js meets the minimum version.
- Create the local RoleGate project files.
- Start the development server.
Check your Node.js version
This project needs Node.js v22.12.0 or newer because Vite 8.3.0 depends on a recent runtime. The version check protects you from package installation failures.
- Press the Windows key to open the system search field.
- Type PowerShell.
- Press Enter to open PowerShell.
- Check the installed Node.js version by running this command:
node --version
What Does This Command Check?
The node --version command prints the Node.js version available to PowerShell. The result determines whether the current installation can run this project.
✔️ I see v22.12.0 or higher
Your runtime is ready. Node.js can install the pinned packages used by RoleGate.
ⓧ I see an older version
The installed runtime is older than this project supports. Updating it now keeps the dependency installation predictable.
- Open the official Node.js download page in your browser.
- Download Node.js v24.21.0 LTS for Windows.
- Run the downloaded installer.
- Complete the installer prompts using the default options.
- Close PowerShell after the installation finishes.
- Reopen PowerShell through the system search field.
- Check the updated version by running this command:
node --version
Why Reopen PowerShell?
An open terminal can keep the previous environment settings. Reopening PowerShell loads the path for the updated Node.js installation.
You should now see v24.21.0 or another supported version.
ⓧ Command not found
PowerShell cannot find a Node.js installation yet. Installing the current LTS release adds the runtime this project needs.
- Open the official Node.js download page in your browser.
- Download Node.js v24.21.0 LTS for Windows.
- Run the downloaded installer.
- Complete the installer prompts using the default options.
- Close PowerShell after the installation finishes.
- Reopen PowerShell through the system search field.
- Confirm that PowerShell can find Node.js by running this command:
node --version
What Confirms the Installation?
A version beginning with v24 confirms that PowerShell can access the installed runtime. You can now create the project.
Still Unable to Check Node.js?
- Close every PowerShell window before opening a fresh session.
- Confirm that the Node.js installer completed before rerunning the check.
Help me troubleshoot my Node.js installation.
Create the project files
A Vite app starts with a small set of entry files. The package manifest pins every dependency so the same project can be reproduced later.
- Move to your Desktop by running this command:
cd ~/Desktop
What Does This Command Do?
The cd command changes PowerShell's current location. Using the Desktop gives the new project a predictable place on your computer.
The final command in the next block opens the project folder in a new Visual Studio Code window.
- Create the rolegate-assignments folder by running these commands:
mkdir rolegate-assignments
cd rolegate-assignments
code .
What Do These Commands Do?
- The first command creates the rolegate-assignments folder on your Desktop.
- The second command moves PowerShell into that folder.
- The final command opens the current folder in Visual Studio Code.
Your project now has a dedicated home. You should see rolegate-assignments at the top of the Explorer.
Did Visual Studio Code Stay Closed?
- Confirm that Visual Studio Code is installed on Windows.
- Restart PowerShell so it can load the Visual Studio Code command.
- Rerun the three project commands from your Desktop.
Help me open this folder from PowerShell.
The next command asks Visual Studio Code to create seven empty files. It also creates the src folder from the nested paths.
- Create the project file structure by running this command in PowerShell:
code package.json .gitignore vite.config.js index.html src/main.jsx src/App.jsx src/App.css
How Are the Files Created?
Visual Studio Code accepts several file paths in one command. Missing files open as new editor tabs inside the current project.
- Check the Explorer for the new file structure.
You should see four files beside a src folder. The src folder should contain three files.
Are Any Files Missing?
Confirm that PowerShell still shows the rolegate-assignments folder in its prompt. Rerun the file command from that folder if any tabs are missing.
Help me create the missing project files.
- Add the pinned project metadata to package.json by pasting this content:
{"name":"rolegate-assignments","private":true,"version":"1.0.0","type":"module","engines":{"node":">=22.12.0"},"scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"@supabase/supabase-js":"2.116.0","react":"19.3.0","react-dom":"19.3.0"},"devDependencies":{"@vitejs/plugin-react":"6.1.1","vite":"8.3.0"}}
What Does This File Control?
- The engines value records the minimum supported Node.js version.
- The dependency sections pin React, Vite, the Vite React plugin, plus the Supabase JavaScript client.
- The scripts section defines the development command used later.
- Press Ctrl+S to save package.json.
- Confirm that the saved file contains one JSON object beginning with the project name.
Does the Package File Show a Problem?
Make sure the file contains the exact JSON block above. A missing quote or comma prevents npm from reading the manifest.
Help me find the JSON formatting issue.
- Add the generated-file exclusions to .gitignore by pasting this content:
node_modules
dist
*.local
What Does This File Exclude?
These rules identify generated dependencies, build output, plus local environment files. They keep machine-specific content separate from the source files.
- Press Ctrl+S to save .gitignore.
- Confirm that the saved file contains three separate lines.
- Configure the React plugin in vite.config.js by pasting this content:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
What Does This Configuration Do?
The configuration loads Vite's React plugin. That plugin lets the development server process the JSX used by the app.
- Press Ctrl+S to save vite.config.js.
- Confirm that the file ends with the closing configuration brace.
Does the Configuration Show an Import Problem?
The packages are installed in the next substep. For now, confirm that both import names match the code block exactly.
Help me compare my Vite configuration.
- Create the browser entry document in index.html by pasting this content:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RoleGate Assignments</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
How Does the Browser Start the App?
- The root element gives React a place to render the interface.
- The module script loads src/main.jsx as the JavaScript entry point.
- The page title identifies the browser tab as RoleGate Assignments.
- Press Ctrl+S to save index.html.
- Confirm that the document contains a single element with the root identifier.
- Add the temporary interface to src/App.jsx by pasting this content:
export default function App() {
return <h1>RoleGate is ready</h1>
}
What Does This Component Render?
The App component returns one heading. That heading gives you a clear visual signal when the React entry point works.
- Press Ctrl+S to save src/App.jsx.
- Confirm that the saved component returns the RoleGate is ready heading.
- Add the page-level colors to src/App.css by pasting this first style block:
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #172033;
background: #eef3f8;
}
What Does the Root Style Control?
The root selector sets the font stack for the whole app. It also establishes the starter text and background colors.
- Press Ctrl+S to save src/App.css.
- Confirm that the file contains the root selector with three properties.
- Add the body sizing rules below the root block by pasting this content:
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
What Does the Body Style Control?
The body fills the browser height while preserving a usable minimum width. Removing its default margin lets later layouts use the full page.
- Press Ctrl+S to save the completed src/App.css file.
- Confirm that the body selector appears directly below the root selector.
- Connect React to the browser in src/main.jsx by pasting this content:
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './App.css'
createRoot(document.getElementById('root')).render(<App />)
How Does React Reach the Page?
- The imports load React's browser renderer, the App component, plus the starter stylesheet.
- The final line finds the root element from index.html.
- The renderer places the App component inside that element.
- Press Ctrl+S to save src/main.jsx.
- Confirm that the final line renders App into the root element.
Do the Starter Files Look Different?
- Confirm that every filename uses the same capitalization shown above.
- Check that App.jsx plus App.css are inside the src folder.
- Check that index.html remains beside package.json.
Help me check my RoleGate file structure.
✔️ Awesome, I've got everything!
Your seven starter files are saved. The React entry path now runs from index.html through src/main.jsx to src/App.jsx.
ⓧ I'd like to double check the full code
- Compare each saved file with the complete starter versions below.
node_modules
dist
*.local
Purpose of This File
This file keeps generated output and local configuration outside the project source.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RoleGate Assignments</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Purpose of This File
This document provides the browser page and loads the React entry module.
{"name":"rolegate-assignments","private":true,"version":"1.0.0","type":"module","engines":{"node":">=22.12.0"},"scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"@supabase/supabase-js":"2.116.0","react":"19.3.0","react-dom":"19.3.0"},"devDependencies":{"@vitejs/plugin-react":"6.1.1","vite":"8.3.0"}}
Purpose of This File
This manifest records the project scripts, runtime requirement, plus exact package versions.
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #172033;
background: #eef3f8;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
Purpose of This File
This stylesheet sets the starter typography, colors, plus page dimensions.
export default function App() {
return <h1>RoleGate is ready</h1>
}
Purpose of This File
This component renders the temporary heading used to verify the setup.
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './App.css'
createRoot(document.getElementById('root')).render(<App />)
Purpose of This File
This entry module renders the App component into the browser document.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
Purpose of This File
This configuration enables React support in Vite.
Install dependencies and start Vite
The files describe which packages the project needs. npm now downloads those exact versions before Vite runs the starter app.
- Switch back to the PowerShell window from earlier.
- Install the pinned project dependencies by running this command:
npm install
What Does This Command Install?
npm reads package.json before downloading the listed dependencies. It keeps their resolved versions together for repeatable installations.
The package installation is complete when PowerShell returns to the prompt. Your React and Vite dependencies are now available locally.
Did the Installation Stop?
- Confirm that PowerShell is inside the rolegate-assignments folder.
- Check that package.json matches the complete version above.
- Confirm that the Node.js version check still reports a supported version.
Help me troubleshoot the dependency installation.
Before you start the server, predict where the temporary RoleGate heading will appear.
- Start the Vite development server by running this command:
npm run dev
What Does the Development Command Do?
The command launches Vite using the dev script from package.json. The server keeps this PowerShell window busy while the app is running.
- Open http://localhost:5173 in your browser.
You should see RoleGate is ready on a pale background. That visible heading proves the browser loaded your React component through Vite.
Do You See a Blank Page?
- Keep the PowerShell window running while the browser is open.
- Confirm that the browser address uses port 5173.
- Compare index.html plus the three src files with the complete versions above.
Help me diagnose the blank Vite page.
Your pinned React foundation is live. Next, you'll replace the starter heading with the first visible assignment dashboard.
Build the Assignment Dashboard
Your React starter is running in Vite. That working page gives you a reliable place to shape the portal.
A visible mock dashboard makes the destination concrete before authentication adds complexity. This step builds that preview from two hardcoded assignments.
In this step, get ready to:
- Replace the temporary starter with a mock assignment dashboard.
- Calculate each status badge from its assignment due date.
- Style the cards for wide screens and narrow screens.
Replace the starter with mock assignments
Each mock assignment keeps its due date in due_at. The dashboard derives its display values from that source.
- Select src/App.jsx in the VS Code Explorer sidebar.
- Select the existing temporary component.
- Replace the selected code with this dashboard:
// Keep the mock records close to the interface while the database is not connected.
const assignments = [
{ id: 'mock-open', title: 'Plan the project launch', instructions: 'Write a short checklist for the launch meeting.', due_at: '2099-01-15T16:00:00Z' },
{ id: 'mock-overdue', title: 'Review access rules', instructions: 'Check which actions belong to each portal role.', due_at: '2020-01-15T16:00:00Z' },
]
// Derive the badge from the stored due date whenever the dashboard renders.
function getStatus(dueAt) {
return new Date(dueAt).getTime() < Date.now() ? 'Overdue' : 'Open'
}
function formatDate(value) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
export default function App() {
// Render the same card structure for every assignment record.
return (
<main className="app-shell">
<header className="topbar"><div><p className="eyebrow">Shared workspace</p><h1>RoleGate Assignments</h1></div><span className="role-pill student">student preview</span></header>
<section className="dashboard-grid">
<div><div className="section-heading"><div><p className="eyebrow">Assignment dashboard</p><h2>Assignments</h2></div><span>{assignments.length} total</span></div>
<div className="assignment-grid">{assignments.map((item) => { const status = getStatus(item.due_at); return <article className="assignment-card" key={item.id}><div className="card-heading"><h3>{item.title}</h3><span className={`status ${status.toLowerCase()}`}>{status}</span></div><p>{item.instructions}</p><p className="due-date">Due {formatDate(item.due_at)}</p></article> })}</div>
</div>
<aside><section className="panel"><p className="eyebrow">Role preview</p><h2>Student dashboard</h2><p className="muted">Students can read every published assignment from this shared workspace.</p></section></aside>
</section>
</main>
)
}
What does this code do?
- The assignments array supplies two temporary records while the database is not connected.
- The getStatus() helper compares each due date with the current timestamp.
- The formatDate() helper turns each stored timestamp into a readable date.
- The map() call renders one card for each assignment record.
- Save src/App.jsx.
- Switch back to the browser tab from the running development server.
You should see two assignment records with plain browser styling. That first visible dashboard proves the mock data and derived values are rendering.
Seeing a blank page?
- Check that both assignment objects have closing braces.
- Confirm that getStatus uses the same capitalization in its definition and card rendering.
- Make sure the final closing parenthesis belongs to the component return statement.
Help me find the syntax problem in my dashboard component.
✔️ Awesome, I've got everything!
Great. Your mock records now render through one reusable assignment card.
ⓧ I'd like to double check the full code
Compare your complete src/App.jsx file with this version.
// Keep the mock records close to the interface while the database is not connected.
const assignments = [
{ id: 'mock-open', title: 'Plan the project launch', instructions: 'Write a short checklist for the launch meeting.', due_at: '2099-01-15T16:00:00Z' },
{ id: 'mock-overdue', title: 'Review access rules', instructions: 'Check which actions belong to each portal role.', due_at: '2020-01-15T16:00:00Z' },
]
// Derive the badge from the stored due date whenever the dashboard renders.
function getStatus(dueAt) {
return new Date(dueAt).getTime() < Date.now() ? 'Overdue' : 'Open'
}
function formatDate(value) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
export default function App() {
// Render the same card structure for every assignment record.
return (
<main className="app-shell">
<header className="topbar"><div><p className="eyebrow">Shared workspace</p><h1>RoleGate Assignments</h1></div><span className="role-pill student">student preview</span></header>
<section className="dashboard-grid">
<div><div className="section-heading"><div><p className="eyebrow">Assignment dashboard</p><h2>Assignments</h2></div><span>{assignments.length} total</span></div>
<div className="assignment-grid">{assignments.map((item) => { const status = getStatus(item.due_at); return <article className="assignment-card" key={item.id}><div className="card-heading"><h3>{item.title}</h3><span className={`status ${status.toLowerCase()}`}>{status}</span></div><p>{item.instructions}</p><p className="due-date">Due {formatDate(item.due_at)}</p></article> })}</div>
</div>
<aside><section className="panel"><p className="eyebrow">Role preview</p><h2>Student dashboard</h2><p className="muted">Students can read every published assignment from this shared workspace.</p></section></aside>
</section>
</main>
)
}
Style the dashboard
The CSS classes separate the dashboard structure from its visual design. The first group establishes the shared page foundation.
- Select src/App.css in the VS Code Explorer sidebar.
- Select the existing starter styles.
- Replace the selected styles with this foundation:
/* Establish the global page foundation. */
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #eef3f8; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; }
/* Keep shared controls consistent. */
button, input, textarea { font: inherit; }
button { border: 0; border-radius: 10px; padding: 0.75rem 1rem; color: white; background: #3157d5; cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.65; }
h1, h2, h3, p { margin-top: 0; }
/* Prepare reusable form styles for later authenticated views. */
.stack { display: grid; gap: 1rem; }
label { display: grid; gap: 0.4rem; font-weight: 700; }
input, textarea { width: 100%; border: 1px solid #c8d3df; border-radius: 10px; padding: 0.75rem; color: #172033; background: #fbfdff; }
textarea { min-height: 110px; resize: vertical; }
.link-button { width: 100%; margin-top: 0.75rem; color: #3157d5; background: transparent; }
.secondary { color: #3157d5; background: #e8edff; }
What do these styles control?
- The :root rule sets the page typeface and color palette.
- The universal selector keeps element dimensions predictable.
- The shared control rules prepare buttons and form fields for the role workflows added later.
- Save src/App.css.
- Switch back to the dashboard in your browser.
You should see a pale page background with darker typography. The dashboard content now uses consistent spacing calculations.
Still seeing the starter styles?
- Confirm that you edited src/App.css inside the rolegate-assignments folder.
- Check that src/main.jsx still imports ./App.css.
Help me trace why my App stylesheet is not loading.
The next style group shapes the page shell and reusable card surfaces. It also aligns the dashboard headings.
- Move to the end of src/App.css.
- Append these shell and card styles:
/* Shape the reusable page shells and card surfaces. */
.centered, .auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.auth-card, .panel, .assignment-card { border: 1px solid #d9e2ec; border-radius: 18px; background: white; box-shadow: 0 12px 35px rgba(29, 45, 75, 0.08); }
.auth-card { width: min(100%, 430px); padding: 2rem; }
.app-shell { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0 4rem; }
/* Align related dashboard content. */
.topbar, .section-heading, .card-heading, .account-actions { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.topbar { margin-bottom: 1.5rem; }
.eyebrow { margin-bottom: 0.35rem; color: #3157d5; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.muted, .due-date, .section-heading span { color: #627085; }
.notice { margin: 1rem 0; border-left: 4px solid #3157d5; border-radius: 8px; padding: 0.85rem 1rem; background: #e8edff; }
How does the shell take shape?
- The .app-shell rule limits the content width on large screens.
- The shared card rule gives panels and assignments the same border treatment.
- The flex rules place headings beside their supporting labels.
- Save src/App.css.
- Return to the dashboard in your browser.
You should see white assignment cards with rounded corners. The page header now stretches across a centered content area.
Cards still look like plain text?
- Check that the selector is spelled .assignment-card.
- Confirm that each assignment uses className="assignment-card" in src/App.jsx.
Help me compare my card class names across JSX and CSS.
The final group creates the two-column dashboard and colors each derived status. A media query collapses the layout when the browser becomes narrow.
- Move to the end of src/App.css.
- Append these layout and status styles:
/* Build the dashboard grid and assignment spacing. */
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 1.5rem; align-items: start; }
.assignment-grid { display: grid; gap: 1rem; }
.assignment-card, .panel { padding: 1.25rem; }
.assignment-card p:last-child { margin-bottom: 0; }
.card-heading { align-items: flex-start; }
/* Give roles and due-date states distinct visual signals. */
.status, .role-pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.35rem 0.65rem; font-size: 0.75rem; font-weight: 800; text-transform: capitalize; }
.status.open, .role-pill.student { color: #126749; background: #dff7ed; }
.status.overdue { color: #9c2f2f; background: #ffe1e1; }
.role-pill.facilitator { color: #5b3aa5; background: #eee5ff; }
.empty-state { border: 1px dashed #aab8c7; border-radius: 14px; padding: 2rem; color: #627085; text-align: center; }
/* Stack dashboard regions when horizontal space becomes limited. */
@media (max-width: 780px) {
.dashboard-grid { grid-template-columns: 1fr; }
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
}
How does the responsive layout work?
- The .dashboard-grid rule gives assignments more room than the role preview on wide screens.
- The status classes connect the lowercase class name to the visible badge color.
- The media query changes the grid to one column below 780px.
- Save src/App.css.
- Switch back to the browser tab from earlier.
You should see a two-column dashboard with green Open styling and red Overdue styling. The role preview sits in its own panel.
Badges missing their colors?
- Confirm that getStatus() returns Open or Overdue.
- Check that status.toLowerCase() remains inside the badge class name.
- Compare .status.open with .status.overdue in the stylesheet.
Help me trace why my status badge class is not matching its CSS selector.
✔️ Awesome, I've got everything!
Your complete stylesheet now covers the dashboard layout and its narrow-screen behavior.
ⓧ I'd like to double check the full code
Compare your complete src/App.css file with this version.
/* Establish the global page foundation. */
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #eef3f8; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; }
/* Keep shared controls consistent. */
button, input, textarea { font: inherit; }
button { border: 0; border-radius: 10px; padding: 0.75rem 1rem; color: white; background: #3157d5; cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.65; }
h1, h2, h3, p { margin-top: 0; }
/* Prepare reusable form styles for later authenticated views. */
.stack { display: grid; gap: 1rem; }
label { display: grid; gap: 0.4rem; font-weight: 700; }
input, textarea { width: 100%; border: 1px solid #c8d3df; border-radius: 10px; padding: 0.75rem; color: #172033; background: #fbfdff; }
textarea { min-height: 110px; resize: vertical; }
.link-button { width: 100%; margin-top: 0.75rem; color: #3157d5; background: transparent; }
.secondary { color: #3157d5; background: #e8edff; }
/* Shape the reusable page shells and card surfaces. */
.centered, .auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.auth-card, .panel, .assignment-card { border: 1px solid #d9e2ec; border-radius: 18px; background: white; box-shadow: 0 12px 35px rgba(29, 45, 75, 0.08); }
.auth-card { width: min(100%, 430px); padding: 2rem; }
.app-shell { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0 4rem; }
/* Align related dashboard content. */
.topbar, .section-heading, .card-heading, .account-actions { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.topbar { margin-bottom: 1.5rem; }
.eyebrow { margin-bottom: 0.35rem; color: #3157d5; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.muted, .due-date, .section-heading span { color: #627085; }
.notice { margin: 1rem 0; border-left: 4px solid #3157d5; border-radius: 8px; padding: 0.85rem 1rem; background: #e8edff; }
/* Build the dashboard grid and assignment spacing. */
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 1.5rem; align-items: start; }
.assignment-grid { display: grid; gap: 1rem; }
.assignment-card, .panel { padding: 1.25rem; }
.assignment-card p:last-child { margin-bottom: 0; }
.card-heading { align-items: flex-start; }
/* Give roles and due-date states distinct visual signals. */
.status, .role-pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.35rem 0.65rem; font-size: 0.75rem; font-weight: 800; text-transform: capitalize; }
.status.open, .role-pill.student { color: #126749; background: #dff7ed; }
.status.overdue { color: #9c2f2f; background: #ffe1e1; }
.role-pill.facilitator { color: #5b3aa5; background: #eee5ff; }
.empty-state { border: 1px dashed #aab8c7; border-radius: 14px; padding: 2rem; color: #627085; text-align: center; }
/* Stack dashboard regions when horizontal space becomes limited. */
@media (max-width: 780px) {
.dashboard-grid { grid-template-columns: 1fr; }
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
}
Verify both status states
Before you check the finished dashboard, which assignment do you expect to be Open?
- Refresh http://localhost:5173.
You should see Plan the project launch with an Open badge. You should see Review access rules with an Overdue badge.
- Drag the browser edge inward until the page becomes narrower than 780px.
You should see the role preview move below the assignment list. The cards remain readable in one column.
That is your first complete RoleGate view. The same assignment data now produces readable dates and distinct status signals.
Your mock portal now shows the experience both roles eventually share. Next, you will connect it to Supabase so accounts and assignments can persist.
Connect Supabase Authentication and Data
Your mock dashboard now turns due dates into clear assignment cards. A shared portal needs persistent data plus a reliable way to identify the person viewing it.
This step connects the React portal to Supabase. You'll add email and password authentication plus the database foundation for student and facilitator roles.
In this step, get ready to:
- Create a Supabase project for RoleGate Assignments.
- Create the profile and assignment database schema.
- Connect the React portal to authentication and stored data.
Create the Supabase project
Supabase gives the portal a hosted database plus account management. Both roles use the same backend while the stored profile decides which experience the portal displays.
Account setup asks for normal sign-in details. Keep those credentials private while you create the project.
- Go to the Supabase Dashboard in your browser.
- Create your Supabase account.
- Select the option to create a new project.
- Enter rolegate-assignments in the project name field.
- Choose the Free Plan.
- Complete the remaining required project fields.
- Create the project.
What does the Free Plan include?
The Supabase Free Plan starts at $0 per month. It provides enough database and authentication capacity for this local portal.
Free projects can pause after one week of inactivity. You can restore a paused project from the dashboard when you return.
Hosted projects normally confirm a user's email before creating an active session. Temporarily disabling that check lets you register both local test accounts during this project.
- Select Authentication in the Supabase Dashboard sidebar.
- Select Providers.
- Open the Email provider configuration.
- Turn off Confirm Email.
- Save the Email provider configuration.
Restore Confirmation Later
This shortcut supports rapid local testing. Re-enable Confirm Email before preparing the portal for public use.
Build the database schema
The portal stores identities in a PostgreSQL relationship. Each profile references an authenticated user while each assignment records the user who created it.
Row Level Security controls which rows an authenticated request can read or create. Grants allow an operation at the table level while policies decide which rows qualify.
Why Supabase for this portal?
Supabase keeps role authorization close to relational tables and policies. That makes the connection between users, profiles, assignments, and permissions visible in one database.
A platform such as Firebase can also support role-based access through Security Rules or custom claims. Supabase fits this project because PostgreSQL policies make the final write decision directly beside the assignment data.
- Return to the rolegate-assignments window in VS Code.
- Select the rolegate-assignments folder in the Explorer sidebar.
- Click New Folder in the Explorer toolbar.
- Name the folder supabase.
- Select the new supabase folder.
- Click New File in the Explorer toolbar.
- Name the file schema.sql.
- Define the profile and assignment tables by pasting this first chunk into supabase/schema.sql:
create table public.profiles (
id uuid not null references auth.users on delete cascade,
display_name text not null,
role text not null default 'student' check (role in ('student', 'facilitator')),
primary key (id)
);
create table public.assignments (
id text primary key,
title text not null,
instructions text not null,
due_at timestamptz not null,
created_by uuid not null references auth.users on delete cascade,
created_at timestamptz not null default now()
);
What do these tables store?
- The profiles table stores each user's display name plus a constrained student or facilitator role.
- The assignments table stores the content and due date for each published assignment.
- The created_by column links every assignment to an authenticated user.
- Save supabase/schema.sql.
- Confirm the file now shows one definition for public.profiles plus one definition for public.assignments.
Do the table definitions look incomplete?
Check that both closing parentheses are followed by a code terminator. Make sure the second table begins after the first table closes.
Ask for help checking the SQL structure without changing its table or column names: Help me compare my two Supabase table definitions with the supplied schema.
- Append the security settings and table privileges below the two table definitions:
alter table public.profiles enable row level security;
alter table public.assignments enable row level security;
revoke all on table public.profiles from anon, authenticated;
revoke all on table public.assignments from anon, authenticated;
grant select on table public.profiles to authenticated;
grant select, insert on table public.assignments to authenticated;
What do these controls do?
- Enabling Row Level Security makes each exposed table depend on policies for row access.
- The revoke statements remove broad privileges from anonymous and authenticated roles.
- The grants return only the operations this portal needs at this stage.
- Save supabase/schema.sql.
- Return to the Supabase Dashboard from earlier.
- Open the SQL Editor.
- Start an empty query.
- Paste the current contents of supabase/schema.sql into the query.
- Run the query.
The query completes without an error. Your project now has both tables with Row Level Security and least-privilege grants enabled.
Did the first schema query fail?
Check that you pasted both table definitions before the security statements. A privilege statement cannot target a table that was never created.
Ask for help locating the failing SQL statement: Help me identify why my RoleGate table and privilege SQL did not run.
- Switch back to supabase/schema.sql in VS Code.
- Append the three access policies below the grant statements:
create policy "Users can view their own profile"
on public.profiles
for select
to authenticated
using ((select auth.uid()) = id);
create policy "Authenticated users can view assignments"
on public.assignments
for select
to authenticated
using (true);
create policy "Authenticated users can create assignments"
on public.assignments
for insert
to authenticated
with check ((select auth.uid()) = created_by);
How do the policies behave?
- Each authenticated user can read only the profile whose ID matches their own user ID.
- Every authenticated user can read assignments because both roles share the dashboard.
- The current insert policy accepts an assignment when the caller uses their own ID in created_by.
- The policy named Authenticated users can create assignments intentionally allows both authenticated roles to insert. A later permission check exposes why that rule is too broad.
- Save supabase/schema.sql.
- Return to the SQL Editor from earlier.
- Start an empty query.
- Paste the policy chunk into the query.
- Run the query.
The policy query completes without an error. Signed-in users can now read their own profile plus every assignment.
Did a policy fail to create?
Confirm that the table and privilege query completed first. Check each policy name plus its target table against the supplied code.
Ask for help comparing the policy definitions: Help me find the mismatch in my Supabase RLS policies.
- Switch back to supabase/schema.sql in VS Code.
- Append the signup function and trigger below the policies:
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, display_name, role)
values (
new.id,
coalesce(new.raw_user_meta_data ->> 'display_name', 'Learner'),
'student'
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
What does the signup trigger do?
The handle_new_user() function copies a new authenticated user's ID plus display name into public.profiles.
Every new profile receives the student role. Promotion to facilitator stays outside the browser interface.
- Save supabase/schema.sql.
- Return to the SQL Editor from earlier.
- Start an empty query.
- Paste the signup function and trigger chunk into the query.
- Run the query.
The query completes without an error. Each new Auth user now receives a matching student profile automatically.
Did the trigger query fail?
Compare the function body with the supplied SQL. A missing delimiter or closing keyword prevents PostgreSQL from creating the function.
Resolve the trigger before registering an account because a failing profile trigger can block signup. Help me debug my handle_new_user function and signup trigger.
✔️ Awesome, I've got everything!
Great. Save supabase/schema.sql with the table definitions, access rules, policies, function, and trigger in that order.
ⓧ I'd like to double check the full code
Compare your file with this complete supabase/schema.sql reference.
create table public.profiles (
id uuid not null references auth.users on delete cascade,
display_name text not null,
role text not null default 'student' check (role in ('student', 'facilitator')),
primary key (id)
);
create table public.assignments (
id text primary key,
title text not null,
instructions text not null,
due_at timestamptz not null,
created_by uuid not null references auth.users on delete cascade,
created_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
alter table public.assignments enable row level security;
revoke all on table public.profiles from anon, authenticated;
revoke all on table public.assignments from anon, authenticated;
grant select on table public.profiles to authenticated;
grant select, insert on table public.assignments to authenticated;
create policy "Users can view their own profile"
on public.profiles
for select
to authenticated
using ((select auth.uid()) = id);
create policy "Authenticated users can view assignments"
on public.assignments
for select
to authenticated
using (true);
create policy "Authenticated users can create assignments"
on public.assignments
for insert
to authenticated
with check ((select auth.uid()) = created_by);
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, display_name, role)
values (
new.id,
coalesce(new.raw_user_meta_data ->> 'display_name', 'Learner'),
'student'
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
How is the schema organized?
The file creates data structures first. It then applies privileges and row policies before attaching automatic profile creation to Auth signups.
Connect the React portal
The browser client needs a project URL plus a publishable key to reach your backend. The publishable key is designed for browser applications because database policies still evaluate every request.
These values can feel sensitive. Keep secret keys on trusted servers while using only the publishable key in this portal.
- Return to the rolegate-assignments window in VS Code.
- Select the rolegate-assignments folder in the Explorer sidebar.
- Click New File in the Explorer toolbar.
- Name the file .env.local.
- Add the environment variable template by pasting:
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key-here
What do these values control?
- The project URL tells the client which Supabase project should receive its requests.
- The publishable key identifies the browser application while Row Level Security evaluates the signed-in user.
- The existing *.local rule in .gitignore keeps this local environment file out of future commits.
- Return to the Supabase Dashboard from earlier.
- Open the project Connect dialog.
- Copy the Project URL.
- Replace https://your-project-ref.supabase.co in .env.local with the copied URL.
- Copy the publishable key.
- Replace your-publishable-key-here in .env.local with the copied key.
- Save .env.local.
Cannot find the connection values?
Use the project Connect dialog for both values. Confirm that you selected the publishable key for browser code.
Ask for help locating the safe client values: Help me find the Project URL and publishable key for my Supabase browser app.
A small client module keeps the connection setup in one place. Every authentication call and database query imports the same supabase client.
- Select the src folder in the VS Code Explorer.
- Click New Folder in the Explorer toolbar.
- Name the folder lib.
- Select the new src/lib folder.
- Click New File in the Explorer toolbar.
- Name the file supabaseClient.js.
- Create and export the shared client by pasting:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
export const supabase = createClient(supabaseUrl, supabasePublishableKey)
What does this client do?
- The import loads the Supabase JavaScript client factory.
- The two constants read the values exposed by the Vite environment.
- The exported supabase object becomes the portal's shared connection to Auth and the database.
- Save src/lib/supabaseClient.js.
- Confirm the Explorer now lists supabaseClient.js inside src/lib.
Does the client file show an import problem?
Check that the package name and environment variable names match the supplied code exactly. Confirm that the client file sits inside src/lib.
Ask for help comparing the client module: Help me debug my Supabase client import and environment variables.
The authenticated component listens for session changes with an effect. A second effect loads the user's profile plus the ordered assignments whenever a signed-in user becomes available.
- Open src/App.jsx from the VS Code Explorer.
- Replace the mock dashboard with the authenticated portal shown in the full-code tab below.
✔️ Awesome, I've got everything!
Continue once src/App.jsx includes the session listener, account form, dashboard loader, and local sign-out handler.
ⓧ I'd like to double check the full code
Replace the current mock version with this complete src/App.jsx file.
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabaseClient'
function getStatus(dueAt) {
return new Date(dueAt).getTime() < Date.now() ? 'Overdue' : 'Open'
}
function formatDate(value) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
export default function App() {
const [session, setSession] = useState(undefined)
const [profile, setProfile] = useState(null)
const [assignments, setAssignments] = useState([])
const [authMode, setAuthMode] = useState('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession)
})
return () => {
subscription.unsubscribe()
}
}, [])
useEffect(() => {
if (!session?.user) {
setProfile(null)
setAssignments([])
return
}
loadDashboard(session.user.id)
}, [session])
async function loadDashboard(userId) {
setBusy(true)
const [profileResult, assignmentsResult] = await Promise.all([
supabase
.from('profiles')
.select('display_name, role')
.eq('id', userId)
.single(),
supabase
.from('assignments')
.select('id, title, instructions, due_at, created_by, created_at')
.order('due_at', { ascending: true }),
])
if (profileResult.error || assignmentsResult.error) {
setMessage(
profileResult.error?.message || assignmentsResult.error?.message,
)
} else {
setProfile(profileResult.data)
setAssignments(assignmentsResult.data)
}
setBusy(false)
}
async function handleAuth(event) {
event.preventDefault()
setBusy(true)
setMessage('')
if (authMode === 'signup') {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { display_name: displayName },
},
})
if (error) {
setMessage(error.message)
} else if (!data.session) {
setMessage('Account created. Confirm the email before signing in.')
} else {
setMessage('Account created and signed in.')
}
} else {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
setMessage(error ? error.message : 'Signed in successfully.')
}
setBusy(false)
}
async function handleSignOut() {
const { error } = await supabase.auth.signOut({ scope: 'local' })
setMessage(error ? error.message : 'Signed out.')
}
if (session === undefined) {
return <main className="centered">Loading RoleGate...</main>
}
if (!session) {
return (
<main className="auth-shell">
<section className="auth-card">
<p className="eyebrow">RoleGate Assignments</p>
<h1>{authMode === 'signup' ? 'Create a test account' : 'Welcome back'}</h1>
<p className="muted">
Every new account starts as a student. Promote only the facilitator
from the SQL Editor.
</p>
<form onSubmit={handleAuth} className="stack">
{authMode === 'signup' && (
<label>
Display name
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
required
/>
</label>
)}
<label>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button disabled={busy} type="submit">
{busy
? 'Working...'
: authMode === 'signup'
? 'Create account'
: 'Sign in'}
</button>
</form>
<button
className="link-button"
onClick={() => {
setAuthMode(authMode === 'signup' ? 'signin' : 'signup')
setMessage('')
}}
>
{authMode === 'signup'
? 'Already registered? Sign in'
: 'Need a test account? Register'}
</button>
{message && <p className="notice">{message}</p>}
</section>
</main>
)
}
if (!profile) {
return <main className="centered">Loading your role...</main>
}
return (
<main className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">RoleGate Assignments</p>
<h1>{profile.display_name}'s dashboard</h1>
</div>
<div className="account-actions">
<span className={`role-pill ${profile.role}`}>{profile.role}</span>
<button className="secondary" onClick={handleSignOut}>
Sign out
</button>
</div>
</header>
{message && <p className="notice">{message}</p>}
<section className="dashboard-grid">
<div>
<div className="section-heading">
<div>
<p className="eyebrow">Shared workspace</p>
<h2>Assignments</h2>
</div>
<span>{assignments.length} total</span>
</div>
<div className="assignment-grid">
{assignments.map((item) => {
const status = getStatus(item.due_at)
return (
<article className="assignment-card" key={item.id}>
<div className="card-heading">
<h3>{item.title}</h3>
<span className={`status ${status.toLowerCase()}`}>
{status}
</span>
</div>
<p>{item.instructions}</p>
<p className="due-date">Due {formatDate(item.due_at)}</p>
</article>
)
})}
{!assignments.length && (
<p className="empty-state">No assignments have been published yet.</p>
)}
</div>
</div>
</section>
</main>
)
}
How does the authenticated portal work?
- The Auth listener updates session whenever a user signs in or out.
- The dashboard effect loads the matching profile plus assignments for each active session.
- The account form sends either a signup request or a password sign-in request according to authMode.
- Local sign-out removes the current browser session while leaving other sessions untouched.
- Save src/App.jsx.
- Stop the development server in the VS Code terminal by pressing Ctrl+C.
- Restart the development server so it reads .env.local by running:
npm run dev
What does this command do?
The command starts the local Vite development server. The restarted process reads the new Supabase environment values before serving the portal.
The terminal shows the local address http://localhost:5173. Your browser displays the RoleGate sign-in card.
Does the sign-in card fail to load?
Confirm that both environment variables contain values from the same Supabase project. Check that supabaseClient.js is inside src/lib.
Ask for help tracing the connection files: Help me debug why my RoleGate authentication screen does not load.
Before you register, which role do you think the signup trigger assigns to the first account?
- Return to http://localhost:5173 in your browser.
- Click Need a test account? Register.
- Enter Facilitator in the Display name field.
- Enter your facilitator email address in the Email field.
- Enter a private test password in the Password field.
- Click Create account.
You'll see your chosen display name in the dashboard header. The role pill shows student because the signup trigger gives every new account the safe starting role.
That connection is working: your account now creates a profile and loads the authenticated dashboard. Next up, you'll promote the facilitator and build the role-aware assignment workflows.
Build Role-Aware Workflows
Your portal now signs users in through Supabase Auth. It also loads each account's stored role from the shared database.
A React role check can shape the dashboard. The temporary Row Level Security policy still trusts every authenticated user with assignment inserts. This step turns that authorization gap into a visible test.
In this step, get ready to:
- Promote the first test account to facilitator.
- Add facilitator assignment creation to the portal.
- Use a student permission probe to expose the broad insert policy.
Promote the facilitator account
Every account currently starts with the student role. A trusted database update promotes one account without exposing role selection in the browser.
- In the Explorer sidebar from earlier, expand the supabase folder.
- Create a new file inside that folder with the name promote-facilitator.sql.
- Add the facilitator promotion query by pasting this complete file:
update public.profiles
set role = 'facilitator'
where id = (
select id
from auth.users
where email = 'facilitator@example.com'
);
What Does This Query Do?
- The subquery finds the authenticated user whose email is facilitator@example.com.
- The outer update changes only that user's profile role to facilitator.
- The browser never receives a control that can promote an account.
- Save supabase/promote-facilitator.sql by pressing Cmd+S on macOS or Ctrl+S on Windows.
- Select the complete query in supabase/promote-facilitator.sql.
- Copy the selected query.
- Return to the Supabase Dashboard from earlier.
- Select the SQL Editor for the rolegate-assignments project.
- Paste the query into a new SQL Editor query.
- Click Run.
The query should complete without an error. The matching profile now stores the facilitator role.
Role Update Did Not Run?
Confirm that the account email is exactly facilitator@example.com. The update cannot match an account that uses another email address.
If the account exists but the query still fails, check that supabase/schema.sql created public.profiles during the previous step.
Ask for help with the exact query result: Why did my facilitator promotion query fail?
- Return to the running RoleGate app at http://localhost:5173.
- Click Sign out.
- Enter the facilitator email in the Email field.
- Enter the facilitator password in the Password field.
- Click Sign in.
Your dashboard header should now show a facilitator role pill. Signing in again forces the app to reload the updated profile.
Add assignment creation and the permission probe
The role-aware interface gives facilitators a publishing form. Students receive a permission check that sends the same kind of database insert without relying on the hidden form.
- Return to src/App.jsx in the VS Code editor from earlier.
- Replace its contents with the complete role-aware version in the double-check tab below.
✔️ I've updated App.jsx
Your file now contains the facilitator form plus the student permission probe.
ⓧ I'd like to double check the full code
- Compare src/App.jsx with this complete version:
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabaseClient'
const emptyAssignment = {
title: '',
instructions: '',
dueAt: '',
}
function getStatus(dueAt) {
return new Date(dueAt).getTime() < Date.now() ? 'Overdue' : 'Open'
}
function formatDate(value) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
export default function App() {
const [session, setSession] = useState(undefined)
const [profile, setProfile] = useState(null)
const [assignments, setAssignments] = useState([])
const [authMode, setAuthMode] = useState('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [assignment, setAssignment] = useState(emptyAssignment)
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession)
})
return () => {
subscription.unsubscribe()
}
}, [])
useEffect(() => {
if (!session?.user) {
setProfile(null)
setAssignments([])
return
}
loadDashboard(session.user.id)
}, [session])
async function loadDashboard(userId) {
setBusy(true)
const [profileResult, assignmentsResult] = await Promise.all([
supabase
.from('profiles')
.select('display_name, role')
.eq('id', userId)
.single(),
supabase
.from('assignments')
.select('id, title, instructions, due_at, created_by, created_at')
.order('due_at', { ascending: true }),
])
if (profileResult.error || assignmentsResult.error) {
setMessage(
profileResult.error?.message || assignmentsResult.error?.message,
)
} else {
setProfile(profileResult.data)
setAssignments(assignmentsResult.data)
}
setBusy(false)
}
async function handleAuth(event) {
event.preventDefault()
setBusy(true)
setMessage('')
if (authMode === 'signup') {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { display_name: displayName },
},
})
if (error) {
setMessage(error.message)
} else if (!data.session) {
setMessage('Account created. Confirm the email before signing in.')
} else {
setMessage('Account created and signed in.')
}
} else {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
setMessage(error ? error.message : 'Signed in successfully.')
}
setBusy(false)
}
async function handleSignOut() {
const { error } = await supabase.auth.signOut({ scope: 'local' })
setMessage(error ? error.message : 'Signed out.')
}
async function handleCreateAssignment(event) {
event.preventDefault()
setBusy(true)
setMessage('')
const { error } = await supabase.from('assignments').insert({
id: `${session.user.id}-${Date.now()}`,
title: assignment.title,
instructions: assignment.instructions,
due_at: new Date(assignment.dueAt).toISOString(),
created_by: session.user.id,
})
if (error) {
setMessage(error.message)
} else {
setAssignment(emptyAssignment)
setMessage('Assignment published.')
await loadDashboard(session.user.id)
}
setBusy(false)
}
async function runPermissionCheck() {
setBusy(true)
setMessage('')
const { error } = await supabase.from('assignments').insert({
id: `${session.user.id}-probe-${Date.now()}`,
title: 'Student permission probe',
instructions: 'This row should be rejected after RLS is fixed.',
due_at: new Date(Date.now() + 86400000).toISOString(),
created_by: session.user.id,
})
if (error) {
setMessage(`Blocked by database policy: ${error.message}`)
} else {
setMessage(
'The insert succeeded. The UI is hiding controls, but the database is still too permissive.',
)
await loadDashboard(session.user.id)
}
setBusy(false)
}
if (session === undefined) {
return <main className="centered">Loading RoleGate...</main>
}
if (!session) {
return (
<main className="auth-shell">
<section className="auth-card">
<p className="eyebrow">RoleGate Assignments</p>
<h1>{authMode === 'signup' ? 'Create a test account' : 'Welcome back'}</h1>
<p className="muted">
Every new account starts as a student. Promote only the facilitator
from the SQL Editor.
</p>
<form onSubmit={handleAuth} className="stack">
{authMode === 'signup' && (
<label>
Display name
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
required
/>
</label>
)}
<label>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button disabled={busy} type="submit">
{busy
? 'Working...'
: authMode === 'signup'
? 'Create account'
: 'Sign in'}
</button>
</form>
<button
className="link-button"
onClick={() => {
setAuthMode(authMode === 'signup' ? 'signin' : 'signup')
setMessage('')
}}
>
{authMode === 'signup'
? 'Already registered? Sign in'
: 'Need a test account? Register'}
</button>
{message && <p className="notice">{message}</p>}
</section>
</main>
)
}
if (!profile) {
return <main className="centered">Loading your role...</main>
}
return (
<main className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">RoleGate Assignments</p>
<h1>{profile.display_name}'s dashboard</h1>
</div>
<div className="account-actions">
<span className={`role-pill ${profile.role}`}>{profile.role}</span>
<button className="secondary" onClick={handleSignOut}>
Sign out
</button>
</div>
</header>
{message && <p className="notice">{message}</p>}
<section className="dashboard-grid">
<div>
<div className="section-heading">
<div>
<p className="eyebrow">Shared workspace</p>
<h2>Assignments</h2>
</div>
<span>{assignments.length} total</span>
</div>
<div className="assignment-grid">
{assignments.map((item) => {
const status = getStatus(item.due_at)
return (
<article className="assignment-card" key={item.id}>
<div className="card-heading">
<h3>{item.title}</h3>
<span className={`status ${status.toLowerCase()}`}>
{status}
</span>
</div>
<p>{item.instructions}</p>
<p className="due-date">Due {formatDate(item.due_at)}</p>
</article>
)
})}
{!assignments.length && (
<p className="empty-state">No assignments have been published yet.</p>
)}
</div>
</div>
<aside>
{profile.role === 'facilitator' ? (
<section className="panel">
<p className="eyebrow">Facilitator action</p>
<h2>Create Assignment</h2>
<form onSubmit={handleCreateAssignment} className="stack">
<label>
Title
<input
value={assignment.title}
onChange={(event) =>
setAssignment({ ...assignment, title: event.target.value })
}
required
/>
</label>
<label>
Instructions
<textarea
value={assignment.instructions}
onChange={(event) =>
setAssignment({
...assignment,
instructions: event.target.value,
})
}
required
/>
</label>
<label>
Due date
<input
type="datetime-local"
value={assignment.dueAt}
onChange={(event) =>
setAssignment({ ...assignment, dueAt: event.target.value })
}
required
/>
</label>
<button disabled={busy} type="submit">
Publish assignment
</button>
</form>
</section>
) : (
<section className="panel">
<p className="eyebrow">Student security check</p>
<h2>Verify your permissions</h2>
<p className="muted">
This sends an assignment insert directly to Supabase. The final
policy must reject it even though the browser has a publishable key.
</p>
<button disabled={busy} onClick={runPermissionCheck}>
Run permission check
</button>
</section>
)}
</aside>
</section>
</main>
)
}
How the Role-Aware File Works
- The assignment state holds the facilitator's title, instructions, and due date.
- The handleCreateAssignment() function writes a normal assignment with the signed-in user's ID.
- The runPermissionCheck() function sends a direct insert from the student dashboard.
- The role condition chooses which panel the signed-in user sees.
- Save src/App.jsx by pressing Cmd+S on macOS or Ctrl+S on Windows.
- Return to the running app in your browser.
The facilitator dashboard should now show the Create Assignment panel. The shared assignment area remains visible beside it.
- Enter Role access review in the Title field.
- Enter Explain which actions belong to each role. in the Instructions field.
- Choose a future date in the Due date field.
- Click Publish assignment.
You should see Assignment published. above the dashboard. Your new assignment card should appear with an Open badge.
Create Assignment Form Missing?
Sign out once more if the role pill still says student. Sign back in with facilitator@example.com so the app reloads the promoted profile.
If the form appears but publishing fails, confirm that every field contains a value. The due date must be a valid date and time.
Ask for help with the visible result: Why is my facilitator assignment form missing or failing to publish?
Expose the student authorization gap
The facilitator workflow proves the normal write path works. A second account now tests whether the database independently enforces the role boundary.
- Click Sign out on the facilitator dashboard.
- Click Need a test account? Register.
- Enter Student Tester in the Display name field.
- Enter a second test email in the Email field.
- Enter a test password in the Password field.
- Click Create account.
The new dashboard should show the student role. You should see Verify your permissions instead of the creation form.
Before you run the check, do you think hiding the facilitator form also stops a direct assignment insert?
- Click Run permission check.
You should see a notice beginning with The insert succeeded. A new Student permission probe card should appear in the shared assignment list.
Why Did the Student Insert Succeed?
The interface reads the profile role before choosing which panel to display. That check controls presentation inside the browser.
The current database policy checks only that the inserted created_by value matches the signed-in user. Every authenticated student can satisfy that condition.
The successful probe is the intended result for this step. It proves that hidden controls cannot protect a database write.
You have exposed the authorization gap with a repeatable test. Next, you will replace the broad policy so the same student probe is blocked by the database.
Enforce Role-Based RLS
Your React dashboard now gives facilitators a creation form. It hides that form from students.
The successful student probe exposed the remaining security gap. The database still accepted the student's direct insert.
A Row Level Security policy in Supabase Database can evaluate the caller's profile before each insert. This moves authorization beside the data it protects.
In this step, get ready to:
- Replace the broad insert policy with a role-aware database policy.
- Prove the student probe is blocked by the database.
- Verify the intended access pattern across both roles.
Replace the permissive insert policy
The new policy checks the row owner against the signed-in user. It also requires the caller's stored profile to carry the facilitator role.
This script deletes only rows titled Student permission probe. Your published assignments stay in place.
- Create supabase/secure-policy.sql inside the existing supabase folder in VS Code by adding the code below:
delete from public.assignments
where title = 'Student permission probe';
drop policy "Authenticated users can create assignments"
on public.assignments;
create policy "Facilitators can create assignments"
on public.assignments
for insert
to authenticated
with check (
(select auth.uid()) = created_by
and exists (
select 1
from public.profiles
where profiles.id = (select auth.uid())
and profiles.role = 'facilitator'
)
);
What Does This Policy Change?
- The delete statement removes rows created by the earlier security probe.
- The drop policy statement removes Authenticated users can create assignments.
- The first policy check compares auth.uid() with created_by.
- The profile lookup permits the insert only when the caller's role is facilitator.
- Save supabase/secure-policy.sql.
The VS Code Explorer should now list secure-policy.sql inside the supabase folder.
✔️ Awesome, I've got everything!
Your secured policy file now matches the required database rules.
ⓧ I'd like to double check the full code
- Compare supabase/secure-policy.sql with the complete file below:
delete from public.assignments
where title = 'Student permission probe';
drop policy "Authenticated users can create assignments"
on public.assignments;
create policy "Facilitators can create assignments"
on public.assignments
for insert
to authenticated
with check (
(select auth.uid()) = created_by
and exists (
select 1
from public.profiles
where profiles.id = (select auth.uid())
and profiles.role = 'facilitator'
)
);
What Should Match?
- The probe cleanup statement comes first.
- The broad policy removal comes second.
- The facilitator policy comes last.
- Return to the Supabase Dashboard tab from earlier.
- Select SQL Editor in the left navigation.
- Create a blank query.
- Copy the entire contents of supabase/secure-policy.sql.
- Paste the script into the query editor.
- Run the query using the SQL Editor's execution control.
The query status should show that it completed successfully. The old probe row is now removed from the database.
That policy swap is complete. Assignment creation now checks the caller's stored role.
Policy Update Did Not Complete?
- Confirm the SQL Editor is connected to the same Supabase project used by .env.local.
- Check that supabase/schema.sql ran before supabase/secure-policy.sql.
- Compare the original policy name with Authenticated users can create assignments.
Help me troubleshoot why the secured assignment policy did not apply.
Prove the student write is blocked
The runPermissionCheck() function still sends the same direct insert. The database policy is the only part that changed.
- Switch back to the local RoleGate app tab from earlier.
- Refresh the browser.
The role pill should show student. The earlier probe card should be gone.
Before you run the probe, consider whether the same student insert can still reach the table.
- Click Run permission check in the student security panel.
You'll see a notice beginning Blocked by database policy. No new assignment card appears.
This is the key security boundary working. The database now rejects the student's write.
Did the Student Insert Still Succeed?
- Confirm that supabase/secure-policy.sql completed in the same project referenced by .env.local.
- Run supabase/secure-policy.sql again from the SQL Editor.
- Refresh the RoleGate app before repeating the permission check.
Help me find why the student permission probe still inserts an assignment.
Confirm facilitator writes and student reads
A secure insert policy must preserve the intended facilitator workflow. The existing read policy still lets both authenticated roles load assignments.
Before the final role check, consider which actions should differ between the two accounts.
- Click Sign out.
- Enter the facilitator account's email in the Email field.
- Enter the facilitator account's password in the Password field.
- Click Sign in.
- Enter RLS verification assignment in the Title field.
- Enter Confirm role-based publishing works. in the Instructions field.
- Choose a date at least one day in the future in the Due date field.
- Click Publish assignment.
You'll see Assignment published. The new card shows an Open status.
Facilitator Assignment Was Blocked?
- Confirm the header role pill shows facilitator.
- Sign out before signing back in after any profile role change.
- Choose a valid future value in the Due date field.
Help me diagnose why the facilitator assignment insert was blocked.
Before returning to the student account, consider whether read access should survive the stricter insert policy.
- Click Sign out.
- Enter the student account's email in the Email field.
- Enter the student account's password in the Password field.
- Click Sign in.
The RLS verification assignment card remains visible. The Create Assignment form is absent.
Can't See the Assignment as the Student?
- Refresh the RoleGate app to reload the ordered assignment list.
- Confirm that the facilitator saw Assignment published. before signing out.
- Confirm the student session is connected to the same Supabase project.
Help me troubleshoot why the student cannot read the facilitator's assignment.
Your authorization boundary now holds across both roles. The database enforces the same role separation that the dashboard presents.
Secret mission
Filter Assignments by Status
Turn the assignment list into a focused dashboard with All, Open, and Overdue filters. Both roles keep the same secure data access while the interface derives each view from existing due dates.
Clean Up Your Resources
Clean Up Your Resources
This project uses the Supabase Free Plan plus local files, so there are no ongoing costs. Choose the option that matches whether you are still building or ready to remove everything.
Resources you used:
- Local React project in the rolegate-assignments folder.
- Local Vite development server running from the rolegate-assignments folder.
- Supabase Free Plan project named rolegate-assignments containing the portal's users plus database data.
Keep everything running
No deletion is needed. Choose this option if you plan to keep developing RoleGate Assignments.
- Switch back to the PowerShell window running Vite.
- Press Ctrl+C to stop the local development server when you finish working.
- Leave the rolegate-assignments Supabase project in place.
- Restore the project from the Supabase dashboard if inactivity pauses it after one week.
- Return to the rolegate-assignments folder in PowerShell when you continue.
- Restart the local development server by running this command:
npm run dev
What does this command do?
This command starts the Vite development server again. You should see the local address http://localhost:5173 in PowerShell.
Pause - I'll come back to this later
Pause the active services while keeping the project intact. This frees local memory plus preserves your files and hosted data.
- Switch back to the PowerShell window running Vite.
- Press Ctrl+C to stop the local development server.
- Return to your Supabase dashboard.
- Select the rolegate-assignments project.
- Pause the Supabase project from its dashboard.
You should see the project marked as paused. Your local files stay ready for your next session.
- Restore the rolegate-assignments project from the Supabase dashboard when you return.
- Return to the rolegate-assignments folder in PowerShell.
- Restart the local development server by running this command:
npm run dev
How Does the Restart Work?
This command starts the existing Vite project without recreating any files. You should see http://localhost:5173 in PowerShell when the server is ready.
Delete - I don't want to use this again
Remove the hosted project plus local files if you want a clean start. The confirmation screen gives you a final chance to back out before Supabase deletes the users and database data.
- Switch back to the PowerShell window running Vite.
- Press Ctrl+C to stop the local development server.
- Return to your Supabase dashboard.
- Select the rolegate-assignments project.
- Open project settings for the selected project.
- Delete the rolegate-assignments project from this page.
- Confirm the deletion when Supabase asks.
The project should disappear from your Supabase dashboard. That removes its Auth users plus database data.
- Locate the rolegate-assignments folder in File Explorer.
- Right-click the rolegate-assignments folder.
- Select Delete from the menu.
The rolegate-assignments folder should no longer appear in File Explorer.
Before a Public Launch
- Re-enable Confirm Email before exposing the portal publicly.
- Review the Security Advisor in your Supabase project.
- Keep only the Supabase publishable key in browser code.
Nice Work!
Nice Work!
You made it! You built RoleGate Assignments as a secure React portal with separate facilitator and student experiences.
You've learned how to:
- Build a responsive assignment dashboard where facilitators publish assignments and students read the shared list.
- Connect Supabase Auth to automatic profiles that give each signed-in user the correct role-specific experience.
- Expose a permissive policy with a student probe before enforcing PostgreSQL Row Level Security that blocks student inserts while preserving facilitator creation.
- Secret Mission: Added All, Open, and Overdue filters that reuse getStatus() without changing secured queries or policies.
Ready to quiz yourself?