From 28c62d1624873884f9a6b2ec4484cf547d51cd6d Mon Sep 17 00:00:00 2001 From: rtaserver <142883865+rtaserver@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:00:58 +0000 Subject: [PATCH 1/2] fix: scope history and workspace to Discord user Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- prisma/schema.prisma | 5 +++ src/app/api/history/route.ts | 53 ++++++++++++++++++++-------- src/app/page.tsx | 2 ++ src/components/session-workspace.tsx | 25 +++++++++++++ src/lib/auth.ts | 10 ++++-- src/lib/store.ts | 20 +++++++++++ 6 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 src/components/session-workspace.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b2383f..3b94029 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,8 +48,13 @@ model AudioUpload { uploadError String? moderationStatus String? // "Pending" | "Reviewing" | "Approved" | "Rejected" moderationReason String? + // Discord ID of the signed-in owner. Existing rows without an owner are + // deliberately not shown to anyone, preventing legacy shared history. + ownerId String? accountId String? account RobloxAccount? @relation(fields: [accountId], references: [id]) + + @@index([ownerId, createdAt]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/app/api/history/route.ts b/src/app/api/history/route.ts index 44ad690..a35d270 100644 --- a/src/app/api/history/route.ts +++ b/src/app/api/history/route.ts @@ -1,16 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; +import { getAuthenticatedUserId } from "@/lib/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -/** GET /api/history — list all conversion/upload history (newest first) */ +async function requireUser() { + const ownerId = await getAuthenticatedUserId(); + if (!ownerId) { + return { error: NextResponse.json({ ok: false, error: "Silakan login dengan Discord terlebih dahulu." }, { status: 401 }) }; + } + return { ownerId }; +} + +/** GET /api/history — only the current Discord user's history, newest first. */ export async function GET() { + const auth = await requireUser(); + if ("error" in auth) return auth.error; + try { const items = await db.audioUpload.findMany({ + where: { ownerId: auth.ownerId }, orderBy: { createdAt: "desc" }, take: 100, - include: { account: true }, }); return NextResponse.json({ ok: true, items }); } catch (e) { @@ -18,12 +30,16 @@ export async function GET() { } } -/** POST /api/history — save a conversion/upload record */ +/** POST /api/history — create a record owned by the current Discord user. */ export async function POST(req: NextRequest) { + const auth = await requireUser(); + if ("error" in auth) return auth.error; + try { const body = await req.json(); const created = await db.audioUpload.create({ data: { + ownerId: auth.ownerId, sourceType: body.sourceType || "file", sourceUrl: body.sourceUrl || null, sourceTitle: body.sourceTitle || null, @@ -44,7 +60,8 @@ export async function POST(req: NextRequest) { uploadError: body.uploadError || null, moderationStatus: body.moderationStatus || null, moderationReason: body.moderationReason || null, - accountId: body.accountId || null, + // Never accept ownerId from the browser. accountId is intentionally + // omitted: Roblox accounts are browser-session credentials, not shared DB rows. }, }); return NextResponse.json({ ok: true, item: created }); @@ -53,35 +70,43 @@ export async function POST(req: NextRequest) { } } -/** PATCH /api/history?id=xxx — update moderation status of a record */ +/** PATCH /api/history?id=xxx — update only a record owned by this user. */ export async function PATCH(req: NextRequest) { + const auth = await requireUser(); + if ("error" in auth) return auth.error; + try { - const url = new URL(req.url); - const id = url.searchParams.get("id"); + const id = new URL(req.url).searchParams.get("id"); if (!id) return NextResponse.json({ ok: false, error: "id wajib diisi" }, { status: 400 }); const body = await req.json(); - const updated = await db.audioUpload.update({ - where: { id }, + const result = await db.audioUpload.updateMany({ + where: { id, ownerId: auth.ownerId }, data: { moderationStatus: body.moderationStatus || null, moderationReason: body.moderationReason || null, uploadStatus: body.uploadStatus || undefined, + uploadError: body.uploadError || undefined, robloxAssetId: body.robloxAssetId || undefined, }, }); - return NextResponse.json({ ok: true, item: updated }); + if (!result.count) return NextResponse.json({ ok: false, error: "Riwayat tidak ditemukan." }, { status: 404 }); + const item = await db.audioUpload.findFirst({ where: { id, ownerId: auth.ownerId } }); + return NextResponse.json({ ok: true, item }); } catch (e) { return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 }); } } -/** DELETE /api/history?id=xxx — delete a record */ +/** DELETE /api/history?id=xxx — delete only a record owned by this user. */ export async function DELETE(req: NextRequest) { + const auth = await requireUser(); + if ("error" in auth) return auth.error; + try { - const url = new URL(req.url); - const id = url.searchParams.get("id"); + const id = new URL(req.url).searchParams.get("id"); if (!id) return NextResponse.json({ ok: false, error: "id wajib diisi" }, { status: 400 }); - await db.audioUpload.delete({ where: { id } }); + const result = await db.audioUpload.deleteMany({ where: { id, ownerId: auth.ownerId } }); + if (!result.count) return NextResponse.json({ ok: false, error: "Riwayat tidak ditemukan." }, { status: 404 }); return NextResponse.json({ ok: true }); } catch (e) { return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 593ba68..11bec34 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import { HistoryList } from "@/components/history-list"; import { PreviewList } from "@/components/preview-list"; import { ThemeToggle } from "@/components/theme-toggle"; import { UserMenu } from "@/components/user-menu"; +import { SessionWorkspace } from "@/components/session-workspace"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -21,6 +22,7 @@ export default function Home() { return (
+ {/* Header */}
diff --git a/src/components/session-workspace.tsx b/src/components/session-workspace.tsx new file mode 100644 index 0000000..3230ba1 --- /dev/null +++ b/src/components/session-workspace.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { useConverter } from "@/lib/store"; + +/** + * Keeps client-side persisted state scoped to the Discord account. The server + * remains the authority for history; this additionally protects shared devices + * from displaying the previous user's Roblox workspace during a session switch. + */ +export function SessionWorkspace() { + const { data: session, status } = useSession(); + const setOwnerId = useConverter((state) => state.setOwnerId); + + useEffect(() => { + if (status === "authenticated" && session.user?.id) { + setOwnerId(session.user.id); + } else if (status === "unauthenticated") { + setOwnerId(null); + } + }, [session?.user?.id, setOwnerId, status]); + + return null; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7d499ed..ad7f72b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,4 @@ -import NextAuth, { type NextAuthOptions } from "next-auth"; +import NextAuth, { getServerSession, type NextAuthOptions } from "next-auth"; import DiscordProvider from "next-auth/providers/discord"; import type { Session } from "next-auth"; @@ -59,7 +59,7 @@ async function notifyLogin(user: { name?: string | null; email?: string | null; // `trustHost` is a v4 runtime option (read from env via NEXTAUTH_URL or // AUTH_TRUST_HOST) — passing it as a config key was never typed. We work // around the type with `as NextAuthOptions`. -const authOptions: NextAuthOptions = { +export const authOptions: NextAuthOptions = { providers: [ DiscordProvider({ clientId: process.env.AUTH_DISCORD_ID!, @@ -85,6 +85,12 @@ const authOptions: NextAuthOptions = { }, }; +/** The Discord user ID is the tenant boundary for all user-owned data. */ +export async function getAuthenticatedUserId(): Promise { + const session = await getServerSession(authOptions); + return session?.user?.id ?? null; +} + const handler = NextAuth(authOptions); export const GET = handler; diff --git a/src/lib/store.ts b/src/lib/store.ts index b70e51e..18e7169 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -130,6 +130,9 @@ const DEFAULT_SETTINGS: ProcessSettings = { }; interface ConverterState { + // Discord user ID that owns this browser-persisted workspace. + ownerId: string | null; + // Sources queue (multi) sources: SourceItem[]; sourceLoading: boolean; @@ -158,6 +161,10 @@ interface ConverterState { // History historyVersion: number; + // Switch workspace when the Discord session changes. This prevents a user + // on a shared browser from seeing another user's Roblox identity or queue. + setOwnerId: (ownerId: string | null) => void; + // Actions - Sources addSource: (s: Omit) => void; removeSource: (id: string) => void; @@ -228,6 +235,7 @@ if (typeof window !== "undefined") { export const useConverter = create()( persist( (set, get) => ({ + ownerId: null, sources: [], sourceLoading: false, sourceError: null, @@ -250,6 +258,17 @@ export const useConverter = create()( historyVersion: 0, + setOwnerId: (ownerId) => set((state) => { + if (state.ownerId === ownerId) return { ownerId }; + // Do not carry any persisted browser state across Discord accounts. + return { + ownerId, + sources: [], cookies: "", settings: { ...DEFAULT_SETTINGS }, activePresetId: "none", + processedMap: {}, uploadMap: {}, account: null, + sourceError: null, processError: null, verifyError: null, + }; + }), + // Sources addSource: (s) => set((state) => ({ @@ -369,6 +388,7 @@ export const useConverter = create()( // the verified identity across reloads, but the user has to re-verify // (paste key + click Verify) before any upload can resume. partialize: (state) => ({ + ownerId: state.ownerId, account: state.account ? { ...state.account, apiKey: "" } : null, From df127e615b32c2cba899b187c5e25fc23b5b3403 Mon Sep 17 00:00:00 2001 From: rtaserver <142883865+rtaserver@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:06:58 +0000 Subject: [PATCH 2/2] fix: distinguish Roblox upload from moderation approval Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/components/history-list.tsx | 8 +++++--- src/components/roblox-panel.tsx | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/history-list.tsx b/src/components/history-list.tsx index b31b0ea..4a5f796 100644 --- a/src/components/history-list.tsx +++ b/src/components/history-list.tsx @@ -200,8 +200,8 @@ export function HistoryList() {

{item.assetName}

{isUploaded && !item.moderationStatus && ( - - Sukses + + Status moderasi belum tersedia )} {isUploaded && item.moderationStatus && ( @@ -276,6 +276,8 @@ function getModBadge(status: string | null): { icon: React.ElementType; label: s case "Pending": return { icon: Clock, label: "Menunggu", className: "text-amber-500 border-amber-500/30" }; default: - return { icon: CheckCircle2, label: "Sukses", className: "text-emerald-500" }; + // "uploaded" only confirms the file reached Roblox, not that the + // asset passed Roblox's independent moderation. + return { icon: Clock, label: "Status belum tersedia", className: "text-slate-500 border-slate-500/30" }; } } diff --git a/src/components/roblox-panel.tsx b/src/components/roblox-panel.tsx index ee41def..68aeda1 100644 --- a/src/components/roblox-panel.tsx +++ b/src/components/roblox-panel.tsx @@ -282,8 +282,12 @@ export function RobloxPanel() { setUploading(false); refreshHistory(); if (success > 0) { - toast.success(`${success} audio terupload`, { - description: failed > 0 ? `${failed} gagal` : "Menunggu moderasi Roblox...", + toast.success(`${success} audio dikirim ke Roblox`, { + // Creating an asset is not the same as being allowed to use it. + // Roblox makes the final moderation decision asynchronously. + description: failed > 0 + ? `${failed} gagal. Audio yang dikirim tetap menunggu moderasi Roblox.` + : "Upload selesai, tetapi belum disetujui. Menunggu moderasi Roblox...", }); } }; @@ -549,7 +553,8 @@ function getModConfig(state: ModerationState | null) { case "Pending": return { icon: Clock, label: "Menunggu", color: "#f59e0b" }; default: - return { icon: CheckCircle2, label: "Sukses", color: "#10b981" }; + // Never call an asset successful before Roblox explicitly approves it. + return { icon: Clock, label: "Status belum tersedia", color: "#94a3b8" }; } }