Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,27 @@ OPENAI_FINETUNED_MODEL=ft:gpt-4o-mini:your-model-id
# Pinecone Configuration
PINECONE_API_KEY=your_pinecone_api_key_here
PINECONE_INDEX=your_index_name_here

# ── LMS (course site at /learn + /admin) ──────────────────────────────
# Only needed if you're running the course site; the chat app above works
# without these. See docs/LMS-SETUP.md.

# Neon Postgres for student progress (its own project — never force-reset)
LMS_DATABASE_URL=postgresql://user:password@host/db?sslmode=require

# Clerk (invite-only email sign-in)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key
CLERK_SECRET_KEY=sk_test_your_key

# Comma-separated admin email allowlist (sees /admin)
LMS_ADMIN_EMAILS=brian@parsity.io

# Used for invite redirect links
NEXT_PUBLIC_APP_URL=http://localhost:3000

# LiteLLM proxy — lets /admin mint budget-capped student API keys
# (same proxy the medical-rag course uses; see infra in that repo)
LITELLM_PROXY_URL=https://parsity-litellm.fly.dev
LITELLM_MASTER_KEY=sk-your-master-key
LITELLM_KEY_BUDGET_USD=10
LITELLM_KEY_DURATION_DAYS=60
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# clerk configuration (can include secrets)
/.clerk/

# clerk deploy zone-file exports
clerk-*.zone
141 changes: 141 additions & 0 deletions app/admin/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use server';

import { clerkClient } from '@clerk/nextjs/server';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/lms/admin';
import { lmsPrisma } from '@/lib/lms/prisma';
import { mintKey, revokeKey, updateKeyBudget } from '@/lib/lms/litellm';

/** Invite a student by email — Clerk sends the magic-link/code email. */
export async function inviteStudent(formData: FormData) {
await requireAdmin();
const email = String(formData.get('email') ?? '').trim();
if (!email) return;

const client = await clerkClient();
await client.invitations.createInvitation({
emailAddress: email,
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL ?? ''}/learn`,
notify: true,
ignoreExisting: true,
});
revalidatePath('/admin');
}

/**
* Evict a student. Clerk's ban is a paid-plan feature, so on the free plan the
* eviction is a hard DELETE: remove the Clerk account (blocks sign-in and
* kills sessions), revoke their proxy key, and drop their LMS row (progress
* cascades). Permanent — re-access requires a fresh invite, and sign-up is
* restricted so they can't self-register.
*/
export async function removeStudent(formData: FormData) {
await requireAdmin();
const userId = String(formData.get('userId') ?? '');
if (!userId) return;

// Best-effort: kill their proxy key so it can't keep spending once they're gone.
const student = await lmsPrisma.student.findUnique({ where: { id: userId } });
if (student?.apiKey) {
try {
await revokeKey(student.apiKey);
} catch {
// A proxy hiccup shouldn't block the eviction.
}
}

const client = await clerkClient();
await client.users.deleteUser(userId);
await lmsPrisma.student.deleteMany({ where: { id: userId } }); // progress cascades

revalidatePath('/admin');
}

/** Lock/unlock the interview-prep section for one student. */
export async function setInterviewAccess(formData: FormData) {
await requireAdmin();
const studentId = String(formData.get('studentId') ?? '');
const unlock = String(formData.get('unlock') ?? '') === 'true';
if (!studentId) return;

await lmsPrisma.student.update({
where: { id: studentId },
data: { interviewUnlockedAt: unlock ? new Date() : null },
});
revalidatePath('/admin');
revalidatePath('/learn');
}

/** Mint a budget-capped LiteLLM key for one student and store it. */
export async function mintStudentKey(formData: FormData) {
await requireAdmin();
const studentId = String(formData.get('studentId') ?? '');
if (!studentId) return;

const student = await lmsPrisma.student.findUnique({ where: { id: studentId } });
if (!student || student.apiKey) return; // one key per student — revoke first

const { key, expiresAt, budget } = await mintKey(student.email);
await lmsPrisma.student.update({
where: { id: studentId },
data: {
apiKey: key,
apiKeyBudget: budget,
apiKeyMintedAt: new Date(),
apiKeyExpiresAt: expiresAt,
},
});
revalidatePath('/admin');
}

/** Add $ to a student's key budget (ceiling moves, spend is preserved). */
export async function bumpStudentKeyBudget(formData: FormData) {
await requireAdmin();
const studentId = String(formData.get('studentId') ?? '');
const amount = Number(formData.get('amount') ?? 0);
if (!studentId || !(amount > 0)) return;

const student = await lmsPrisma.student.findUnique({ where: { id: studentId } });
if (!student?.apiKey) return;

const newBudget = (student.apiKeyBudget ?? 0) + amount;
await updateKeyBudget(student.apiKey, newBudget);
await lmsPrisma.student.update({
where: { id: studentId },
data: { apiKeyBudget: newBudget },
});
revalidatePath('/admin');
}

/** Revoke a student's key on the proxy and clear it locally. */
export async function revokeStudentKey(formData: FormData) {
await requireAdmin();
const studentId = String(formData.get('studentId') ?? '');
if (!studentId) return;

const student = await lmsPrisma.student.findUnique({ where: { id: studentId } });
if (!student?.apiKey) return;

await revokeKey(student.apiKey);
await lmsPrisma.student.update({
where: { id: studentId },
data: {
apiKey: null,
apiKeyBudget: null,
apiKeyMintedAt: null,
apiKeyExpiresAt: null,
},
});
revalidatePath('/admin');
}

/** Cancel a pending invitation. */
export async function revokeInvitation(formData: FormData) {
await requireAdmin();
const invitationId = String(formData.get('invitationId') ?? '');
if (!invitationId) return;

const client = await clerkClient();
await client.invitations.revokeInvitation(invitationId);
revalidatePath('/admin');
}
41 changes: 41 additions & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignOutButton, UserButton } from '@clerk/nextjs';
import { isAdmin } from '@/lib/lms/admin';

export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
// Gate the whole /admin segment. Middleware guarantees authentication;
// this enforces the admin allowlist. Every action re-checks too.
if (!(await isAdmin())) redirect('/learn');

return (
<div className='lms min-h-screen'>
<header className='sticky top-0 z-20 border-b border-zinc-200 bg-white/90 backdrop-blur'>
<div className='mx-auto flex max-w-6xl items-center justify-between px-4 py-3'>
<div className='flex items-center gap-3'>
<span className='text-[15px] font-bold tracking-tight text-zinc-900'>Admin</span>
<Link
href='/learn'
className='text-sm font-medium text-blue-600 hover:text-blue-800'
>
← Course
</Link>
</div>
<span className='flex items-center gap-2'>
<UserButton />
<SignOutButton>
<button className='cursor-pointer text-xs font-medium text-zinc-400 hover:text-zinc-700'>
Sign out
</button>
</SignOutButton>
</span>
</div>
</header>
<div className='mx-auto max-w-6xl px-4 py-8'>{children}</div>
</div>
);
}
Loading