Skip to content

Commit a2d7b5c

Browse files
committed
feat: implement admin email whitelist and update user role handling
1 parent cdb1d06 commit a2d7b5c

File tree

4 files changed

+53
-8
lines changed

4 files changed

+53
-8
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Migration 007: Replace users.role column with a dedicated admin_emails whitelist table.
2+
--
3+
-- Why: Whitelist table is easier to audit and manage (INSERT/DELETE rows)
4+
-- without touching the users table. Role is now derived via JOIN at query time.
5+
6+
-- 1. Create the whitelist table
7+
CREATE TABLE sg_reports_survey.admin_emails (
8+
email text NOT NULL PRIMARY KEY
9+
);
10+
11+
COMMENT ON TABLE sg_reports_survey.admin_emails IS
12+
'Whitelist of email addresses that receive admin privileges. ';
13+
14+
-- 2. Seed: migrate all current admins from the users table
15+
INSERT INTO sg_reports_survey.admin_emails (email)
16+
SELECT email
17+
FROM sg_reports_survey.users
18+
WHERE role = 'admin'
19+
ON CONFLICT DO NOTHING;
20+
21+
-- 3. Remove the now-redundant role column (and its check constraint) from users
22+
ALTER TABLE sg_reports_survey.users
23+
DROP CONSTRAINT IF EXISTS users_role_check,
24+
DROP COLUMN IF EXISTS role;

src/app/analysis/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Footer } from "@/components/Footer";
22
import { Header } from "@/components/Header";
33
import { EntityTableExport } from "@/components/EntityTableExport";
44
import { getCurrentUser } from "@/lib/auth";
5+
import { notAdminSQL } from "@/lib/config";
56
import { query } from "@/lib/db";
67
import { BarChart3, CheckCircle2, Circle, Clock, FileText, Users } from "lucide-react";
78
import { redirect } from "next/navigation";
@@ -79,8 +80,9 @@ async function getAnalysisData() {
7980
// User counts per entity
8081
query<UserCountRow>(
8182
`SELECT entity, COUNT(*) AS user_count
82-
FROM ${DB_SCHEMA}.users
83-
WHERE entity IS NOT NULL AND role != 'admin'
83+
FROM ${DB_SCHEMA}.users u
84+
WHERE entity IS NOT NULL
85+
AND ${notAdminSQL()}
8486
GROUP BY entity`,
8587
),
8688
// Per-entity progress: suggested vs confirmed vs responded
@@ -148,7 +150,7 @@ async function getAnalysisData() {
148150
ORDER BY confirmed_reports DESC, suggested_reports DESC, entity`,
149151
),
150152
query<TotalUsersRow>(
151-
`SELECT COUNT(*) AS total_users FROM ${DB_SCHEMA}.users WHERE role != 'admin'`,
153+
`SELECT COUNT(*) AS total_users FROM ${DB_SCHEMA}.users u WHERE ${notAdminSQL()}`,
152154
),
153155
query<ActiveUsersRow>(
154156
`SELECT COUNT(DISTINCT responded_by_user_id) AS active_users FROM ${DB_SCHEMA}.survey_responses`,

src/lib/auth.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cache } from "react";
12
import { cookies } from "next/headers";
23
import { randomBytes, createHmac, timingSafeEqual } from "crypto";
34
import { query } from "./db";
@@ -117,18 +118,26 @@ export interface CurrentUser {
117118
role: "user" | "admin";
118119
}
119120

120-
export async function getCurrentUser(): Promise<CurrentUser | null> {
121+
// Cached per-request: React deduplicates repeated calls within one render tree
122+
// so the DB is hit at most once per server request regardless of how many
123+
// server components or route handlers call getCurrentUser().
124+
export const getCurrentUser = cache(async (): Promise<CurrentUser | null> => {
121125
const session = await getSession();
122126
if (!session) return null;
123-
const rows = await query<{ id: string; email: string; entity: string | null; role: string }>(
124-
`SELECT id, email, entity, role FROM ${tables.users} WHERE id = $1`,
127+
// Single query: LEFT JOIN admin_emails derives the role without a separate lookup
128+
const rows = await query<{ id: string; email: string; entity: string | null; role: "admin" | "user" }>(
129+
`SELECT u.id, u.email, u.entity,
130+
CASE WHEN ae.email IS NOT NULL THEN 'admin' ELSE 'user' END AS role
131+
FROM ${tables.users} u
132+
LEFT JOIN ${tables.admin_emails} ae ON ae.email = u.email
133+
WHERE u.id = $1`,
125134
[session.userId]
126135
);
127136
if (!rows[0]) return null;
128137
return {
129138
id: rows[0].id,
130139
email: rows[0].email,
131140
entity: rows[0].entity,
132-
role: rows[0].role === "admin" ? "admin" : "user",
141+
role: rows[0].role,
133142
};
134-
}
143+
});

src/lib/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,14 @@ export const tables = {
66
users: `${DB_SCHEMA}.users`,
77
magic_tokens: `${DB_SCHEMA}.magic_tokens`,
88
allowed_domains: `${DB_SCHEMA}.allowed_domains`,
9+
admin_emails: `${DB_SCHEMA}.admin_emails`,
910
} as const;
11+
12+
/**
13+
* SQL fragment that excludes admin users from a query.
14+
* @param alias - the table alias for the users table (default: "u")
15+
* @example
16+
* `SELECT * FROM ${tables.users} u WHERE ${notAdminSQL()}`
17+
*/
18+
export const notAdminSQL = (alias = "u") =>
19+
`NOT EXISTS (SELECT 1 FROM ${tables.admin_emails} ae WHERE ae.email = ${alias}.email)`;

0 commit comments

Comments
 (0)