a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Watch progress and unlocking: - Player samples the playhead and reports the ranges actually played, so skipped sections earn no credit and early plays are not lost - Server merges ranges into compact segment rows; progress never regresses - Single 80% unlock rule enforced server-side (lib/watch-progress.ts); the Unlock button and automatic unlock now agree - Lock state and progress bar update live from each progress save - Playlist is derived from the video, so unlocks work for shared playlists and for links from watch history and liked videos - First/previous video decided by playlist position, not index numbers Access control: - Video API and HLS/MP4 routes require a signed-in viewer who is enrolled and has the video unlocked; thumbnails require sign-in - Enrolment sync reads course codes from the allow-list, not the request - /api/admin/meta is admin-only - Account status, role and levels are re-checked from the database during a session, so deactivated students lose access within a minute Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
178 lines
4.6 KiB
TypeScript
178 lines
4.6 KiB
TypeScript
// lib/auth-options.ts
|
|
import { NextAuthOptions } from "next-auth";
|
|
import GoogleProvider from "next-auth/providers/google";
|
|
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
|
import { prisma } from "./prisma";
|
|
import { normalizeEmail } from "./normalize-email";
|
|
import { getAccountStatus } from "./account-status";
|
|
|
|
export const authOptions: NextAuthOptions = {
|
|
adapter: PrismaAdapter(prisma),
|
|
|
|
providers: [
|
|
GoogleProvider({
|
|
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
|
authorization: {
|
|
params: {
|
|
prompt: "select_account",
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
|
|
pages: {
|
|
signIn: "/login",
|
|
error: "/login/unauthorized", // Error code passed in query string as ?error=
|
|
},
|
|
|
|
session: {
|
|
strategy: "jwt",
|
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
},
|
|
|
|
cookies: {
|
|
sessionToken: {
|
|
name: `__Secure-next-auth.session-token`,
|
|
options: {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
secure: process.env.NODE_ENV === 'production',
|
|
},
|
|
},
|
|
},
|
|
|
|
callbacks: {
|
|
/**
|
|
* SIGN-IN GATE
|
|
* This callback MUST NOT mutate users.
|
|
* It only decides: allowed or not.
|
|
*/
|
|
async signIn({ user, account }) {
|
|
if (!account || !user?.email) {
|
|
return "/login/unauthorized";
|
|
}
|
|
|
|
const normalizedEmail = normalizeEmail(user.email);
|
|
|
|
if (!normalizedEmail) {
|
|
return "/login/unauthorized";
|
|
}
|
|
|
|
/**
|
|
* 1️⃣ Enforce: one OAuth account → one user
|
|
*/
|
|
const existingAccount = await prisma.account.findUnique({
|
|
where: {
|
|
provider_providerAccountId: {
|
|
provider: account.provider,
|
|
providerAccountId: account.providerAccountId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (existingAccount && existingAccount.userId !== (user as any).id) {
|
|
// OAuth account already linked to another user → block
|
|
return "/login/unauthorized";
|
|
}
|
|
|
|
/**
|
|
* 2️⃣ Check if user already exists
|
|
*/
|
|
const existingUser = await prisma.user.findFirst({
|
|
where: {
|
|
email: {
|
|
equals: normalizedEmail,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* 3️⃣ Admins / Superadmins are always allowed
|
|
*/
|
|
if (
|
|
existingUser &&
|
|
(existingUser.role === "admin" || existingUser.role === "superadmin")
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 4️⃣ Regular users must be explicitly approved
|
|
*/
|
|
const allowedStudent = await prisma.allowedStudent.findFirst({
|
|
where: {
|
|
email: {
|
|
equals: normalizedEmail,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!allowedStudent || !allowedStudent.active) {
|
|
return "/login/unauthorized";
|
|
}
|
|
|
|
/**
|
|
* IMPORTANT:
|
|
* - DO NOT create users here
|
|
* - DO NOT link accounts here
|
|
* PrismaAdapter will create the user on first successful login
|
|
*/
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* JWT CALLBACK
|
|
* Runs on sign-in and again every time the session is read. Role, course
|
|
* levels and whether the account is still allowed always come from the
|
|
* database, so removing a student takes effect without waiting for their
|
|
* 30-day session to expire.
|
|
*/
|
|
async jwt({ token, user }) {
|
|
if (user?.id) token.id = user.id;
|
|
|
|
const userId = (token.id ?? token.sub) as string | undefined;
|
|
if (userId) {
|
|
try {
|
|
// A fresh sign-in always re-reads the database.
|
|
const status = await getAccountStatus(userId, { fresh: !!user });
|
|
token.id = userId;
|
|
token.role = status.role;
|
|
token.levels = status.levels;
|
|
token.disabled = !status.allowed;
|
|
} catch (err) {
|
|
// Database hiccup: keep what the token already says rather than
|
|
// signing everyone out.
|
|
console.error('[auth] account status check failed', err);
|
|
}
|
|
}
|
|
|
|
token.role = token.role ?? "user";
|
|
|
|
return token;
|
|
},
|
|
|
|
/**
|
|
* SESSION CALLBACK
|
|
* Only exposes data already trusted in the token. A disabled account gets
|
|
* a session with no user, which every route treats as signed out.
|
|
*/
|
|
async session({ session, token }) {
|
|
if (token.disabled) {
|
|
return { expires: session.expires };
|
|
}
|
|
|
|
if (session.user) {
|
|
(session.user as any).id = token.id ?? null;
|
|
(session.user as any).role = token.role ?? "user";
|
|
(session.user as any).levels = token.levels ?? "";
|
|
}
|
|
|
|
return session;
|
|
},
|
|
},
|
|
};
|