Skip to content

Commit e0e5566

Browse files
feat(auth): added self registration request flow w/ admin review & approval
1 parent 3704688 commit e0e5566

11 files changed

Lines changed: 458 additions & 10 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { db, event } from "#src/utils";
2+
3+
export const getAccessRequests = async () => {
4+
await db.connect();
5+
const requesterType = (await db.query({
6+
text: `SELECT type FROM users WHERE id=$1`,
7+
values: [event.claims.sub],
8+
})).rows?.[0]?.type;
9+
if (requesterType !== 'admin') {
10+
await db.clean();
11+
return { status: 'error', message: 'Admin access required.' };
12+
}
13+
14+
const requests = (await db.query({
15+
text: `SELECT "id", "name", "email", "status", "created_at" FROM "access_requests" WHERE "status"='pending' ORDER BY "created_at" ASC`,
16+
})).rows;
17+
await db.clean();
18+
19+
return { status: 'success', requests };
20+
};

apps/backend/routes/auth/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ export * from './syncFromRemoteCsv'
2424
export * from './getBlockerSummary'
2525
export * from './flagBlockerSummary'
2626
export * from './getBedrockModels'
27-
export * from './getSystemStats'
27+
export * from './getSystemStats'
28+
export * from './getAccessRequests'
29+
export * from './reviewAccessRequest'
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { db, event, isStaging, sendEmail } from "#src/utils";
2+
3+
//
4+
// Admin-only: approve or deny an access request. Approving creates an invite,
5+
// which is what authorizes the email's SSO sign-in (see ensureSsoUser).
6+
//
7+
8+
export const reviewAccessRequest = async () => {
9+
const { id, action } = event.body;
10+
if (!['approve', 'deny'].includes(action)) {
11+
return { status: 'error', message: 'Invalid action.' };
12+
}
13+
14+
await db.connect();
15+
const requesterType = (await db.query({
16+
text: `SELECT type FROM users WHERE id=$1`,
17+
values: [event.claims.sub],
18+
})).rows?.[0]?.type;
19+
if (requesterType !== 'admin') {
20+
await db.clean();
21+
return { status: 'error', message: 'Admin access required.' };
22+
}
23+
24+
const request = (await db.query({
25+
text: `SELECT "id", "name", "email", "status" FROM "access_requests" WHERE "id"=$1`,
26+
values: [id],
27+
})).rows?.[0];
28+
if (!request) {
29+
await db.clean();
30+
return { status: 'error', message: 'Access request not found.' };
31+
}
32+
if (request.status !== 'pending') {
33+
await db.clean();
34+
return { status: 'error', message: 'This request has already been reviewed.' };
35+
}
36+
37+
if (action === 'deny') {
38+
await db.query({
39+
text: `UPDATE "access_requests" SET "status"='denied', "reviewed_by"=$2, "reviewed_at"=now(), "updated_at"=now() WHERE "id"=$1`,
40+
values: [id, event.claims.sub],
41+
});
42+
await db.clean();
43+
return { status: 'success', message: 'Request denied.' };
44+
}
45+
46+
// approve: create the invite unless one already exists for this email
47+
const inviteExists = (await db.query({
48+
text: `SELECT id FROM invites WHERE lower(email)=lower($1)`,
49+
values: [request.email],
50+
})).rows?.[0]?.id;
51+
if (!inviteExists) {
52+
await db.query({
53+
text: `INSERT INTO "invites" ("user_id", "email", "name") VALUES ($1, $2, $3)`,
54+
values: [event.claims.sub, request.email, request.name],
55+
});
56+
}
57+
await db.query({
58+
text: `UPDATE "access_requests" SET "status"='approved', "reviewed_by"=$2, "reviewed_at"=now(), "updated_at"=now() WHERE "id"=$1`,
59+
values: [id, event.claims.sub],
60+
});
61+
await db.clean();
62+
63+
// notify the requester; the approval already succeeded, so an email failure
64+
// shouldn't surface as an error to the reviewing admin
65+
try {
66+
await sendEmail({
67+
to: request.email,
68+
subject: `Your Equalify access request was approved`,
69+
body: `<tr>
70+
<td style="padding:24px 24px 8px 24px; font-size:16px; line-height:1.5; color:#334155;">
71+
Hello,
72+
</td>
73+
</tr>
74+
<tr>
75+
<td style="padding:0 24px 24px 24px; font-size:16px; line-height:1.5; color:#334155;">
76+
Your request to access Equalify has been approved. Sign in with your SSO account below to get started:
77+
</td>
78+
</tr>
79+
80+
<!-- Button -->
81+
<tr>
82+
<td align="left" style="padding:0 24px 24px 24px;">
83+
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
84+
<tr>
85+
<td align="center" bgcolor="#186121" style="border-radius:6px;">
86+
<a href="${process.env.APP_URL ?? `https://app${isStaging ? '-staging' : ''}.equalify.uic.edu`}/login"
87+
style="display:inline-block; padding:12px 24px; font-size:16px; font-weight:600; color:#ffffff; text-decoration:none; border-radius:6px; background-color:#186121;">
88+
Sign In
89+
</a>
90+
</td>
91+
</tr>
92+
</table>
93+
</td>
94+
</tr>`
95+
});
96+
} catch (emailError) {
97+
console.error('Approval email failed to send:', request.email, emailError);
98+
}
99+
100+
return { status: 'success', message: 'Request approved — invite created.' };
101+
};

apps/backend/routes/public/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export * from './getAuditSummary'
99
export * from './getAuditSummaryFast'
1010
export * from './putMetrics'
1111
export * from './getBlockerSummary'
12-
export * from './flagBlockerSummary'
12+
export * from './flagBlockerSummary'
13+
export * from './requestAccess'
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { db, event } from "#src/utils";
2+
3+
//
4+
// Public endpoint: lets someone with an SSO account but no Equalify access
5+
// request access. Admins review requests on the Account > Requests tab.
6+
//
7+
8+
export const requestAccess = async () => {
9+
const email = String(event.body?.email ?? '').trim().toLowerCase();
10+
const name = String(event.body?.name ?? '').trim();
11+
12+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
13+
return { status: 'error', message: 'Please enter a valid email address.' };
14+
}
15+
16+
if (process.env.SSO_ENABLED && process.env.SSO_EMAIL_DOMAINS) {
17+
const ssoEmailDomains = JSON.parse(process.env.SSO_EMAIL_DOMAINS);
18+
if (!ssoEmailDomains.includes(email.split('@')[1])) {
19+
return { status: 'error', message: `Please use your institutional email address (${ssoEmailDomains.map((domain: string) => `@${domain}`).join(', ')}).` };
20+
}
21+
}
22+
23+
await db.connect();
24+
25+
const userExists = (await db.query({
26+
text: `SELECT id FROM users WHERE lower(email)=$1`,
27+
values: [email],
28+
})).rows?.[0]?.id;
29+
if (userExists) {
30+
await db.clean();
31+
return { status: 'error', message: 'An account already exists for this email address — try signing in.' };
32+
}
33+
34+
const inviteExists = (await db.query({
35+
text: `SELECT id FROM invites WHERE lower(email)=$1`,
36+
values: [email],
37+
})).rows?.[0]?.id;
38+
if (inviteExists) {
39+
await db.clean();
40+
return { status: 'success', message: 'You already have an invite — sign in with SSO to activate your account.' };
41+
}
42+
43+
const pendingExists = (await db.query({
44+
text: `SELECT id FROM access_requests WHERE lower(email)=$1 AND status='pending'`,
45+
values: [email],
46+
})).rows?.[0]?.id;
47+
if (pendingExists) {
48+
await db.clean();
49+
return { status: 'success', message: 'Your access request is already pending review — an administrator will get to it soon.' };
50+
}
51+
52+
await db.query({
53+
text: `INSERT INTO "access_requests" ("email", "name") VALUES ($1, $2)`,
54+
values: [email, name || null],
55+
});
56+
await db.clean();
57+
58+
return { status: 'success', message: 'Request submitted! An administrator will review it shortly.' };
59+
};

apps/backend/utils/ensureSsoUser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const ensureSsoUser = async (claims: SsoClaims) => {
4141
// Create user if doesn't exist
4242
if (existingUser.rows.length === 0) {
4343
const inviteId = (await db.query({
44-
text: `SELECT id FROM invites WHERE email=$1`,
44+
text: `SELECT id FROM invites WHERE lower(email)=lower($1)`,
4545
values: [email],
4646
}))?.rows?.[0]?.id;
4747
if (inviteId) {

0 commit comments

Comments
 (0)