Skip to content

Commit 6d579ff

Browse files
authored
Merge pull request #37 from hack4impact/feature/7-roles
feat(auth): roles and admin dashboard protection
2 parents b606166 + d7f06b3 commit 6d579ff

10 files changed

Lines changed: 226 additions & 63 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ drizzle/meta/
5757
.supabase/
5858

5959

60-
.agents/
60+
.agents/
61+
.playwright-mcp/
62+
.mcp.json

app/dashboard/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default async function DashboardPage() {
2+
return (
3+
<main className="flex min-h-screen flex-col p-8">
4+
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
5+
<p className="mt-2 text-muted-foreground">
6+
You are admin
7+
</p>
8+
</main>
9+
);
10+
}

app/login/actions.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,66 @@
22

33
import { redirect } from "next/navigation";
44
import { createClient } from "@/utils/supabase/server";
5+
import { loginSchema, signupSchema } from "./schema";
56

6-
export async function login(formData: FormData) {
7-
const supabase = await createClient();
7+
export type ActionState = {
8+
errors: Partial<Record<string, string[]>>;
9+
} | null;
810

9-
const { error } = await supabase.auth.signInWithPassword({
10-
email: formData.get("email") as string,
11-
password: formData.get("password") as string,
11+
export async function login(
12+
_prevState: ActionState,
13+
formData: FormData
14+
): Promise<ActionState> {
15+
const result = loginSchema.safeParse({
16+
email: formData.get("email"),
17+
password: formData.get("password"),
1218
});
1319

20+
if (!result.success) {
21+
return { errors: result.error.flatten().fieldErrors };
22+
}
23+
24+
const supabase = await createClient();
25+
const { error } = await supabase.auth.signInWithPassword(result.data);
26+
1427
if (error) {
15-
redirect(`/login?error=${encodeURIComponent(error.message)}`);
28+
return { errors: { email: [error.message] } };
1629
}
1730

1831
redirect("/");
1932
}
2033

21-
export async function signup(formData: FormData) {
22-
const supabase = await createClient();
34+
export async function signup(
35+
_prevState: ActionState,
36+
formData: FormData
37+
): Promise<ActionState> {
38+
const result = signupSchema.safeParse({
39+
firstName: formData.get("first_name"),
40+
lastName: formData.get("last_name"),
41+
email: formData.get("email"),
42+
password: formData.get("password"),
43+
});
44+
45+
if (!result.success) {
46+
return { errors: result.error.flatten().fieldErrors };
47+
}
2348

49+
const { firstName, lastName, email, password } = result.data;
50+
51+
const supabase = await createClient();
2452
const { error } = await supabase.auth.signUp({
25-
email: formData.get("email") as string,
26-
password: formData.get("password") as string,
53+
email,
54+
password,
55+
options: {
56+
data: { first_name: firstName, last_name: lastName },
57+
},
2758
});
2859

2960
if (error) {
30-
redirect(`/login?error=${encodeURIComponent(error.message)}`);
61+
return { errors: { email: [error.message] } };
3162
}
3263

33-
redirect(
34-
`/login?message=${encodeURIComponent("Check your email to confirm your account.")}`
35-
);
64+
redirect("/login?message=Check+your+email+to+confirm+your+account.");
3665
}
3766

3867
export async function signout() {

app/login/page.tsx

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client";
22

3-
import { login, signup } from "./actions";
3+
import { useActionState, useState, Suspense } from "react";
44
import { useSearchParams } from "next/navigation";
5-
import { Suspense } from "react";
5+
import { login, signup, type ActionState } from "./actions";
66
import {
77
Card,
88
CardContent,
@@ -14,61 +14,117 @@ import { Input } from "@/components/ui/input";
1414
import { Label } from "@/components/ui/label";
1515
import { Button } from "@/components/ui/button";
1616

17+
function FieldError({ errors }: { errors?: string[] }) {
18+
if (!errors?.length) return null;
19+
return <p className="text-sm text-destructive">{errors[0]}</p>;
20+
}
21+
1722
function LoginForm() {
1823
const searchParams = useSearchParams();
1924
const message = searchParams.get("message");
20-
const error = searchParams.get("error");
25+
const [mode, setMode] = useState<"login" | "signup">("login");
26+
27+
const [loginState, loginAction] = useActionState<ActionState, FormData>(
28+
login,
29+
null
30+
);
31+
const [signupState, signupAction] = useActionState<ActionState, FormData>(
32+
signup,
33+
null
34+
);
35+
36+
const state = mode === "login" ? loginState : signupState;
37+
const action = mode === "login" ? loginAction : signupAction;
2138

2239
return (
2340
<div className="flex min-h-screen items-center justify-center p-4">
2441
<Card className="w-full max-w-md">
2542
<CardHeader>
26-
<CardTitle className="text-2xl">Welcome</CardTitle>
43+
<CardTitle className="text-2xl">
44+
{mode === "login" ? "Welcome back" : "Create an account"}
45+
</CardTitle>
2746
<CardDescription>
28-
Sign in to your account or create a new one
47+
{mode === "login"
48+
? "Sign in to your account"
49+
: "Fill in your details to get started"}
2950
</CardDescription>
3051
</CardHeader>
3152
<CardContent>
32-
{error && (
33-
<div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
34-
{error}
35-
</div>
36-
)}
3753
{message && (
3854
<div className="mb-4 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
3955
{message}
4056
</div>
4157
)}
4258

43-
<form className="space-y-4">
44-
<div className="space-y-2">
59+
<form action={action} className="space-y-4" noValidate>
60+
{mode === "signup" && (
61+
<div className="flex gap-3">
62+
<div className="space-y-1 flex-1">
63+
<Label htmlFor="first_name">First name</Label>
64+
<Input
65+
id="first_name"
66+
name="first_name"
67+
type="text"
68+
placeholder="Jane"
69+
/>
70+
<FieldError errors={signupState?.errors?.firstName} />
71+
</div>
72+
<div className="space-y-1 flex-1">
73+
<Label htmlFor="last_name">Last name</Label>
74+
<Input
75+
id="last_name"
76+
name="last_name"
77+
type="text"
78+
placeholder="Doe"
79+
/>
80+
<FieldError errors={signupState?.errors?.lastName} />
81+
</div>
82+
</div>
83+
)}
84+
<div className="space-y-1">
4585
<Label htmlFor="email">Email</Label>
4686
<Input
4787
id="email"
4888
name="email"
4989
type="email"
50-
required
5190
placeholder="you@example.com"
5291
/>
92+
<FieldError errors={state?.errors?.email} />
5393
</div>
54-
<div className="space-y-2">
94+
<div className="space-y-1">
5595
<Label htmlFor="password">Password</Label>
5696
<Input
5797
id="password"
5898
name="password"
5999
type="password"
60-
required
61-
minLength={6}
62100
placeholder="••••••••"
63101
/>
102+
<FieldError errors={state?.errors?.password} />
64103
</div>
65-
<div className="flex gap-2 pt-2">
66-
<Button formAction={login} className="flex-1">
67-
Log in
68-
</Button>
69-
<Button formAction={signup} variant="outline" className="flex-1">
70-
Sign up
71-
</Button>
104+
<div className="flex flex-col gap-2 pt-2">
105+
{mode === "login" ? (
106+
<>
107+
<Button type="submit">Log in</Button>
108+
<Button
109+
type="button"
110+
variant="outline"
111+
onClick={() => setMode("signup")}
112+
>
113+
Create an account
114+
</Button>
115+
</>
116+
) : (
117+
<>
118+
<Button type="submit">Sign up</Button>
119+
<Button
120+
type="button"
121+
variant="outline"
122+
onClick={() => setMode("login")}
123+
>
124+
Already have an account? Log in
125+
</Button>
126+
</>
127+
)}
72128
</div>
73129
</form>
74130
</CardContent>

app/login/schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { z } from "zod";
2+
3+
export const loginSchema = z.object({
4+
email: z.string().email("Invalid email address"),
5+
password: z.string().min(6, "Password must be at least 6 characters"),
6+
});
7+
8+
export const signupSchema = z.object({
9+
firstName: z.string().min(1, "First name is required"),
10+
lastName: z.string().min(1, "Last name is required"),
11+
email: z.string().email("Invalid email address"),
12+
password: z.string().min(6, "Password must be at least 6 characters"),
13+
});

lib/db/schema.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1-
import { pgTable, text, timestamp, uuid, pgEnum, integer, boolean, jsonb } from "drizzle-orm/pg-core";
1+
import {
2+
pgTable,
3+
text,
4+
timestamp,
5+
uuid,
6+
pgEnum,
7+
integer,
8+
boolean,
9+
jsonb,
10+
} from "drizzle-orm/pg-core";
211

312
export const roleEnum = pgEnum("role", ["user", "admin", "coach"]);
4-
export const serviceTypeEnum = pgEnum('service_type', ["coaching_session", "booking"]);
5-
export const bookingStatusEnum = pgEnum('booking_status', ["pending", "confirmed", "cancelled"]);
6-
export const webinarTierEnum = pgEnum('webinar_tier', ["free", "premium"]);
7-
export const sessionStatusEnum = pgEnum("session_status", ["pending", "confirmed", "cancelled", "completed"]);
8-
13+
export const serviceTypeEnum = pgEnum("service_type", [
14+
"coaching_session",
15+
"booking",
16+
]);
17+
export const bookingStatusEnum = pgEnum("booking_status", [
18+
"pending",
19+
"confirmed",
20+
"cancelled",
21+
]);
22+
export const webinarTierEnum = pgEnum("webinar_tier", ["free", "premium"]);
23+
export const sessionStatusEnum = pgEnum("session_status", [
24+
"pending",
25+
"confirmed",
26+
"cancelled",
27+
"completed",
28+
]);
929

1030
export const profiles = pgTable("profiles", {
1131
id: uuid("id").primaryKey(),
@@ -15,7 +35,7 @@ export const profiles = pgTable("profiles", {
1535
stripeCustomerId: text("stripe_customer_id").unique(),
1636
createdAt: timestamp("created_at").defaultNow().notNull(),
1737
updatedAt: timestamp("updated_at").defaultNow().notNull(),
18-
})
38+
});
1939

2040
export const services = pgTable("services", {
2141
id: uuid("id").primaryKey().defaultRandom(),
@@ -28,12 +48,16 @@ export const services = pgTable("services", {
2848
isActive: boolean("is_active").notNull().default(true),
2949
createdAt: timestamp("created_at").defaultNow().notNull(),
3050
updatedAt: timestamp("updated_at").defaultNow().notNull(),
31-
})
51+
});
3252

3353
export const serviceBookings = pgTable("service_bookings", {
3454
id: uuid("id").primaryKey().defaultRandom(),
35-
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
36-
serviceId: uuid("service_id").references(() => services.id, { onDelete: "cascade" }).notNull(),
55+
userId: uuid("user_id")
56+
.references(() => profiles.id, { onDelete: "cascade" })
57+
.notNull(),
58+
serviceId: uuid("service_id")
59+
.references(() => services.id, { onDelete: "cascade" })
60+
.notNull(),
3761
status: bookingStatusEnum("status").notNull().default("pending"),
3862
notes: text("notes"),
3963
isActive: boolean("is_active").notNull().default(true),
@@ -55,9 +79,15 @@ export const webinars = pgTable("webinars", {
5579

5680
export const coachingSessions = pgTable("coaching_sessions", {
5781
id: uuid("id").primaryKey().defaultRandom(),
58-
serviceId: uuid("service_id").references(() => services.id, { onDelete: "cascade" }).notNull(),
59-
coachId: uuid("coach_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
60-
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
82+
serviceId: uuid("service_id")
83+
.references(() => services.id, { onDelete: "cascade" })
84+
.notNull(),
85+
coachId: uuid("coach_id")
86+
.references(() => profiles.id, { onDelete: "cascade" })
87+
.notNull(),
88+
userId: uuid("user_id")
89+
.references(() => profiles.id, { onDelete: "cascade" })
90+
.notNull(),
6191
scheduledAt: timestamp("scheduled_at"),
6292
durationMinutes: integer("duration_minutes").notNull().default(60),
6393
status: sessionStatusEnum("status").notNull().default("pending"),
@@ -70,7 +100,10 @@ export const coachingSessions = pgTable("coaching_sessions", {
70100

71101
export const subscriptions = pgTable("subscriptions", {
72102
id: uuid("id").primaryKey().defaultRandom(),
73-
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull().unique(),
103+
userId: uuid("user_id")
104+
.references(() => profiles.id, { onDelete: "cascade" })
105+
.notNull()
106+
.unique(),
74107
stripeSubscriptionId: text("stripe_subscription_id").unique(),
75108
status: text("status").notNull().default("none"),
76109
stripePriceId: text("stripe_price_id"),
@@ -83,7 +116,9 @@ export const subscriptions = pgTable("subscriptions", {
83116

84117
export const purchases = pgTable("purchases", {
85118
id: uuid("id").primaryKey().defaultRandom(),
86-
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
119+
userId: uuid("user_id")
120+
.references(() => profiles.id, { onDelete: "cascade" })
121+
.notNull(),
87122
stripePriceId: text("stripe_price_id").notNull(),
88123
stripeSessionId: text("stripe_session_id").notNull().unique(),
89124
productName: text("product_name").notNull(),

lib/roles.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const ROLES = {
2+
ADMIN: "admin",
3+
COACH: "coach",
4+
USER: "user",
5+
} as const;
6+
7+
export type Role = (typeof ROLES)[keyof typeof ROLES];

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"react-dom": "19.2.4",
3030
"shadcn": "^4.1.0",
3131
"tailwind-merge": "^3.5.0",
32-
"tw-animate-css": "^1.4.0"
32+
"tw-animate-css": "^1.4.0",
33+
"zod": "^3.24.0"
3334
},
3435
"devDependencies": {
3536
"@tailwindcss/postcss": "^4",

0 commit comments

Comments
 (0)