mirror of
https://github.com/hanzo-apps/university.git
synced 2026-10-30 15:05:53 +00:00
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
/**
|
|
* Hanzo University Student Authentication & Backend Class Resolution
|
|
*
|
|
* Manages authentic OIDC / IAM user sessions, JWT extraction of student legal names
|
|
* and handles, token verification, and backend course enrollment mapping.
|
|
*/
|
|
|
|
import { UNIVERSITY_COURSES } from '../app/courses-data'
|
|
|
|
export interface StudentSession {
|
|
isAuthenticated: boolean
|
|
token?: string
|
|
sub?: string
|
|
name: string
|
|
handle: string
|
|
email: string
|
|
did: string
|
|
enrolledClasses: string[]
|
|
backendPlan?: string
|
|
isFullAccess?: boolean
|
|
source: 'iam_token' | 'checkout' | 'manual'
|
|
}
|
|
|
|
/** Mapping of Hanzo billing plan IDs to University course slugs */
|
|
export const PLAN_TO_COURSE_SLUG: Record<string, string> = {
|
|
'course-eng-100': 'agentic-coding',
|
|
'course-ag-101': 'agentic-coding',
|
|
'course-rl-101': 'reinforcement-learning',
|
|
'course-rl-102': 'reinforcement-learning',
|
|
'course-sys-103': 'systems-engineering',
|
|
'course-mkt-102': 'agentic-marketing',
|
|
'course-mkt-104': 'agentic-marketing',
|
|
'course-pra-104': 'ai-practitioner',
|
|
'course-arc-105': 'ai-architect',
|
|
// Course slugs directly
|
|
'agentic-coding': 'agentic-coding',
|
|
'reinforcement-learning': 'reinforcement-learning',
|
|
'systems-engineering': 'systems-engineering',
|
|
'agentic-marketing': 'agentic-marketing',
|
|
'ai-practitioner': 'ai-practitioner',
|
|
'ai-architect': 'ai-architect',
|
|
// Membership tiers with full curriculum access
|
|
'max-20x': 'agentic-coding',
|
|
'max-5x': 'agentic-coding',
|
|
'pro': 'agentic-coding',
|
|
'membership': 'agentic-coding',
|
|
}
|
|
|
|
/** Parse and validate JWT token claims without external dependencies */
|
|
export function parseJwt(token: string): Record<string, any> | null {
|
|
try {
|
|
const parts = token.trim().split('.')
|
|
if (parts.length < 2) return null
|
|
const base64Url = parts[1]
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
|
const jsonPayload = decodeURIComponent(
|
|
atob(base64)
|
|
.split('')
|
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
|
.join('')
|
|
)
|
|
return JSON.parse(jsonPayload)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Format raw name or email username into human-readable Display Name */
|
|
export function formatHumanName(raw: string): string {
|
|
if (!raw) return 'Student'
|
|
// If it's an email, strip domain
|
|
const clean = raw.includes('@') ? raw.split('@')[0] : raw
|
|
// Replace dots, underscores, hyphens with spaces
|
|
const spaced = clean.replace(/[._-]+/g, ' ').trim()
|
|
if (!spaced) return 'Student'
|
|
// Capitalize words
|
|
return spaced
|
|
.split(' ')
|
|
.filter(Boolean)
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
.join(' ')
|
|
}
|
|
|
|
/**
|
|
* Retrieve the list of courses explicitly enrolled / paid for by the student.
|
|
* Stored in localStorage under `hanzo_portal_enrolled_courses`.
|
|
*/
|
|
export function getStoredEnrolledCourses(): string[] {
|
|
if (typeof window === 'undefined') return []
|
|
try {
|
|
const raw = localStorage.getItem('hanzo_portal_enrolled_courses')
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw)
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.filter(
|
|
(slug): slug is string =>
|
|
typeof slug === 'string' && UNIVERSITY_COURSES.some((c) => c.slug === slug)
|
|
)
|
|
}
|
|
}
|
|
// Backward compatibility for existing checkout sessions where single course was set
|
|
const handle = localStorage.getItem('hanzo_portal_student_handle')
|
|
const legacy = localStorage.getItem('hanzo_portal_selected_course')
|
|
if (handle && legacy && UNIVERSITY_COURSES.some((c) => c.slug === legacy)) {
|
|
return [legacy]
|
|
}
|
|
} catch (_) {}
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Record a newly paid / enrolled course in persistent student storage.
|
|
*/
|
|
export function addEnrolledCourse(slug: string): string[] {
|
|
if (typeof window === 'undefined') return [slug]
|
|
try {
|
|
const current = getStoredEnrolledCourses()
|
|
if (UNIVERSITY_COURSES.some((c) => c.slug === slug)) {
|
|
const updated = current.includes(slug) ? current : [...current, slug]
|
|
localStorage.setItem('hanzo_portal_enrolled_courses', JSON.stringify(updated))
|
|
localStorage.setItem('hanzo_portal_selected_course', slug)
|
|
return updated
|
|
}
|
|
return current
|
|
} catch (_) {
|
|
return [slug]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove an enrolled course from persistent student storage.
|
|
*/
|
|
export function removeEnrolledCourse(slug: string): string[] {
|
|
if (typeof window === 'undefined') return []
|
|
try {
|
|
const current = getStoredEnrolledCourses()
|
|
const updated = current.filter((s) => s !== slug)
|
|
localStorage.setItem('hanzo_portal_enrolled_courses', JSON.stringify(updated))
|
|
return updated
|
|
} catch (_) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the student session has active paid enrollment for a specific course slug.
|
|
*/
|
|
export function isUserEnrolledInCourse(session: StudentSession | null | undefined, slug: string): boolean {
|
|
if (!session || !session.isAuthenticated) return false
|
|
if (session.isFullAccess) return true
|
|
return Array.isArray(session.enrolledClasses) && session.enrolledClasses.includes(slug)
|
|
}
|
|
|
|
/** Build the official Hanzo IAM login redirect URL */
|
|
export function getHanzoLoginUrl(returnPath: string = '/portal'): string {
|
|
if (typeof window === 'undefined') {
|
|
return `https://hanzo.ai/login?next=${encodeURIComponent('https://hanzo.university' + returnPath)}`
|
|
}
|
|
const currentUrl = new URL(returnPath, window.location.origin).toString()
|
|
return `https://hanzo.ai/login?next=${encodeURIComponent(currentUrl)}`
|
|
}
|
|
|
|
/** Check and extract active student session from localStorage, URL params, or hash */
|
|
export function resolveStudentSession(): StudentSession | null {
|
|
if (typeof window === 'undefined') return null
|
|
|
|
let token: string | null = null
|
|
|
|
// 1. Check URL parameters for token (e.g. redirected from IAM / OAuth callback)
|
|
const url = new URL(window.location.href)
|
|
const queryToken =
|
|
url.searchParams.get('access_token') ||
|
|
url.searchParams.get('token') ||
|
|
url.searchParams.get('id_token') ||
|
|
url.searchParams.get('jwt')
|
|
|
|
// 2. Check URL Hash (OIDC implicit / hybrid flow)
|
|
if (!queryToken && window.location.hash) {
|
|
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
|
const hashToken = hashParams.get('access_token') || hashParams.get('id_token') || hashParams.get('token')
|
|
if (hashToken) {
|
|
token = hashToken
|
|
try {
|
|
localStorage.setItem('hanzo_iam_access_token', token)
|
|
// Clean hash from URL bar
|
|
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
if (queryToken) {
|
|
token = queryToken
|
|
try {
|
|
localStorage.setItem('hanzo_iam_access_token', token)
|
|
// Clean query token from URL bar
|
|
url.searchParams.delete('access_token')
|
|
url.searchParams.delete('token')
|
|
url.searchParams.delete('id_token')
|
|
url.searchParams.delete('jwt')
|
|
window.history.replaceState(null, '', url.pathname + (url.search ? url.search : ''))
|
|
} catch (_) {}
|
|
}
|
|
|
|
// 3. Check persistent localStorage keys
|
|
if (!token) {
|
|
token =
|
|
localStorage.getItem('hanzo_iam_access_token') ||
|
|
localStorage.getItem('hanzo_iam_id_token') ||
|
|
localStorage.getItem('hanzo_access_token') ||
|
|
localStorage.getItem('token') ||
|
|
localStorage.getItem('auth_token')
|
|
}
|
|
|
|
// If a JWT token exists, parse authentic claims
|
|
if (token) {
|
|
const claims = parseJwt(token)
|
|
if (claims) {
|
|
// Check expiry if timestamp provided
|
|
if (claims.exp && typeof claims.exp === 'number') {
|
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
if (nowSec > claims.exp) {
|
|
// Token expired
|
|
console.warn('[Hanzo University] IAM token expired at', new Date(claims.exp * 1000).toISOString())
|
|
// Don't immediately nuke if there's a stored checkout session, but token is stale
|
|
}
|
|
}
|
|
|
|
const email = claims.email || ''
|
|
const rawName = claims.displayName || claims.name || claims.preferred_username || (email ? email.split('@')[0] : '')
|
|
const finalName = formatHumanName(rawName)
|
|
const handle =
|
|
(claims.preferred_username || claims.owner || claims.name || email.split('@')[0] || 'student')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]/g, '')
|
|
const did = claims.did || (claims.sub ? `did:lux:${claims.sub}` : `did:hanzo:student:${handle}`)
|
|
const plan = (claims.billing_plan || claims.plan || claims.tier || '').toLowerCase()
|
|
const isFull = ['max-20x', 'max-5x', 'pro', 'membership'].includes(plan)
|
|
|
|
// Resolve enrolled classes from claims or storage
|
|
const enrolled: string[] = []
|
|
if (isFull) {
|
|
enrolled.push(...UNIVERSITY_COURSES.map((c) => c.slug))
|
|
} else {
|
|
// 1. Check explicit courses in JWT claims
|
|
if (claims.courses && Array.isArray(claims.courses)) {
|
|
for (const c of claims.courses) {
|
|
const slug = PLAN_TO_COURSE_SLUG[c] || c
|
|
if (UNIVERSITY_COURSES.some((uc) => uc.slug === slug) && !enrolled.includes(slug)) {
|
|
enrolled.push(slug)
|
|
}
|
|
}
|
|
}
|
|
// 2. Check explicit entitlements in JWT claims
|
|
if (claims.entitlements && Array.isArray(claims.entitlements)) {
|
|
for (const ent of claims.entitlements) {
|
|
const slug = PLAN_TO_COURSE_SLUG[ent] || ent
|
|
if (UNIVERSITY_COURSES.some((uc) => uc.slug === slug) && !enrolled.includes(slug)) {
|
|
enrolled.push(slug)
|
|
}
|
|
}
|
|
}
|
|
// 3. Check single plan mapping
|
|
if (plan && PLAN_TO_COURSE_SLUG[plan]) {
|
|
const slug = PLAN_TO_COURSE_SLUG[plan]
|
|
if (UNIVERSITY_COURSES.some((uc) => uc.slug === slug) && !enrolled.includes(slug)) {
|
|
enrolled.push(slug)
|
|
}
|
|
}
|
|
|
|
// 4. Merge locally purchased courses
|
|
const stored = getStoredEnrolledCourses()
|
|
for (const s of stored) {
|
|
if (!enrolled.includes(s)) {
|
|
enrolled.push(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
isAuthenticated: true,
|
|
token,
|
|
sub: claims.sub,
|
|
name: finalName,
|
|
handle,
|
|
email,
|
|
did,
|
|
enrolledClasses: enrolled,
|
|
backendPlan: plan || (isFull ? 'Max Membership' : enrolled.length > 0 ? 'Course License' : 'Registered Student'),
|
|
isFullAccess: isFull,
|
|
source: 'iam_token',
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Check if student signed up / completed tuition checkout directly on hanzo.university
|
|
try {
|
|
const studentHandle = localStorage.getItem('hanzo_portal_student_handle')
|
|
const studentName = localStorage.getItem('hanzo_portal_student_name')
|
|
const studentEmail = localStorage.getItem('hanzo_portal_student_email') || ''
|
|
const stored = getStoredEnrolledCourses()
|
|
|
|
if (studentHandle && studentName) {
|
|
return {
|
|
isAuthenticated: true,
|
|
name: formatHumanName(studentName),
|
|
handle: studentHandle.toLowerCase().replace(/[^a-z0-9_-]/g, ''),
|
|
email: studentEmail,
|
|
did: `did:hanzo:student:${studentHandle}`,
|
|
enrolledClasses: stored,
|
|
backendPlan: stored.includes('membership') ? 'University Pro All-Access' : stored.length > 0 ? 'Class Enrollment' : 'Registered Student',
|
|
isFullAccess: stored.includes('membership') || stored.includes('pro'),
|
|
source: 'checkout',
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
return null
|
|
}
|
|
|
|
/** Clear all student authentication and enrollment state from browser */
|
|
export function signOutStudent(): void {
|
|
if (typeof window === 'undefined') return
|
|
try {
|
|
localStorage.removeItem('hanzo_iam_access_token')
|
|
localStorage.removeItem('hanzo_iam_id_token')
|
|
localStorage.removeItem('hanzo_access_token')
|
|
localStorage.removeItem('token')
|
|
localStorage.removeItem('auth_token')
|
|
localStorage.removeItem('hanzo_portal_student_handle')
|
|
localStorage.removeItem('hanzo_portal_student_name')
|
|
localStorage.removeItem('hanzo_portal_student_email')
|
|
localStorage.removeItem('hanzo_portal_enrolled_courses')
|
|
localStorage.removeItem('hanzo_portal_selected_course')
|
|
} catch (_) {}
|
|
}
|
|
|
|
/** Asynchronously verify active plan and course subscription with the backend */
|
|
export async function syncBackendClassEnrollment(session: StudentSession): Promise<{
|
|
enrolledSlugs: string[]
|
|
planName?: string
|
|
status: 'ok' | 'offline' | 'unauthorized'
|
|
}> {
|
|
if (!session.token) {
|
|
return {
|
|
enrolledSlugs: session.enrolledClasses,
|
|
planName: session.backendPlan,
|
|
status: 'ok',
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Probe backend plan usage / limits endpoint
|
|
const res = await fetch('https://api.hanzo.ai/v1/ai/limits', {
|
|
headers: {
|
|
Authorization: `Bearer ${session.token}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (res.status === 401) {
|
|
return { enrolledSlugs: session.enrolledClasses, status: 'unauthorized' }
|
|
}
|
|
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const plan = (data.plan || '').toLowerCase()
|
|
const isFull = ['max-20x', 'max-5x', 'pro'].includes(plan)
|
|
const slugs = isFull
|
|
? UNIVERSITY_COURSES.map((c) => c.slug)
|
|
: PLAN_TO_COURSE_SLUG[plan]
|
|
? [PLAN_TO_COURSE_SLUG[plan]]
|
|
: session.enrolledClasses
|
|
|
|
return {
|
|
enrolledSlugs: slugs,
|
|
planName: data.plan,
|
|
status: 'ok',
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Hanzo University] Backend plan sync fallback to local session:', err)
|
|
}
|
|
|
|
return {
|
|
enrolledSlugs: session.enrolledClasses,
|
|
planName: session.backendPlan,
|
|
status: 'offline',
|
|
}
|
|
}
|