Skip to content

Commit 88d3281

Browse files
committed
Add admin whitelist management feature
Moves admin access from hardcoded-only to Firestore-backed: - backend/src/authAdmin.ts: new authenticateAdmin middleware that checks both hardcoded superadmins AND Firestore adminWhitelist collection - GET /api/is-admin: async admin check for frontend - GET/POST/DELETE /api/admin/whitelist: CRUD endpoints for Firestore whitelist - App.tsx: async isAdminUser state via /api/is-admin so Firestore-added admins can access /admin route without a redeploy - AdminPage.tsx: new Admin Management tab with add/remove + confirmation
1 parent e01f91e commit 88d3281

4 files changed

Lines changed: 409 additions & 1 deletion

File tree

backend/src/app.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import axios from 'axios';
3232
import { db, FieldValue, FieldPath } from './firebase-config';
3333
import { Faq } from './firebase-config/types';
3434
import authenticate from './auth';
35+
import authenticateAdmin, { isAdminEmail } from './authAdmin';
3536
import { admins } from '../../frontend/src/constants/HomeConsts';
3637

3738
// Imports for email sending
@@ -53,6 +54,7 @@ const pendingBuildingsCollection = db.collection('pendingBuildings');
5354
const contactQuestionsCollection = db.collection('contactQuestions');
5455
const blogPostCollection = db.collection('blogposts');
5556
const folderCollection = db.collection('folders');
57+
const adminWhitelistCollection = db.collection('adminWhitelist');
5658
const travelTimesCollection = db.collection('travelTimes');
5759

5860
// Middleware setup
@@ -3022,6 +3024,115 @@ app.get('/api/folders/:folderId/apartments', authenticate, async (req, res) => {
30223024
}
30233025
});
30243026

3027+
/**
3028+
* Is Admin – Returns whether the authenticated user has admin privileges.
3029+
*
3030+
* @route GET /api/is-admin
3031+
*
3032+
* @status
3033+
* - 200: { isAdmin: boolean }
3034+
* - 401: Not authenticated
3035+
*/
3036+
app.get('/api/is-admin', authenticate, async (req, res) => {
3037+
try {
3038+
if (!req.user?.email) return res.status(200).json({ isAdmin: false });
3039+
const adminStatus = await isAdminEmail(req.user.email);
3040+
return res.status(200).json({ isAdmin: adminStatus });
3041+
} catch (err) {
3042+
console.error(err);
3043+
return res.status(200).json({ isAdmin: false });
3044+
}
3045+
});
3046+
3047+
/**
3048+
* Get Admin Whitelist – Returns all emails in the Firestore adminWhitelist plus hardcoded superadmins.
3049+
*
3050+
* @route GET /api/admin/whitelist
3051+
*
3052+
* @status
3053+
* - 200: { superadmins: string[], whitelist: { id: string, email: string }[] }
3054+
* - 403: Not an admin
3055+
*/
3056+
app.get('/api/admin/whitelist', authenticateAdmin, async (req, res) => {
3057+
try {
3058+
const snapshot = await adminWhitelistCollection.orderBy('addedAt', 'asc').get();
3059+
const whitelist = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
3060+
return res.status(200).json({ superadmins: admins, whitelist });
3061+
} catch (err) {
3062+
console.error(err);
3063+
return res.status(500).send('Error fetching admin whitelist');
3064+
}
3065+
});
3066+
3067+
/**
3068+
* Add Admin – Adds an email to the Firestore adminWhitelist.
3069+
*
3070+
* @route POST /api/admin/whitelist
3071+
*
3072+
* @input {string} req.body.email – The Cornell email to add.
3073+
*
3074+
* @status
3075+
* - 201: Admin added successfully
3076+
* - 400: Missing or invalid email
3077+
* - 409: Email already in whitelist or superadmin list
3078+
* - 403: Not an admin
3079+
*/
3080+
app.post('/api/admin/whitelist', authenticateAdmin, async (req, res) => {
3081+
try {
3082+
const { email } = req.body;
3083+
if (!email || typeof email !== 'string' || !email.endsWith('@cornell.edu')) {
3084+
return res.status(400).send('Valid Cornell email required');
3085+
}
3086+
if (admins.includes(email)) {
3087+
return res.status(409).send('Email is already a superadmin');
3088+
}
3089+
const existing = await adminWhitelistCollection.where('email', '==', email).limit(1).get();
3090+
if (!existing.empty) {
3091+
return res.status(409).send('Email is already in the whitelist');
3092+
}
3093+
const docRef = await adminWhitelistCollection.add({
3094+
email,
3095+
addedAt: new Date(),
3096+
addedBy: req.user!.email,
3097+
});
3098+
return res.status(201).json({ id: docRef.id, email });
3099+
} catch (err) {
3100+
console.error(err);
3101+
return res.status(500).send('Error adding admin');
3102+
}
3103+
});
3104+
3105+
/**
3106+
* Remove Admin – Removes an email from the Firestore adminWhitelist.
3107+
*
3108+
* @route DELETE /api/admin/whitelist/:email
3109+
*
3110+
* @input {string} req.params.email – URL-encoded email to remove.
3111+
*
3112+
* @status
3113+
* - 200: Admin removed
3114+
* - 400: Email is a superadmin (cannot remove via UI)
3115+
* - 404: Email not found in whitelist
3116+
* - 403: Not an admin
3117+
*/
3118+
app.delete('/api/admin/whitelist/:email', authenticateAdmin, async (req, res) => {
3119+
try {
3120+
const email = decodeURIComponent(req.params.email);
3121+
if (admins.includes(email)) {
3122+
return res.status(400).send('Cannot remove a superadmin via this endpoint');
3123+
}
3124+
const snapshot = await adminWhitelistCollection.where('email', '==', email).limit(1).get();
3125+
if (snapshot.empty) {
3126+
return res.status(404).send('Email not found in whitelist');
3127+
}
3128+
await snapshot.docs[0].ref.delete();
3129+
return res.status(200).send('Admin removed successfully');
3130+
} catch (err) {
3131+
console.error(err);
3132+
return res.status(500).send('Error removing admin');
3133+
}
3134+
});
3135+
30253136
/**
30263137
* Init Collections – Ensures required Firestore collections exist with correct schema.
30273138
* Idempotent: safe to run multiple times, never overwrites existing documents.

backend/src/authAdmin.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { RequestHandler } from 'express';
2+
import { auth , db } from './firebase-config';
3+
import { admins } from '../../frontend/src/constants/HomeConsts';
4+
5+
const adminWhitelistCollection = db.collection('adminWhitelist');
6+
7+
/**
8+
* Checks whether an email is a recognized admin.
9+
*
10+
* @remarks
11+
* Returns true if the email is in the hardcoded `admins` array (superadmins)
12+
* OR in the Firestore `adminWhitelist` collection (dynamically added admins).
13+
*
14+
* @param {string} email – The email address to check.
15+
* @returns {Promise<boolean>} – Whether the email has admin privileges.
16+
*/
17+
export const isAdminEmail = async (email: string): Promise<boolean> => {
18+
if (admins.includes(email)) return true;
19+
const snapshot = await adminWhitelistCollection.where('email', '==', email).limit(1).get();
20+
return !snapshot.empty;
21+
};
22+
23+
/**
24+
* Middleware to authenticate admin API requests.
25+
*
26+
* @remarks
27+
* Extends the base `authenticate` middleware by additionally verifying that the
28+
* requesting user is a recognized admin (either hardcoded superadmin or in
29+
* Firestore `adminWhitelist`).
30+
*
31+
* @returns 401 if token is invalid, 403 if user is not an admin, otherwise calls next.
32+
*/
33+
const authenticateAdmin: RequestHandler = async (req, res, next) => {
34+
try {
35+
const { authorization } = req.headers;
36+
37+
if (!authorization) {
38+
res.status(401).send({ error: 'Header not found' });
39+
return;
40+
}
41+
42+
const [bearer, token] = authorization.split(' ');
43+
44+
if (bearer !== 'Bearer') {
45+
res.status(401).send({ error: 'Invalid token syntax' });
46+
return;
47+
}
48+
49+
const user = await auth.verifyIdToken(token);
50+
51+
if (!user.email?.endsWith('@cornell.edu')) {
52+
res.status(401).send({ error: 'Invalid domain' });
53+
return;
54+
}
55+
56+
const adminStatus = await isAdminEmail(user.email);
57+
if (!adminStatus) {
58+
res.status(403).send({ error: 'Not authorized' });
59+
return;
60+
}
61+
62+
req.user = user;
63+
next();
64+
} catch (e) {
65+
res.status(401).send({ error: 'Authentication Error' });
66+
}
67+
};
68+
69+
export default authenticateAdmin;

