advance
This commit is contained in:
36
lib/auth/clientAuth.ts
Normal file
36
lib/auth/clientAuth.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DEMO_AUTH_EMAIL_COOKIE, DEMO_AUTH_ROLE_COOKIE } from "@/lib/auth/demoAuth";
|
||||
|
||||
export type ClientAuthSnapshot = {
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
isAuthed: boolean;
|
||||
isTeacher: boolean;
|
||||
};
|
||||
|
||||
const readCookieMap = (): Map<string, string> =>
|
||||
new Map(
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const [key, ...rest] = entry.split("=");
|
||||
return [key, decodeURIComponent(rest.join("="))] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
export const readDemoClientAuth = (): ClientAuthSnapshot => {
|
||||
if (typeof document === "undefined") {
|
||||
return { userId: "guest", userEmail: null, isAuthed: false, isTeacher: false };
|
||||
}
|
||||
|
||||
const cookies = readCookieMap();
|
||||
const userEmail = cookies.get(DEMO_AUTH_EMAIL_COOKIE) ?? null;
|
||||
const role = cookies.get(DEMO_AUTH_ROLE_COOKIE) ?? "";
|
||||
return {
|
||||
userId: userEmail ?? "guest",
|
||||
userEmail,
|
||||
isAuthed: Boolean(userEmail),
|
||||
isTeacher: role === "teacher",
|
||||
};
|
||||
};
|
||||
34
lib/auth/demoAuth.ts
Normal file
34
lib/auth/demoAuth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const DEMO_AUTH_EMAIL_COOKIE = "acve_demo_email";
|
||||
export const DEMO_AUTH_ROLE_COOKIE = "acve_demo_role";
|
||||
|
||||
type DemoAccount = {
|
||||
email: string;
|
||||
password: string;
|
||||
role: "teacher" | "learner";
|
||||
};
|
||||
|
||||
const demoAccounts: DemoAccount[] = [
|
||||
{
|
||||
email: "teacher@acve.local",
|
||||
password: "teacher123",
|
||||
role: "teacher",
|
||||
},
|
||||
{
|
||||
email: "learner@acve.local",
|
||||
password: "learner123",
|
||||
role: "learner",
|
||||
},
|
||||
];
|
||||
|
||||
export const demoCredentialsHint = demoAccounts
|
||||
.map((account) => `${account.email} / ${account.password}`)
|
||||
.join(" | ");
|
||||
|
||||
export const findDemoAccount = (email: string, password: string): DemoAccount | null => {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
return (
|
||||
demoAccounts.find(
|
||||
(account) => account.email.toLowerCase() === cleanEmail && account.password === password,
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
67
lib/auth/requireTeacher.ts
Normal file → Executable file
67
lib/auth/requireTeacher.ts
Normal file → Executable file
@@ -1,26 +1,53 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireUser } from "@/lib/auth/requireUser";
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { db } from "@/lib/prisma";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const readTeacherEmails = (): string[] =>
|
||||
(process.env.TEACHER_EMAILS ?? "")
|
||||
.split(",")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
export async function requireTeacher() {
|
||||
|
||||
export const requireTeacher = async () => {
|
||||
const user = await requireUser("/teacher");
|
||||
if (!user?.email) {
|
||||
redirect("/");
|
||||
const cookieStore = await cookies();
|
||||
|
||||
// 1. Get Supabase Session
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll() },
|
||||
setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch (error) {
|
||||
// This is expected in Server Components, but let's log it just in case
|
||||
logger.warn("Failed to set cookies in Server Component context (expected behavior)", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return null; // Let the caller handle the redirect
|
||||
}
|
||||
|
||||
const allowed = readTeacherEmails();
|
||||
if (allowed.length === 0) {
|
||||
redirect("/");
|
||||
// 2. Check Role in Database
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { id: user.id },
|
||||
}
|
||||
);
|
||||
console.log("AUTH_USER_ID:", user.id);
|
||||
console.log("DB_PROFILE:", profile);
|
||||
|
||||
|
||||
if (!profile || (profile.role !== UserRole.TEACHER && profile.role !== UserRole.SUPER_ADMIN)) {
|
||||
// You can decide to return null or throw an error here
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowed.includes(user.email.toLowerCase())) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
return profile;
|
||||
}
|
||||
28
lib/auth/requireUser.ts
Normal file → Executable file
28
lib/auth/requireUser.ts
Normal file → Executable file
@@ -1,16 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabaseServer } from "@/lib/supabase/server";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { db } from "@/lib/prisma";
|
||||
|
||||
export const requireUser = async (redirectTo: string) => {
|
||||
const supabase = await supabaseServer();
|
||||
if (!supabase) {
|
||||
return null;
|
||||
}
|
||||
export async function requireUser() {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{ cookies: { getAll() { return cookieStore.getAll() } } }
|
||||
);
|
||||
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!data.user) {
|
||||
redirect(`/auth/login?redirectTo=${encodeURIComponent(redirectTo)}`);
|
||||
}
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return null;
|
||||
|
||||
return data.user;
|
||||
};
|
||||
const profile = await db.profile.findUnique({ where: { id: user.id } });
|
||||
return profile;
|
||||
}
|
||||
16
lib/auth/teacherAllowlist.ts
Normal file
16
lib/auth/teacherAllowlist.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const parseTeacherEmails = (source: string | undefined): string[] =>
|
||||
(source ?? "")
|
||||
.split(",")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
export const readTeacherEmailsServer = (): string[] => parseTeacherEmails(process.env.TEACHER_EMAILS);
|
||||
|
||||
export const readTeacherEmailsBrowser = (): string[] =>
|
||||
parseTeacherEmails(process.env.NEXT_PUBLIC_TEACHER_EMAILS);
|
||||
|
||||
export const isTeacherEmailAllowed = (email: string | null, allowed: string[]): boolean => {
|
||||
if (!email) return false;
|
||||
if (allowed.length === 0) return false;
|
||||
return allowed.includes(email.toLowerCase());
|
||||
};
|
||||
14
lib/data/courseCatalog.ts
Normal file → Executable file
14
lib/data/courseCatalog.ts
Normal file → Executable file
@@ -2,12 +2,14 @@ import { mockCourses } from "@/lib/data/mockCourses";
|
||||
import { getTeacherCourseBySlug, getTeacherCourses } from "@/lib/data/teacherCourses";
|
||||
import type { Course } from "@/types/course";
|
||||
|
||||
const isPublished = (course: Course) => course.status === "Published";
|
||||
|
||||
export const getAllCourses = (): Course[] => {
|
||||
const teacherCourses = getTeacherCourses();
|
||||
if (teacherCourses.length === 0) return mockCourses;
|
||||
const teacherCourses = getTeacherCourses().filter(isPublished);
|
||||
if (teacherCourses.length === 0) return mockCourses.filter(isPublished);
|
||||
|
||||
const teacherSlugs = new Set(teacherCourses.map((course) => course.slug));
|
||||
const baseCourses = mockCourses.filter((course) => !teacherSlugs.has(course.slug));
|
||||
const baseCourses = mockCourses.filter((course) => isPublished(course) && !teacherSlugs.has(course.slug));
|
||||
return [...baseCourses, ...teacherCourses];
|
||||
};
|
||||
|
||||
@@ -17,4 +19,8 @@ export const getCourseBySlug = (slug: string): Course | undefined => {
|
||||
return mockCourses.find((course) => course.slug === slug);
|
||||
};
|
||||
|
||||
export const getAllCourseSlugs = (): string[] => getAllCourses().map((course) => course.slug);
|
||||
export const getAllCourseSlugs = (): string[] => {
|
||||
const teacherCourses = getTeacherCourses();
|
||||
const all = [...mockCourses, ...teacherCourses];
|
||||
return all.map((course) => course.slug);
|
||||
};
|
||||
|
||||
0
lib/data/mockCaseStudies.ts
Normal file → Executable file
0
lib/data/mockCaseStudies.ts
Normal file → Executable file
4
lib/data/mockCourses.ts
Normal file → Executable file
4
lib/data/mockCourses.ts
Normal file → Executable file
@@ -5,6 +5,7 @@ export const mockCourses: Course[] = [
|
||||
slug: "legal-english-foundations",
|
||||
title: "Legal English Foundations",
|
||||
level: "Beginner",
|
||||
status: "Published",
|
||||
summary: "Build legal vocabulary, core writing patterns, and court terminology.",
|
||||
rating: 4.8,
|
||||
weeks: 4,
|
||||
@@ -24,6 +25,7 @@ export const mockCourses: Course[] = [
|
||||
slug: "contract-analysis-practice",
|
||||
title: "Contract Analysis Practice",
|
||||
level: "Intermediate",
|
||||
status: "Published",
|
||||
summary: "Analyze contract sections and identify risk-heavy clauses quickly.",
|
||||
rating: 4.7,
|
||||
weeks: 5,
|
||||
@@ -44,6 +46,7 @@ export const mockCourses: Course[] = [
|
||||
slug: "litigation-brief-writing",
|
||||
title: "Litigation Brief Writing",
|
||||
level: "Advanced",
|
||||
status: "Published",
|
||||
summary: "Craft persuasive briefs with strong authority usage and argument flow.",
|
||||
rating: 4.9,
|
||||
weeks: 6,
|
||||
@@ -65,6 +68,7 @@ export const mockCourses: Course[] = [
|
||||
slug: "cross-border-ip-strategy",
|
||||
title: "Cross-Border IP Strategy",
|
||||
level: "Advanced",
|
||||
status: "Published",
|
||||
summary: "Understand IP enforcement strategy across multiple jurisdictions.",
|
||||
rating: 4.6,
|
||||
weeks: 4,
|
||||
|
||||
0
lib/data/mockPractice.ts
Normal file → Executable file
0
lib/data/mockPractice.ts
Normal file → Executable file
1
lib/data/teacherCourses.ts
Normal file → Executable file
1
lib/data/teacherCourses.ts
Normal file → Executable file
@@ -92,6 +92,7 @@ export const createTeacherCourse = (input: TeacherCourseInput, reservedSlugs: st
|
||||
slug,
|
||||
title: input.title,
|
||||
level: input.level,
|
||||
status: "Draft",
|
||||
summary: input.summary,
|
||||
rating: 5,
|
||||
weeks: Math.max(1, input.weeks),
|
||||
|
||||
21
lib/logger.ts
Normal file
21
lib/logger.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const getTimestamp = () => new Date().toISOString();
|
||||
|
||||
export const logger = {
|
||||
info: (message: string, ...args: unknown[]) => {
|
||||
console.log(`[INFO] [${getTimestamp()}] ${message}`, ...args);
|
||||
},
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
console.warn(`[WARN] [${getTimestamp()}] ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, error?: unknown) => {
|
||||
console.error(`[ERROR] [${getTimestamp()}] ${message}`, error);
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
},
|
||||
debug: (message: string, ...args: unknown[]) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[DEBUG] [${getTimestamp()}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
24
lib/prisma.ts
Executable file
24
lib/prisma.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import { Pool } from 'pg'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
// Use the POOLER URL (6543) for the app to handle high traffic
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
|
||||
const createPrismaClient = () => {
|
||||
// 1. Create the Pool
|
||||
const pool = new Pool({ connectionString })
|
||||
// 2. Create the Adapter
|
||||
const adapter = new PrismaPg(pool)
|
||||
// 3. Init the Client
|
||||
return new PrismaClient({ adapter })
|
||||
}
|
||||
|
||||
// Prevent multiple instances during development hot-reloading
|
||||
declare global {
|
||||
var prisma: ReturnType<typeof createPrismaClient> | undefined
|
||||
}
|
||||
|
||||
export const db = globalThis.prisma || createPrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db
|
||||
0
lib/progress/localProgress.ts
Normal file → Executable file
0
lib/progress/localProgress.ts
Normal file → Executable file
9
lib/supabase/browser.ts
Normal file → Executable file
9
lib/supabase/browser.ts
Normal file → Executable file
@@ -1,17 +1,16 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { readSupabasePublicConfig } from "@/lib/supabase/config";
|
||||
|
||||
let browserClient: SupabaseClient | null = null;
|
||||
|
||||
export const supabaseBrowser = (): SupabaseClient | null => {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!url || !anonKey) {
|
||||
const config = readSupabasePublicConfig();
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!browserClient) {
|
||||
browserClient = createClient(url, anonKey);
|
||||
browserClient = createClient(config.url, config.anonKey);
|
||||
}
|
||||
|
||||
return browserClient;
|
||||
|
||||
28
lib/supabase/config.ts
Normal file
28
lib/supabase/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const isValidHttpUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export type SupabasePublicConfig = {
|
||||
url: string;
|
||||
anonKey: string;
|
||||
};
|
||||
|
||||
export const readSupabasePublicConfig = (): SupabasePublicConfig | null => {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
|
||||
|
||||
if (!url || !anonKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidHttpUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { url, anonKey };
|
||||
};
|
||||
8
lib/supabase/middleware.ts
Normal file → Executable file
8
lib/supabase/middleware.ts
Normal file → Executable file
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import { readSupabasePublicConfig } from "@/lib/supabase/config";
|
||||
|
||||
export type SessionSnapshot = {
|
||||
response: NextResponse;
|
||||
@@ -11,9 +12,8 @@ export type SessionSnapshot = {
|
||||
export const updateSession = async (req: NextRequest): Promise<SessionSnapshot> => {
|
||||
const response = NextResponse.next({ request: req });
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!url || !anonKey) {
|
||||
const config = readSupabasePublicConfig();
|
||||
if (!config) {
|
||||
return {
|
||||
response,
|
||||
isAuthed: false,
|
||||
@@ -22,7 +22,7 @@ export const updateSession = async (req: NextRequest): Promise<SessionSnapshot>
|
||||
};
|
||||
}
|
||||
|
||||
const supabase = createServerClient(url, anonKey, {
|
||||
const supabase = createServerClient(config.url, config.anonKey, {
|
||||
cookies: {
|
||||
getAll: () => req.cookies.getAll(),
|
||||
setAll: (
|
||||
|
||||
9
lib/supabase/server.ts
Normal file → Executable file
9
lib/supabase/server.ts
Normal file → Executable file
@@ -1,17 +1,16 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import { readSupabasePublicConfig } from "@/lib/supabase/config";
|
||||
|
||||
export const supabaseServer = async () => {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!url || !anonKey) {
|
||||
const config = readSupabasePublicConfig();
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(url, anonKey, {
|
||||
return createServerClient(config.url, config.anonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
|
||||
6
lib/utils.ts
Executable file
6
lib/utils.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
12
lib/validations/course.ts
Executable file
12
lib/validations/course.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const translationSchema = z.object({
|
||||
en: z.string().min(1, "English version is required"),
|
||||
es: z.string().min(1, "Spanish version is required"),
|
||||
});
|
||||
|
||||
export const courseSchema = z.object({
|
||||
title: translationSchema,
|
||||
description: translationSchema,
|
||||
tags: z.array(z.string()).default([]),
|
||||
});
|
||||
Reference in New Issue
Block a user