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 (