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" };
}
}