frontend/src/App.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,36 @@ hotjar.initialize(HJID, HJSV);
9999

100100
const App = (): ReactElement => {
101101
const [user, setUser] = useState<firebase.User | null>(null);
102+
const [isAdminUser, setIsAdminUser] = useState<boolean>(false);
102103
const { pathname } = useLocation();
104+
103105
useEffect(() => {
104106
const setData = async () => {
105107
await axios.post('/api/set-data');
106108
};
107109
setData();
108110
}, []);
109111

112+
// Check admin status whenever user changes — checks both hardcoded list and Firestore whitelist
113+
useEffect(() => {
114+
const checkAdmin = async () => {
115+
if (!user) {
116+
setIsAdminUser(false);
117+
return;
118+
}
119+
try {
120+
const token = await user.getIdToken();
121+
const response = await axios.get('/api/is-admin', {
122+
headers: { Authorization: `Bearer ${token}` },
123+
});
124+
setIsAdminUser(response.data.isAdmin === true);
125+
} catch {
126+
setIsAdminUser(false);
127+
}
128+
};
129+
checkAdmin();
130+
}, [user]);
131+
110132
return (
111133
<ThemeProvider theme={theme}>
112134
<ModalProvider user={user} setUser={setUser}>
@@ -155,7 +177,7 @@ const App = (): ReactElement => {
155177
path="/search"
156178
component={() => <SearchResultsPage user={user} setUser={setUser} />}
157179
/>
158-
{isAdmin(user) && <Route exact path="/admin" component={AdminPage} />}
180+
{(isAdmin(user) || isAdminUser) && <Route exact path="/admin" component={AdminPage} />}
159181
</Switch>
160182
</div>
161183
<ContactModal user={user} />

0 commit comments

Comments
 (0)