From 6bbcc40a9d6e577aa3d404dea3cfd7e2b0b3cebf Mon Sep 17 00:00:00 2001 From: YasunariIguchi Date: Thu, 31 Jul 2025 00:13:13 +0900 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E5=BD=93=E6=97=A5=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=80=91=E9=96=8B=E5=82=AC=E4=B8=AD=E3=80=81admin?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AB=E3=82=88=E3=82=8B?= =?UTF-8?q?=E3=82=A2=E3=82=A4=E3=82=B3=E3=83=B3=E5=89=8A=E9=99=A4=20#266?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/admin/check-permission/route.ts | 26 +++++ front/src/app/room/[roomId]/page.tsx | 12 ++- .../organisms/room/DeleteConfirmDialog.tsx | 63 +++++++++++ .../organisms/room/NonDraggableIcon.tsx | 102 ++++++++++++++---- .../templates/room/ActiveEventTemplate.tsx | 2 +- front/src/lib/api/checkAdminPermission.ts | 21 ++++ front/src/lib/api/deleteUser.ts | 15 +++ front/src/lib/db/finished_event_state.ts | 2 +- partykit/src/server.ts | 20 +++- 9 files changed, 232 insertions(+), 31 deletions(-) create mode 100644 front/src/app/api/admin/check-permission/route.ts create mode 100644 front/src/components/organisms/room/DeleteConfirmDialog.tsx create mode 100644 front/src/lib/api/checkAdminPermission.ts create mode 100644 front/src/lib/api/deleteUser.ts diff --git a/front/src/app/api/admin/check-permission/route.ts b/front/src/app/api/admin/check-permission/route.ts new file mode 100644 index 0000000..d9c5550 --- /dev/null +++ b/front/src/app/api/admin/check-permission/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth/auth"; +import { checkAdminPermission } from "@/lib/api/checkAdminPermission"; + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const roomId = searchParams.get("roomId"); + + if (!roomId) { + return NextResponse.json({ error: "roomId is required" }, { status: 400 }); + } + + const isAdmin = await checkAdminPermission(session.user.email, roomId); + + return NextResponse.json({ isAdmin }); + } catch (error) { + console.error("Error checking admin permission:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/front/src/app/room/[roomId]/page.tsx b/front/src/app/room/[roomId]/page.tsx index ca1ecc4..e95f967 100644 --- a/front/src/app/room/[roomId]/page.tsx +++ b/front/src/app/room/[roomId]/page.tsx @@ -45,10 +45,12 @@ export default async function RoomPage({ } return ( - + + + ) } @@ -60,6 +62,7 @@ export default async function RoomPage({ const res = await selectUserByEmail(user.email); if (res) { + user.id = res.id.toString(); user.name = res.name; user.bio = res.bio ?? ""; user.interests = res.interests ?? ""; @@ -72,6 +75,7 @@ export default async function RoomPage({ user.name = "New User"; const userId = await insertNewUser(user.email); + user.id = userId.toString(); await insertMemberAssignment(userId, event.userGroupId); } } diff --git a/front/src/components/organisms/room/DeleteConfirmDialog.tsx b/front/src/components/organisms/room/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..02c4961 --- /dev/null +++ b/front/src/components/organisms/room/DeleteConfirmDialog.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/atoms/shadcn/dialog"; +import { Button } from "@/components/atoms/shadcn/button"; + +interface DeleteConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + userName: string; +} + +export default function DeleteConfirmDialog({ + isOpen, + onClose, + onConfirm, + userName, +}: DeleteConfirmDialogProps) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleConfirm = async () => { + setIsDeleting(true); + try { + await onConfirm(); + } finally { + setIsDeleting(false); + onClose(); + } + }; + + return ( + + + + ユーザーを削除しますか? + + 「{userName}」をルームから削除します。この操作は取り消せません。 + + + + + + + + + ); +} \ No newline at end of file diff --git a/front/src/components/organisms/room/NonDraggableIcon.tsx b/front/src/components/organisms/room/NonDraggableIcon.tsx index 9c36279..f1cad07 100644 --- a/front/src/components/organisms/room/NonDraggableIcon.tsx +++ b/front/src/components/organisms/room/NonDraggableIcon.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; import { Avatar, AvatarFallback, @@ -6,32 +10,88 @@ import { import { User } from "lucide-react"; import { UserIcon } from "@/types/room/shared"; import UserIconTooltip from "./UserIconTooltip"; +import DeleteConfirmDialog from "./DeleteConfirmDialog"; +import { deleteUserFromRoom } from "@/lib/api/deleteUser"; export default function NonDraggableIcon({ - icon + icon, + roomId } : { - icon: UserIcon + icon: UserIcon, + roomId?: string }) { + const { data: session } = useSession(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + + // roomIdがない場合(終了したイベント)は削除機能を無効化 + const canDelete = roomId && isAdmin; + + useEffect(() => { + const checkAdmin = async () => { + if (session?.user?.email && roomId) { + try { + const response = await fetch(`/api/admin/check-permission?roomId=${roomId}`); + if (response.ok) { + const data = await response.json(); + setIsAdmin(data.isAdmin); + } + } catch (error) { + console.error("Failed to check admin permission:", error); + setIsAdmin(false); + } + } + }; + + checkAdmin(); + }, [session?.user?.email, roomId]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (canDelete) { + setShowDeleteDialog(true); + } + }; + + const handleDeleteConfirm = async () => { + if (!roomId) return; + try { + await deleteUserFromRoom(roomId, icon.user.id); + } catch (error) { + console.error("Failed to delete user:", error); + } + }; return ( -
- - e.preventDefault()} - onPointerDown={(e) => e.preventDefault()} - > - - - - -
+ <> +
+ + e.preventDefault()} + > + + + + +
+ + {canDelete && ( + setShowDeleteDialog(false)} + onConfirm={handleDeleteConfirm} + userName={icon.user.name || "Unknown User"} + /> + )} + ) } diff --git a/front/src/components/templates/room/ActiveEventTemplate.tsx b/front/src/components/templates/room/ActiveEventTemplate.tsx index 15f0c77..d44ba2e 100644 --- a/front/src/components/templates/room/ActiveEventTemplate.tsx +++ b/front/src/components/templates/room/ActiveEventTemplate.tsx @@ -82,7 +82,7 @@ export default function ActiveEventTemplate({ {imgUrl && } {userIcons.map((icon, i) => { if (icon.user.id !== userId) { - return + return } else { return } diff --git a/front/src/lib/api/checkAdminPermission.ts b/front/src/lib/api/checkAdminPermission.ts new file mode 100644 index 0000000..0bb08c0 --- /dev/null +++ b/front/src/lib/api/checkAdminPermission.ts @@ -0,0 +1,21 @@ +import { selectEventById } from "@/lib/db/event"; +import { isUserAdminOfGroup } from "@/lib/db/user_group_assignment"; +import { selectUserByEmail } from "@/lib/db/user"; + +export async function checkAdminPermission(userEmail: string, roomId: string): Promise { + try { + const eventId = Number(roomId); + if (isNaN(eventId)) return false; + + const event = await selectEventById(eventId); + if (!event) return false; + + const user = await selectUserByEmail(userEmail); + if (!user) return false; + + return await isUserAdminOfGroup(user.id, event.userGroupId); + } catch (error) { + console.error("Failed to check admin permission:", error); + return false; + } +} \ No newline at end of file diff --git a/front/src/lib/api/deleteUser.ts b/front/src/lib/api/deleteUser.ts new file mode 100644 index 0000000..797e8bd --- /dev/null +++ b/front/src/lib/api/deleteUser.ts @@ -0,0 +1,15 @@ +import { PARTYKIT_URL } from "@/app/env"; + +export async function deleteUserFromRoom(roomId: string, userId: string): Promise { + const response = await fetch(`${PARTYKIT_URL}/parties/main/${roomId}`, { + method: "DELETE", + body: JSON.stringify(userId), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to delete user: ${response.status} ${response.statusText}`); + } +} \ No newline at end of file diff --git a/front/src/lib/db/finished_event_state.ts b/front/src/lib/db/finished_event_state.ts index 41be4c3..da82e6e 100644 --- a/front/src/lib/db/finished_event_state.ts +++ b/front/src/lib/db/finished_event_state.ts @@ -14,7 +14,7 @@ export async function selectFinishedEventState(eventId: number) { .select() .from(finishedEventState) .where(eq(finishedEventState.eventId, eventId)); - return res[0].userIcons as UserIcon[] ?? null; + return res[0]?.userIcons as UserIcon[] ?? null; } /** diff --git a/partykit/src/server.ts b/partykit/src/server.ts index 7ac7d5b..c0de090 100644 --- a/partykit/src/server.ts +++ b/partykit/src/server.ts @@ -58,10 +58,22 @@ export default class Server implements Party.Server { } async onRequest(request: Party.Request) { + // CORS headers + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }; + + // Handle preflight requests + if (request.method === "OPTIONS") { + return new Response(null, { status: 200, headers: corsHeaders }); + } + const userIcons = await this.ensureLoadUserIcons(); if (request.method === "GET") { - return new Response(JSON.stringify(userIcons)); + return new Response(JSON.stringify(userIcons), { headers: corsHeaders }); } if (request.method === "POST") { @@ -76,7 +88,7 @@ export default class Server implements Party.Server { await this.room.storage.put("userIcons", this.userIcons); } - return new Response(JSON.stringify(user.id)); + return new Response(JSON.stringify(user.id), { headers: corsHeaders }); } if (request.method === "DELETE") { @@ -88,10 +100,10 @@ export default class Server implements Party.Server { }); await this.room.storage.put("userIcons", this.userIcons); - return new Response(null, { status: 204 }); + return new Response(null, { status: 204, headers: corsHeaders }); } - return new Response("Not Found", { status: 404 }); + return new Response("Not Found", { status: 404, headers: corsHeaders }); } async onConnect(conn: Party.Connection, _ctx: Party.ConnectionContext) {