Upgrade Next.js to 16.4.6 and show locks on latest videos
Deploy Vault CMS / deploy (push) Failing after 4m1s

- Upgrade next and eslint-config-next from 16.3.5 to 16.4.6. The older
  version has critical advisories, which made the Docker build's
  `pnpm audit --audit-level critical` step fail and blocked deploys.
- /api/videos/latest now reports whether the viewer can open each video;
  the dashboard shows a lock and the reason instead of opening the player.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-11-11 17:56:19 +02:00
parent c98b5c5692
commit 9a73058eb4
5 changed files with 343 additions and 332 deletions
+17 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options'; import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { checkVideoAccess } from '@/lib/video-access';
export async function GET() { export async function GET() {
try { try {
@@ -70,7 +71,22 @@ export async function GET() {
take: 10, take: 10,
}); });
return NextResponse.json({ videos }); // Tell the dashboard which of these the user can actually open, so locked
// ones show a lock instead of leading to a refusal.
const role = (session.user as any).role;
const viewer = { id: user.id, isAdmin: role === 'admin' || role === 'superadmin' };
const withAccess = await Promise.all(
videos.map(async (video) => {
const access = await checkVideoAccess(viewer, video.id, { peek: true });
return {
...video,
accessible: access.ok,
lockReason: access.ok ? null : access.error,
};
})
);
return NextResponse.json({ videos: withAccess });
} catch (err: any) { } catch (err: any) {
console.error('GET /api/videos/latest error', err); console.error('GET /api/videos/latest error', err);
return NextResponse.json( return NextResponse.json(
+10
View File
@@ -25,6 +25,7 @@ import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { toast } from 'sonner';
type VideoItem = { type VideoItem = {
id: string; id: string;
@@ -248,6 +249,10 @@ export default function DashboardClient() {
<Card <Card
className="@container/card overflow-hidden cursor-pointer" className="@container/card overflow-hidden cursor-pointer"
onClick={() => { onClick={() => {
if (v.accessible === false) {
toast.error(v.lockReason ?? 'This video is locked.');
return;
}
if (v.playlist?.id) { if (v.playlist?.id) {
handleOpenVideo(v.playlist.id, v.id); handleOpenVideo(v.playlist.id, v.id);
} }
@@ -261,6 +266,11 @@ export default function DashboardClient() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
/> />
{v.accessible === false ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
) : null}
</div> </div>
<div className="flex gap-2 items-start pt-2"> <div className="flex gap-2 items-start pt-2">
{v.uploader?.image && ( {v.uploader?.image && (
+11 -4
View File
@@ -30,7 +30,12 @@ const allowed = new Map<string, number>();
* be open to them: first in its playlist, instant access, already unlocked, or * be open to them: first in its playlist, instant access, already unlocked, or
* earned by completing the video before it. * earned by completing the video before it.
*/ */
export async function checkVideoAccess(viewer: Viewer, videoId: string): Promise<AccessResult> { export async function checkVideoAccess(
viewer: Viewer,
videoId: string,
// `peek` only answers the question (for listings); it records nothing.
opts: { peek?: boolean } = {}
): Promise<AccessResult> {
if (viewer.isAdmin) return { ok: true }; if (viewer.isAdmin) return { ok: true };
const key = `${viewer.id}:${videoId}`; const key = `${viewer.id}:${videoId}`;
@@ -41,7 +46,7 @@ export async function checkVideoAccess(viewer: Viewer, videoId: string): Promise
const where = { userId_videoId: { userId: viewer.id, videoId } }; const where = { userId_videoId: { userId: viewer.id, videoId } };
if (check.ok) { if (check.ok) {
if (!check.free) { if (!check.free && !opts.peek) {
// Earned but never recorded (e.g. completed before unlocks were tracked). // Earned but never recorded (e.g. completed before unlocks were tracked).
await prisma.videoUnlock.upsert({ await prisma.videoUnlock.upsert({
where, where,
@@ -56,7 +61,9 @@ export async function checkVideoAccess(viewer: Viewer, videoId: string): Promise
if (!unlocked) return { ok: false, status: check.status, error: check.error }; if (!unlocked) return { ok: false, status: check.status, error: check.error };
} }
if (allowed.size >= MAX_ENTRIES) allowed.clear(); if (!opts.peek) {
allowed.set(key, Date.now() + TTL_MS); if (allowed.size >= MAX_ENTRIES) allowed.clear();
allowed.set(key, Date.now() + TTL_MS);
}
return { ok: true }; return { ok: true };
} }
+2 -2
View File
@@ -45,7 +45,7 @@
"formidable": "^3.5.4", "formidable": "^3.5.4",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"next": "16.3.5", "next": "16.4.6",
"next-auth": "^4.24.15", "next-auth": "^4.24.15",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.3.0", "react": "19.3.0",
@@ -67,7 +67,7 @@
"@types/unzipper": "^0.10.10", "@types/unzipper": "^0.10.10",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "16.4.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
+303 -325
View File
File diff suppressed because it is too large Load Diff