Skip to content

Commit de38474

Browse files
committed
feat(auth): land shared auth-guard helper and secure phase 1 admin surface
1 parent ce3a3d4 commit de38474

6 files changed

Lines changed: 163 additions & 42 deletions

File tree

‎apps/codebility/app/home/(dashboard)/actions.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createClientServerComponent } from "@/utils/supabase/server";
55

66

77
import { z } from "zod";
8+
import { requireSelfOrRole } from "@/lib/server/auth-guard";
89

910
export const updateUserSchedule = async (
1011
{
@@ -16,7 +17,7 @@ export const updateUserSchedule = async (
1617
},
1718
codevId: string,
1819
) => {
19-
const supabase = await createClientServerComponent();
20+
const { supabase } = await requireSelfOrRole(codevId, "dashboard");
2021
const { error } = await supabase
2122
.from("codev")
2223
.update({
@@ -29,7 +30,7 @@ export const updateUserSchedule = async (
2930
};
3031

3132
export const startUserTimer = async (codevId: string) => {
32-
const supabase = await createClientServerComponent();
33+
const { supabase } = await requireSelfOrRole(codevId, "dashboard");
3334
const currentDate = new Date();
3435

3536
const { error } = await supabase
@@ -43,7 +44,7 @@ export const startUserTimer = async (codevId: string) => {
4344
};
4445

4546
const stopUserTimer = async (codevId: string) => {
46-
const supabase = await createClientServerComponent();
47+
const { supabase } = await requireSelfOrRole(codevId, "dashboard");
4748

4849
const { error } = await supabase
4950
.from("codev")
@@ -55,9 +56,9 @@ const stopUserTimer = async (codevId: string) => {
5556
};
5657

5758
export const logUserTime = async (formData: FormData) => {
58-
const supabase = await createClientServerComponent();
59-
6059
const codevId = formData.get("codevId") as string;
60+
if (!codevId) throw new Error("codevId is required");
61+
const { supabase } = await requireSelfOrRole(codevId, "dashboard");
6162
const taskId = formData.get("taskId");
6263

6364
const { data: codevData, error: fetchingCodevError } = await supabase
@@ -93,7 +94,7 @@ export const logUserTime = async (formData: FormData) => {
9394
};
9495

9596
export const updateUserTaskOnHand = async (codevId: string, taskId: string) => {
96-
const supabase = await createClientServerComponent();
97+
const { supabase } = await requireSelfOrRole(codevId, "dashboard");
9798

9899
const formData = new FormData();
99100
formData.append("codevId", codevId);
@@ -121,7 +122,7 @@ export const updateUserAvailabilityStatus = async ({
121122
}
122123
) => {
123124
try {
124-
const supabase = await createClientServerComponent();
125+
const { supabase } = await requireSelfOrRole(userId, "dashboard");
125126

126127
const { error } = await supabase
127128
.from("codev")

‎apps/codebility/app/home/in-house/actions.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { Codev } from "@/types/home/codev";
55
import { createClientServerComponent } from "@/utils/supabase/server";
6+
import { requireRole } from "@/lib/server/auth-guard";
67
import { revalidatePath } from "next/cache";
78

89
/**
@@ -14,7 +15,7 @@ export const updateCodev = async (
1415
value: any,
1516
{ codevId }: { codevId: string },
1617
) => {
17-
const supabase = await createClientServerComponent();
18+
const { supabase } = await requireRole("inhouse");
1819

1920
// Define keys and their corresponding target tables
2021
const keys = {
@@ -134,7 +135,7 @@ export const updateNdaUrls = async (
134135
signatureUrl: string,
135136
documentUrl: string
136137
) => {
137-
const supabase = await createClientServerComponent();
138+
const { supabase } = await requireRole("inhouse");
138139

139140
try {
140141
const { error } = await supabase
@@ -170,7 +171,7 @@ export const sendNdaEmailAction = async (
170171
subject: string,
171172
message: string
172173
) => {
173-
const supabase = await createClientServerComponent();
174+
const { supabase } = await requireRole("inhouse");
174175

175176
try {
176177
// Generate unique token with 7-day expiration
@@ -243,7 +244,7 @@ export const sendNdaEmailAction = async (
243244
* Handles foreign key relationships and file cleanup
244245
*/
245246
export const deleteCodevAction = async (codevId: string) => {
246-
const supabase = await createClientServerComponent();
247+
const { supabase } = await requireRole("inhouse");
247248

248249
try {
249250
// Get codev data first for cleanup

‎apps/codebility/app/home/my-team/actions.ts‎

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use server";
22

33
import { createClientServerComponent } from "@/utils/supabase/server";
4+
import { requireUser } from "@/lib/server/auth-guard";
45
import { revalidatePath } from "next/cache";
56

67
// ============================================================================
@@ -12,20 +13,14 @@ import { revalidatePath } from "next/cache";
1213
* Only team leads can create checklist items
1314
*/
1415
export async function createChecklistItem(formData: FormData) {
15-
const supabase = await createClientServerComponent();
16-
1716
try {
18-
// Get current user
19-
const { data: { user }, error: authError } = await supabase.auth.getUser();
20-
if (authError || !user) {
21-
return { success: false, error: "Authentication required" };
22-
}
17+
const { user, supabase } = await requireUser();
2318

2419
// Get user's codev profile
2520
const { data: codevProfile, error: codevError } = await supabase
2621
.from("codev")
2722
.select("id")
28-
.eq("email_address", user.email)
23+
.eq("id", user.id)
2924
.single();
3025

3126
if (codevError || !codevProfile) {
@@ -135,20 +130,14 @@ export async function updateChecklistItem(
135130
due_date?: string;
136131
}
137132
) {
138-
const supabase = await createClientServerComponent();
139-
140133
try {
141-
// Get current user
142-
const { data: { user }, error: authError } = await supabase.auth.getUser();
143-
if (authError || !user) {
144-
return { success: false, error: "Authentication required" };
145-
}
134+
const { user, supabase } = await requireUser();
146135

147136
// Get user's codev profile
148137
const { data: codevProfile, error: codevError } = await supabase
149138
.from("codev")
150139
.select("id")
151-
.eq("email_address", user.email)
140+
.eq("id", user.id)
152141
.single();
153142

154143
if (codevError || !codevProfile) {
@@ -217,20 +206,14 @@ export async function updateChecklistItem(
217206
* Only team leads can delete checklist items
218207
*/
219208
export async function deleteChecklistItem(itemId: string) {
220-
const supabase = await createClientServerComponent();
221-
222209
try {
223-
// Get current user
224-
const { data: { user }, error: authError } = await supabase.auth.getUser();
225-
if (authError || !user) {
226-
return { success: false, error: "Authentication required" };
227-
}
210+
const { user, supabase } = await requireUser();
228211

229212
// Get user's codev profile
230213
const { data: codevProfile, error: codevError } = await supabase
231214
.from("codev")
232215
.select("id")
233-
.eq("email_address", user.email)
216+
.eq("id", user.id)
234217
.single();
235218

236219
if (codevError || !codevProfile) {

‎apps/codebility/app/home/promote-modal/actions.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use server";
22

33
import { createClientServerComponent } from "@/utils/supabase/server";
4+
import { requireRole } from "@/lib/server/auth-guard";
45
import { FeatureModal } from "./type";
56

67
export async function fetchActiveModal(): Promise<FeatureModal | null> {
@@ -55,7 +56,7 @@ export async function upsertActiveModal(
5556
data: Partial<FeatureModal>
5657
): Promise<{ error: string | null }> {
5758
try {
58-
const supabase = await createClientServerComponent();
59+
const { supabase } = await requireRole("settings");
5960
const { error } = await supabase.from("feature_modals").upsert({
6061
...data,
6162
updated_at: new Date().toISOString(),
@@ -72,7 +73,7 @@ export async function createModal(): Promise<{
7273
error: string | null;
7374
}> {
7475
try {
75-
const supabase = await createClientServerComponent();
76+
const { supabase } = await requireRole("settings");
7677
const { data, error } = await supabase
7778
.from("feature_modals")
7879
.insert({
@@ -102,7 +103,7 @@ export async function uploadModalImage(
102103
formData: FormData
103104
): Promise<{ url: string | null; error: string | null }> {
104105
try {
105-
const supabase = await createClientServerComponent();
106+
const { supabase } = await requireRole("settings");
106107
const file = formData.get("file") as File;
107108

108109
const ext = file.name.split(".").pop();
@@ -129,7 +130,7 @@ export async function deleteModal(
129130
id: string
130131
): Promise<{ error: string | null }> {
131132
try {
132-
const supabase = await createClientServerComponent();
133+
const { supabase } = await requireRole("settings");
133134
const { error } = await supabase
134135
.from("feature_modals")
135136
.delete()
@@ -146,7 +147,7 @@ export async function toggleModalActive(
146147
is_active: boolean
147148
): Promise<{ error: string | null }> {
148149
try {
149-
const supabase = await createClientServerComponent();
150+
const { supabase } = await requireRole("settings");
150151

151152
const { error } = await supabase
152153
.from("feature_modals")

‎apps/codebility/app/home/ticket-support/actions.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use server";
22

33
import { createClientServerComponent } from "@/utils/supabase/server";
4+
import { requireUser } from "@/lib/server/auth-guard";
45

56
interface SubmitTicketData {
67
userId: string | null;
@@ -17,14 +18,14 @@ interface SubmitTicketData {
1718
}
1819

1920
export async function submitTicket(data: SubmitTicketData) {
20-
const supabase = await createClientServerComponent();
21+
const { user, supabase } = await requireUser();
2122

2223
// Generate a ticket number: TIC-XXXXX
2324
const ticketNumber = `TIC-${Math.floor(10000 + Math.random() * 90000)}`;
2425

2526
const { error } = await supabase.from("ticket_support").insert({
2627
ticket_number: ticketNumber,
27-
user_id: data.userId || null,
28+
user_id: user.id,
2829
full_name: data.fullName,
2930
email: data.email || null,
3031
role_position: data.rolePosition || null,
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { createClientServerComponent } from "@/utils/supabase/server";
2+
3+
export type RolePermissionKey =
4+
| "dashboard"
5+
| "kanban"
6+
| "time_tracker"
7+
| "interns"
8+
| "applicants"
9+
| "inhouse"
10+
| "clients"
11+
| "projects"
12+
| "settings"
13+
| "orgchart"
14+
| "resume";
15+
16+
/**
17+
* Ensures the caller is authenticated.
18+
* @returns The authenticated user object and a supabase instance.
19+
* @throws Error with message "Unauthorized" if missing.
20+
*/
21+
export async function requireUser() {
22+
const supabase = await createClientServerComponent();
23+
const { data: { user }, error } = await supabase.auth.getUser();
24+
25+
if (error || !user) {
26+
throw new Error("Unauthorized");
27+
}
28+
return { user, supabase };
29+
}
30+
31+
/**
32+
* Ensures the caller has the required role permission.
33+
* Role permission corresponds to columns in the 'roles' table.
34+
*/
35+
export async function requireRole(permissionKey: RolePermissionKey) {
36+
const { user, supabase } = await requireUser();
37+
38+
const { data: codevData, error: codevError } = await supabase
39+
.from("codev")
40+
.select("role_id")
41+
.eq("id", user.id)
42+
.single();
43+
44+
if (codevError || !codevData?.role_id) {
45+
throw new Error("Forbidden");
46+
}
47+
48+
// Admins bypass
49+
if (codevData.role_id === 1) {
50+
return { user, supabase, roleId: codevData.role_id };
51+
}
52+
53+
const { data: roleData, error: roleError } = await supabase
54+
.from("roles")
55+
.select(permissionKey)
56+
.eq("id", codevData.role_id)
57+
.single();
58+
59+
if (roleError || !roleData || !(roleData as any)[permissionKey]) {
60+
throw new Error("Forbidden");
61+
}
62+
63+
return { user, supabase, roleId: codevData.role_id };
64+
}
65+
66+
/**
67+
* Ensures the caller is a member of the specified project.
68+
*/
69+
export async function requireProjectMember(projectId: string) {
70+
const { user, supabase } = await requireUser();
71+
72+
const { data: codevData } = await supabase
73+
.from("codev")
74+
.select("role_id")
75+
.eq("id", user.id)
76+
.single();
77+
78+
// Admins bypass
79+
if (codevData?.role_id === 1) {
80+
return { user, supabase, roleId: codevData.role_id };
81+
}
82+
83+
const { data: memberData, error: memberError } = await supabase
84+
.from("project_members")
85+
.select("id")
86+
.eq("project_id", projectId)
87+
.eq("codev_id", user.id)
88+
.maybeSingle();
89+
90+
if (memberError || !memberData) {
91+
throw new Error("Forbidden");
92+
}
93+
94+
return { user, supabase, roleId: codevData?.role_id };
95+
}
96+
97+
/**
98+
* Helper specifically for self-mutating actions.
99+
* Enforces that the caller is either mutating their own data, or has an optional fallback role (e.g., admin).
100+
*/
101+
export async function requireSelfOrRole(targetUserId: string, fallbackRoleKey?: RolePermissionKey) {
102+
const { user, supabase } = await requireUser();
103+
104+
if (user.id === targetUserId) {
105+
return { user, supabase };
106+
}
107+
108+
// If not self, verify fallback role if provided
109+
if (fallbackRoleKey) {
110+
const { data: codevData } = await supabase
111+
.from("codev")
112+
.select("role_id")
113+
.eq("id", user.id)
114+
.single();
115+
116+
if (codevData?.role_id === 1) {
117+
return { user, supabase, roleId: codevData.role_id };
118+
}
119+
120+
if (codevData?.role_id) {
121+
const { data: roleData } = await supabase
122+
.from("roles")
123+
.select(fallbackRoleKey)
124+
.eq("id", codevData.role_id)
125+
.single();
126+
127+
if (roleData && (roleData as any)[fallbackRoleKey]) {
128+
return { user, supabase, roleId: codevData.role_id };
129+
}
130+
}
131+
}
132+
133+
throw new Error("Forbidden");
134+
}

0 commit comments

Comments
 (0)