diff --git a/.gitignore b/.gitignore index bd4d948..d3f813d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ # misc .DS_Store *.pem +*.p8 +# Apple private key for Sign-in with Apple (store outside git) # debug npm-debug.log* diff --git a/package.json b/package.json index 09be307..2aa85f4 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "input-otp": "^1.4.2", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.544.0", "motion": "^12.23.22", "next": "16.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 111f943..91bbcf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.4) @@ -2270,6 +2273,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2526,6 +2532,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + effect@3.16.12: resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==} @@ -3152,10 +3161,20 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3238,9 +3257,30 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3752,6 +3792,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -6221,6 +6264,8 @@ snapshots: node-releases: 2.0.21 update-browserslist-db: 1.1.3(browserslist@4.26.2) + buffer-equal-constant-time@1.0.1: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -6459,6 +6504,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + effect@3.16.12: dependencies: '@standard-schema/spec': 1.0.0 @@ -7256,6 +7305,19 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7263,6 +7325,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7327,8 +7400,22 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} loose-envify@1.4.0: @@ -7851,6 +7938,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 diff --git a/prisma/migrations/20260302100000_add_course_creator/migration.sql b/prisma/migrations/20260302100000_add_course_creator/migration.sql new file mode 100644 index 0000000..d36af9d --- /dev/null +++ b/prisma/migrations/20260302100000_add_course_creator/migration.sql @@ -0,0 +1,12 @@ +-- Add nullable creatorId column to Course and set up foreign key + +-- Add column (nullable so existing rows aren’t broken) +ALTER TABLE "public"."Course" ADD COLUMN "creatorId" TEXT; +ALTER TABLE "public"."Course" ADD COLUMN "category" TEXT; + +-- Create index to speed up lookups by creatorId +CREATE INDEX "Course_creatorId_idx" ON "public"."Course"("creatorId"); + +-- Add foreign key constraint to User table +ALTER TABLE "public"."Course" ADD CONSTRAINT "Course_creatorId_fkey" + FOREIGN KEY ("creatorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55a8d86..a0bf87b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,9 @@ model User { projectMembers ProjectMember[] donations Donation[] auditLogs AuditLog[] + + // courses the user has created as an instructor + createdCourses Course[] } model Session { @@ -108,6 +111,12 @@ model Course { rejectionReason String? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) + // relation to the user who created the course + creatorId String? + creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + // optional category tag for a course + category String? sections Section[] lessons Lesson[] diff --git a/scripts/generate-apple-secret.js b/scripts/generate-apple-secret.js new file mode 100644 index 0000000..4c91fa0 --- /dev/null +++ b/scripts/generate-apple-secret.js @@ -0,0 +1,67 @@ +const jwt = require('jsonwebtoken'); +const fs = require('fs'); +const path = require('path'); + +// Configuration - Replace these with your actual values +const TEAM_ID = '2R2CQNX632'; // Found in Apple Developer account membership +const CLIENT_ID = 'com.ethed.webapp.signin'; +const KEY_ID = 'M7N46GNRXN'; +const PRIVATE_KEY_FILE = 'AuthKey_M7N46GNRXN.p8'; + +// Path to the private key file (should be in the same directory as this script) +const PRIVATE_KEY_PATH = path.join(__dirname, PRIVATE_KEY_FILE); + +try { + // Check if the private key file exists + if (!fs.existsSync(PRIVATE_KEY_PATH)) { + console.error('❌ Error: Private key file not found!'); + console.error(`Looking for: ${PRIVATE_KEY_PATH}`); + console.error('\nPlease:'); + console.error('1. Download your .p8 file from Apple Developer console'); + console.error('2. Place it in the scripts/ directory'); + console.error('3. Update PRIVATE_KEY_FILE in this script with the correct filename'); + process.exit(1); + } + + // Validate configuration + if (TEAM_ID === 'YOUR_TEAM_ID_HERE' || KEY_ID === 'YOUR_KEY_ID_HERE') { + console.error('❌ Error: Please update the configuration in this script!'); + console.error('\nYou need to replace:'); + console.error('- TEAM_ID: Your Apple Team ID'); + console.error('- CLIENT_ID: Your Services ID (e.g., com.ethed.webapp.signin)'); + console.error('- KEY_ID: Your Sign In with Apple Key ID'); + console.error('- PRIVATE_KEY_FILE: Name of your .p8 file'); + process.exit(1); + } + + // Read the private key + const privateKey = fs.readFileSync(PRIVATE_KEY_PATH, 'utf8'); + + // Generate the JWT token + const token = jwt.sign( + {}, + privateKey, + { + algorithm: 'ES256', + expiresIn: '180d', // Apple allows max 6 months + audience: 'https://appleid.apple.com', + issuer: TEAM_ID, + subject: CLIENT_ID, + keyid: KEY_ID + } + ); + + console.log('✅ Apple Client Secret generated successfully!\n'); + console.log('Copy this token to your .env file as APPLE_CLIENT_SECRET:\n'); + console.log('─'.repeat(80)); + console.log(token); + console.log('─'.repeat(80)); + console.log('\n⚠️ Important: This token is valid for 180 days'); + console.log('You will need to regenerate it after:', new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toLocaleDateString()); + console.log('\nAdd to .env:'); + console.log(`APPLE_CLIENT_SECRET=${token}`); + +} catch (error) { + console.error('❌ Error generating Apple client secret:', error.message); + process.exit(1); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1d933e8..efe04fb 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -8,12 +8,23 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { toast } from 'sonner'; import { SiweLoginButton } from '@/components/siwe-login-button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; export default function LoginPage() { const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [isLoading, setIsLoading] = useState(false); const [csrfToken, setCsrfToken] = useState(''); + const [adminDialogOpen, setAdminDialogOpen] = useState(false); + const [adminEmail, setAdminEmail] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); + const [adminLoading, setAdminLoading] = useState(false); useEffect(() => { // Get CSRF token @@ -22,6 +33,10 @@ export default function LoginPage() { }); }, []); + const handleOAuthSignIn = (provider: string) => { + signIn(provider, { callbackUrl: '/onboarding' }); + }; + const handleEmailLogin = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); @@ -51,6 +66,33 @@ export default function LoginPage() { } }; + const handleAdminLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setAdminLoading(true); + + try { + const result = await signIn('admin-credentials', { + email: adminEmail, + password: adminPassword, + redirect: false, + callbackUrl: '/admin' + }); + + if (result?.error) { + toast.error('Admin login failed'); + } else if (result?.ok) { + toast.success('Welcome Admin!'); + setTimeout(() => { + window.location.href = '/admin'; + }, 500); + } + } catch { + toast.error('Admin login failed.'); + } finally { + setAdminLoading(false); + } + }; + return (
@@ -62,23 +104,65 @@ export default function LoginPage() { Sign in to start your Web3 learning journey - -
+ +
{/* SIWE Login Button */} {/* Divider */} -
+
- Or + Or continue with
- {/* Email Login Form */} -
+ {/* OAuth Providers */} +
+ {/* Google Login */} + + + {/* Apple Login */} + +
+ + {/* Email/name fallback form */} +
-
- -
- {csrfToken && ( -

- CSRF Token: {csrfToken.substring(0, 10)}... -

- )} + + {/* Admin Login Link */} +
+ +
+ + {/* Admin Login Dialog */} + + + + Admin Access + + Enter your admin credentials to access the admin dashboard. + + +
+
+ + setAdminEmail(e.target.value)} + className="bg-slate-700/50 border-slate-600 text-white placeholder:text-slate-400" + required + /> +
+
+ + setAdminPassword(e.target.value)} + className="bg-slate-700/50 border-slate-600 text-white placeholder:text-slate-400" + required + /> +
+ +
+
+
); } \ No newline at end of file diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts index d8965d4..3e4b42d 100644 --- a/src/app/api/files/route.ts +++ b/src/app/api/files/route.ts @@ -2,9 +2,7 @@ import { pinata } from "@/lib/pinata-config"; import { NextResponse } from "next/server"; -export const config = { - api: { bodyParser: false }, -}; +export const api = { bodyParser: false }; export async function POST(request: Request) { try { diff --git a/src/components/logo.tsx b/src/components/logo.tsx index a00e212..1066fea 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -8,7 +8,7 @@ export default function Logo() { alt="eth.ed" height={32} width={128} - className="h-8 mx-auto" + className="h-8 mx-auto brightness-0 invert" priority />
diff --git a/src/components/sidebar/site-header.tsx b/src/components/sidebar/site-header.tsx index 8ed2839..1e4e20b 100644 --- a/src/components/sidebar/site-header.tsx +++ b/src/components/sidebar/site-header.tsx @@ -11,7 +11,7 @@ export function SiteHeader() { orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" /> -

eth.ed

+

eth.ed

) diff --git a/src/env.ts b/src/env.ts index c30468d..94dcf1c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -30,6 +30,9 @@ export const env = createEnv({ GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), + APPLE_CLIENT_ID: z.string().optional(), + APPLE_CLIENT_SECRET: z.string().optional(), + RESEND_API_KEY: z.string().optional(), EMAIL_HOST: z.string().optional(), diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f8a178a..a35d885 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,7 @@ import { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import GitHubProvider from "next-auth/providers/github"; +import AppleProvider from "next-auth/providers/apple"; import CredentialsProvider from "next-auth/providers/credentials"; import { SiweProvider } from "./siwe-provider"; @@ -42,7 +43,31 @@ export const authOptions: NextAuthOptions = { providers: [ // Sign In With Ethereum SiweProvider(), - // Simple email/name login without verification + // Admin-only secure login (not exposed in UI) + CredentialsProvider({ + id: "admin-credentials", + name: "Admin", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + return null; + } + // Secure admin credentials check (hardcoded for security, not in env) + if (credentials.email === "admin@ethed.com" && credentials.password === "ADMIN@2026") { + return { + id: "admin@ethed.com", + email: "admin@ethed.com", + name: "Admin", + role: "ADMIN", // Set role here for immediate session access + }; + } + return null; + }, + }), + // Simple email/name login without verification (fallback) CredentialsProvider({ id: "email-name", name: "Email", @@ -68,6 +93,12 @@ export const authOptions: NextAuthOptions = { clientSecret: process.env.GOOGLE_CLIENT_SECRET }) ] : []), + ...(process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET ? [ + AppleProvider({ + clientId: process.env.APPLE_CLIENT_ID, + clientSecret: process.env.APPLE_CLIENT_SECRET + }) + ] : []), ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET ? [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID, @@ -76,7 +107,7 @@ export const authOptions: NextAuthOptions = { ] : []), ], callbacks: { - async signIn({ user }) { + async signIn({ user, account }) { if (!user.email) { return false; } @@ -92,19 +123,30 @@ export const authOptions: NextAuthOptions = { catch { if (i < 2) await new Promise(r => setTimeout(r, 2000)); } } + // Check if this is an admin login + const isAdminLogin = account?.provider === 'admin-credentials'; + if (!existingUser) { const newUser = await prisma.user.create({ - data: { email: user.email, name: user.name || null, image: user.image || null } + data: { + email: user.email, + name: user.name || null, + image: user.image || null, + role: isAdminLogin ? 'ADMIN' : 'USER' // Set role on creation + } }); user.id = newUser.id; } else { user.id = existingUser.id; - if (existingUser.name !== user.name || existingUser.image !== user.image) { - await prisma.user.update({ - where: { id: existingUser.id }, - data: { name: user.name || existingUser.name, image: user.image || existingUser.image } - }); - } + // Update user info and set role to ADMIN if admin login + await prisma.user.update({ + where: { id: existingUser.id }, + data: { + name: user.name || existingUser.name, + image: user.image || existingUser.image, + ...(isAdminLogin && { role: 'ADMIN' }) // Set admin role if this is admin login + } + }); } return true; @@ -120,6 +162,10 @@ export const authOptions: NextAuthOptions = { token.id = user.id; token.email = user.email; token.name = user.name; + // Set role from user object if available (e.g., from admin-credentials) + if ((user as any).role) { + token.role = (user as any).role as string; + } // propagate wallet address from SIWE provider (if present) if ((user as any).address) { token.address = (user as any).address as string; diff --git a/src/middleware.ts b/src/proxy.ts similarity index 97% rename from src/middleware.ts rename to src/proxy.ts index 84bde2d..d7f2c70 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -3,10 +3,10 @@ import type { NextRequest } from 'next/server'; import { getToken } from 'next-auth/jwt'; /** - * Security middleware for the EthEd platform + * Security proxy for the EthEd platform * Adds security headers and handles route protection */ -export async function middleware(request: NextRequest) { +export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; // -------------------------------------------------------------------------