A role-based project and task management app built with React, Firebase Authentication, and Firebase Realtime Database (RTDB). Admins create projects and assign tasks; Members track and update progress on their own tasks — all in real time, no page reload needed.
- Features
- Tech Stack
- Project Structure
- How Firebase Auth Works in This Project
- How Firebase RTDB Works in This Project
- Role & Permission System
- Setup Guide for New Users
- Environment Variables
- Common Issues & Fixes
- Email/password signup and login via Firebase Auth
- Role-based access: ADMIN and MEMBER
- Admins can create/delete projects and assign tasks to members
- Members can update status (To Do → In Progress → Done) on their own tasks
- All data updates are real-time — no page refresh required
- Persistent login — session survives page reloads
| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite |
| Routing | React Router v6 |
| Auth | Firebase Authentication |
| Database | Firebase Realtime Database |
| Animations | Framer Motion |
| UI | Bootstrap 5 + Lucide Icons |
src/
├── context/
│ └── AuthContext.jsx # Auth state + dbUser provider
├── pages/
│ ├── Login.jsx # Login form
│ ├── Signup.jsx # Signup form (with role selection)
│ ├── Dashboard.jsx # Project list + all/my tasks
│ └── ProjectDetails.jsx # Tasks for a single project
├── utils/
│ └── permissions.js # Role-based permission helpers
├── firebase.js # Firebase app init + exports
└── App.jsx # Routes + PrivateRoute guard
User visits app
│
▼
PrivateRoute checks loading state
│
├── loading === true → show spinner (wait for Firebase)
│
├── currentUser === null → redirect to /login
│
└── currentUser exists → render the page
Firebase Auth restores the session asynchronously on page reload. Without the loading guard in PrivateRoute, currentUser is null for ~300ms on every refresh, which causes an immediate redirect to /login even for logged-in users.
// App.jsx — PrivateRoute
const PrivateRoute = ({ children }) => {
const { currentUser, loading } = useAuth();
if (loading) return <Spinner />; // ← wait for Firebase to restore session
return currentUser ? children : <Navigate to="/login" replace />;
};Firebase gives you a firebaseUser object (from Auth) that holds uid, email, and displayName. But role and name are stored in RTDB. AuthContext fetches both and merges them into a single dbUser object available everywhere:
// What dbUser looks like after AuthContext resolves:
{
id: "uid_abc123",
email: "alice@company.com",
name: "Alice",
role: "ADMIN" // or "MEMBER"
}On login / signup / reload:
onAuthStateChangedfires with thefirebaseUser- A
get()call fetchesusers/{uid}from RTDB - Both are merged into
dbUserand stored in context loadingis set tofalse— the app renders
During signup, two things happen in sequence:
// 1. Create the Firebase Auth account
const credential = await createUserWithEmailAndPassword(auth, email, password);
// 2. Write the user profile (including role) to RTDB
await set(ref(db, `users/${credential.user.uid}`), { name, email, role });The role (ADMIN or MEMBER) is written to RTDB at signup and validated by the database rules.
/
├── users/
│ └── {uid}/
│ ├── name: "Alice"
│ ├── email: "alice@company.com"
│ └── role: "ADMIN" | "MEMBER"
│
├── projects/
│ └── {projectId}/
│ ├── name: "Website Redesign"
│ ├── description: "..."
│ ├── managerId: "{uid}"
│ └── createdAt: "2024-01-01T00:00:00.000Z"
│
└── tasks/
└── {taskId}/
├── title: "Build login page"
├── description: "..."
├── projectId: "{projectId}"
├── assigneeId: "{uid}"
├── status: "TODO" | "IN_PROGRESS" | "DONE"
└── createdAt: "2024-01-01T00:00:00.000Z"
Dashboard and ProjectDetails both use onValue() — Firebase's real-time listener — instead of one-time get() calls. This means any change written to RTDB (by any user) is pushed to all connected clients instantly.
// Three parallel listeners — each updates a ref and re-derives state
const unsubUsers = onValue(ref(db, "users"), (snap) => { ... });
const unsubProjects = onValue(ref(db, "projects"), (snap) => { ... });
const unsubTasks = onValue(ref(db, "tasks"), (snap) => { ... });
// Clean up when component unmounts
return () => { unsubUsers(); unsubProjects(); unsubTasks(); };The three raw data maps (usersData, projectsData, tasksData) are stored in useRef rather than useState. This is intentional:
- Each listener fires independently. If stored in
useState, one listener updating its state would trigger a re-render before the other two have updated — causing a brief render with stale cross-references (e.g. a task rendered before its project name is loaded). - With
useRef, all three maps are always in sync whensync()runs, because refs are mutable and don't trigger re-renders themselves. Only the derivedprojectsandtasksarrays (set viauseState) cause re-renders.
const usersRef = useRef({}); // raw map, no re-render
const projectsRef = useRef({});
const tasksRef = useRef({});
const sync = () => {
// derive display data from all three maps at once
// then call setProjects / setTasks → single re-render
};All permission logic lives in src/utils/permissions.js. Components import these helpers and use them to conditionally render UI and guard write operations.
| Action | ADMIN | MEMBER |
|---|---|---|
| Create project | ✅ | ❌ |
| Delete project | ✅ | ❌ |
| Create task | ✅ | ❌ |
| Assign task to member | ✅ | ❌ |
| Delete task | ✅ | ❌ |
| Update status on own task | ✅ | ✅ |
| Update status on others' tasks | ✅ | ❌ |
| View all tasks | ✅ | ❌ (own only) |
git clone https://github.com/your-username/team-task-manager.git
cd team-task-manager
npm install- Go to https://console.firebase.google.com
- Click Add project → give it a name → click through the steps
- On the project dashboard, click the Web icon (
</>) to register a web app - Give it a nickname (e.g.
team-task-manager-web) - Copy the
firebaseConfigobject shown — you'll need it in the next step
- In the Firebase console, go to Build → Authentication
- Click Get started
- Under Sign-in method, enable Email/Password
- Click Save
- Go to Build → Realtime Database
- Click Create Database
- Choose your region (pick the one closest to your users)
- Start in locked mode (you'll add the rules next)
- Click Enable
- In the Realtime Database console, click the Rules tab
- Replace the contents with the rules from
database.rules.jsonin this project - Click Publish
Create a .env file in the project root:
VITE_FIREBASE_API_KEY=your_api_key
VITE_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
VITE_FIREBASE_DATABASE_URL=https://your_project-default-rtdb.firebaseio.com
VITE_FIREBASE_PROJECT_ID=your_project_id
VITE_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
VITE_FIREBASE_APP_ID=your_app_idAll values come from the firebaseConfig object you copied in step 2.
// src/firebase.js
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getDatabase } from "firebase/database";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getDatabase(app);npm run devOpen http://localhost:5173 in your browser.
- Go to
/signupand create an Admin account (select Administrator role) - Create another account as a Member
- Log in as Admin → create a project → open it → assign tasks to the Member
- Log in as Member → see only your tasks → update their status
| Variable | Where to find it |
|---|---|
VITE_FIREBASE_API_KEY |
Firebase Console → Project Settings → Your apps |
VITE_FIREBASE_AUTH_DOMAIN |
Same as above |
VITE_FIREBASE_DATABASE_URL |
Realtime Database → Data tab (the URL at the top) |
VITE_FIREBASE_PROJECT_ID |
Project Settings → General |
VITE_FIREBASE_STORAGE_BUCKET |
Project Settings → Your apps |
VITE_FIREBASE_MESSAGING_SENDER_ID |
Project Settings → Your apps |
VITE_FIREBASE_APP_ID |
Project Settings → Your apps |
Never commit your
.envfile. Add it to.gitignore.
Dashboard shows spinner forever / user gets logged out on reload
→ Make sure PrivateRoute in App.jsx checks loading before checking currentUser. Firebase needs ~300ms to restore the session on page load.
"Failed to load users" error in console
→ Your database rule for users must have .read: "auth != null" at the collection level, not nested inside $uid. See database.rules.json.
Members can't update task status
→ The task write rule requires data.exists() — meaning the task must already exist (members cannot create tasks, only update them). If you're seeing permission errors, check that assigneeId in the task matches the logged-in user's uid exactly.
No members showing in the assign dropdown
→ The dropdown only lists users with role === "MEMBER" in RTDB. Make sure the member signed up with the Member role — check the Firebase Console under Realtime Database → users → {uid} → role.
Tasks not updating in real time
→ Confirm you haven't removed the onValue listeners or added return statements before the cleanup function in useEffect.