Complete WatchLink V1 realtime features
This commit is contained in:
97
src/lib/admin-actions.ts
Normal file
97
src/lib/admin-actions.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "./prisma";
|
||||
import { requireCurrentUser, userIsAdmin } from "./session";
|
||||
|
||||
async function requireAdmin() {
|
||||
const user = await requireCurrentUser();
|
||||
if (!userIsAdmin(user)) redirect("/dashboard");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function disableUser(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const userId = String(formData.get("userId") || "");
|
||||
if (!userId || userId === admin.id) return;
|
||||
await prisma.user.update({ where: { id: userId }, data: { disabledAt: new Date() } });
|
||||
await audit(admin.id, "admin.user.disable", { userId });
|
||||
revalidateAdmin();
|
||||
}
|
||||
|
||||
export async function enableUser(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const userId = String(formData.get("userId") || "");
|
||||
if (!userId) return;
|
||||
await prisma.user.update({ where: { id: userId }, data: { disabledAt: null } });
|
||||
await audit(admin.id, "admin.user.enable", { userId });
|
||||
revalidateAdmin();
|
||||
}
|
||||
|
||||
export async function grantAdminRole(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const userId = String(formData.get("userId") || "");
|
||||
if (!userId) return;
|
||||
const role = await prisma.role.upsert({
|
||||
where: { name: "admin" },
|
||||
update: {},
|
||||
create: { name: "admin", description: "Full system administrator" }
|
||||
});
|
||||
await prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId, roleId: role.id } },
|
||||
update: {},
|
||||
create: { userId, roleId: role.id }
|
||||
});
|
||||
await audit(admin.id, "admin.user.role.grant", { userId, role: "admin" });
|
||||
revalidateAdmin();
|
||||
}
|
||||
|
||||
export async function revokeAdminRole(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const userId = String(formData.get("userId") || "");
|
||||
if (!userId || userId === admin.id) return;
|
||||
const role = await prisma.role.findUnique({ where: { name: "admin" } });
|
||||
if (!role) return;
|
||||
await prisma.userRole.deleteMany({ where: { userId, roleId: role.id } });
|
||||
await audit(admin.id, "admin.user.role.revoke", { userId, role: "admin" });
|
||||
revalidateAdmin();
|
||||
}
|
||||
|
||||
export async function createInstanceInvite(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const roomId = String(formData.get("roomId") || "") || null;
|
||||
const expiresDays = Number(formData.get("expiresDays") || 0);
|
||||
const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null;
|
||||
const invite = await prisma.invite.create({
|
||||
data: {
|
||||
code: randomBytes(12).toString("base64url"),
|
||||
creatorId: admin.id,
|
||||
roomId,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
await audit(admin.id, "admin.invite.create", { inviteId: invite.id, roomId });
|
||||
revalidateAdmin();
|
||||
redirect("/admin?tab=Invites&saved=1");
|
||||
}
|
||||
|
||||
export async function revokeInvite(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const inviteId = String(formData.get("inviteId") || "");
|
||||
if (!inviteId) return;
|
||||
await prisma.invite.update({ where: { id: inviteId }, data: { status: "REVOKED" } });
|
||||
await audit(admin.id, "admin.invite.revoke", { inviteId });
|
||||
revalidateAdmin();
|
||||
}
|
||||
|
||||
async function audit(actorId: string, action: string, metadata: Record<string, unknown>) {
|
||||
await prisma.auditEvent.create({ data: { actorId, action, metadata } });
|
||||
}
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/rooms");
|
||||
}
|
||||
@@ -52,6 +52,82 @@ export async function declineFriendRequest(formData: FormData) {
|
||||
await updateIncomingRequest(formData, "DECLINED");
|
||||
}
|
||||
|
||||
export async function removeFriendship(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const friendshipId = String(formData.get("friendshipId") || "");
|
||||
if (!friendshipId) return;
|
||||
|
||||
await prisma.friendship.deleteMany({
|
||||
where: {
|
||||
id: friendshipId,
|
||||
OR: [{ requesterId: user.id }, { receiverId: user.id }]
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePeople();
|
||||
}
|
||||
|
||||
export async function cancelFriendRequest(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const friendshipId = String(formData.get("friendshipId") || "");
|
||||
if (!friendshipId) return;
|
||||
|
||||
await prisma.friendship.deleteMany({
|
||||
where: {
|
||||
id: friendshipId,
|
||||
requesterId: user.id,
|
||||
status: "PENDING"
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePeople();
|
||||
}
|
||||
|
||||
export async function blockUser(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const targetId = String(formData.get("targetId") || "");
|
||||
if (!targetId || targetId === user.id) return;
|
||||
|
||||
const existing = await prisma.friendship.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: user.id, receiverId: targetId },
|
||||
{ requesterId: targetId, receiverId: user.id }
|
||||
]
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.friendship.update({
|
||||
where: { id: existing.id },
|
||||
data: { requesterId: user.id, receiverId: targetId, status: "BLOCKED" }
|
||||
});
|
||||
} else {
|
||||
await prisma.friendship.create({
|
||||
data: { requesterId: user.id, receiverId: targetId, status: "BLOCKED" }
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePeople();
|
||||
}
|
||||
|
||||
export async function unblockUser(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const friendshipId = String(formData.get("friendshipId") || "");
|
||||
if (!friendshipId) return;
|
||||
|
||||
await prisma.friendship.deleteMany({
|
||||
where: {
|
||||
id: friendshipId,
|
||||
requesterId: user.id,
|
||||
status: "BLOCKED"
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePeople();
|
||||
}
|
||||
|
||||
async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "DECLINED") {
|
||||
const user = await requireCurrentUser();
|
||||
const friendshipId = String(formData.get("friendshipId") || "");
|
||||
@@ -70,3 +146,9 @@ async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "D
|
||||
revalidatePath("/people");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
function revalidatePeople() {
|
||||
revalidatePath("/friends");
|
||||
revalidatePath("/people");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function addMediaToRoom(formData: FormData) {
|
||||
|
||||
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
|
||||
|
||||
await prisma.mediaSource.create({
|
||||
const created = await prisma.mediaSource.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
submitterId: user.id,
|
||||
@@ -49,6 +49,10 @@ export async function addMediaToRoom(formData: FormData) {
|
||||
provider: media.provider,
|
||||
originalUrl: media.originalUrl,
|
||||
playbackUrl: media.playbackUrl,
|
||||
mediaSourceId: created.id,
|
||||
status: "PAUSED",
|
||||
position: 0,
|
||||
rate: 1,
|
||||
updatedBy: user.username,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
@@ -106,6 +110,9 @@ export async function setCurrentMedia(formData: FormData) {
|
||||
originalUrl: media.originalUrl,
|
||||
playbackUrl: media.playbackUrl,
|
||||
mediaSourceId: media.id,
|
||||
status: "PLAYING",
|
||||
position: 0,
|
||||
rate: 1,
|
||||
updatedBy: user.username,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "./prisma";
|
||||
@@ -47,3 +48,27 @@ export async function updateProfile(formData: FormData) {
|
||||
revalidatePath("/dashboard");
|
||||
redirect("/account/settings?saved=1");
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const currentPassword = String(formData.get("currentPassword") || "");
|
||||
const newPassword = String(formData.get("newPassword") || "");
|
||||
const confirmPassword = String(formData.get("confirmPassword") || "");
|
||||
|
||||
if (newPassword.length < 10 || newPassword !== confirmPassword) {
|
||||
redirect("/account/settings?error=password");
|
||||
}
|
||||
|
||||
const account = await prisma.user.findUnique({ where: { id: user.id }, select: { passwordHash: true } });
|
||||
if (!account || !(await compare(currentPassword, account.passwordHash))) {
|
||||
redirect("/account/settings?error=password");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: await hash(newPassword, 12) }
|
||||
});
|
||||
|
||||
revalidatePath("/account/settings");
|
||||
redirect("/account/settings?saved=password");
|
||||
}
|
||||
|
||||
@@ -54,6 +54,65 @@ export async function createRoom(formData: FormData) {
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}`);
|
||||
}
|
||||
|
||||
export async function deleteRoom(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const roomId = String(formData.get("roomId") || "");
|
||||
if (!roomId) return;
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
|
||||
});
|
||||
|
||||
if (!room || room.isPersonal || !canManageRoom(user, room)) return;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.auditEvent.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
roomId: room.id,
|
||||
action: "room.delete",
|
||||
metadata: { slug: room.slug, name: room.name }
|
||||
}
|
||||
}),
|
||||
prisma.room.delete({ where: { id: room.id } })
|
||||
]);
|
||||
|
||||
revalidatePath("/rooms");
|
||||
revalidatePath("/dashboard");
|
||||
redirect("/rooms?deleted=1");
|
||||
}
|
||||
|
||||
export async function resetPersonalRoom(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const roomId = String(formData.get("roomId") || "");
|
||||
if (!roomId) return;
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
|
||||
});
|
||||
|
||||
if (!room || !room.isPersonal || !canManageRoom(user, room)) return;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.mediaSource.deleteMany({ where: { roomId: room.id } }),
|
||||
prisma.roomMessage.deleteMany({ where: { roomId: room.id } }),
|
||||
prisma.room.update({ where: { id: room.id }, data: { currentState: null } }),
|
||||
prisma.auditEvent.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
roomId: room.id,
|
||||
action: "room.reset",
|
||||
metadata: { slug: room.slug }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
revalidateRoom(room.slug);
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Settings&saved=1`);
|
||||
}
|
||||
|
||||
export async function updateRoomSettings(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const roomId = String(formData.get("roomId") || "");
|
||||
@@ -112,6 +171,33 @@ export async function addRoomMember(formData: FormData) {
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
|
||||
}
|
||||
|
||||
export async function removeRoomMember(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const roomId = String(formData.get("roomId") || "");
|
||||
const userId = String(formData.get("userId") || "");
|
||||
if (!roomId || !userId) return;
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
|
||||
});
|
||||
|
||||
if (!room || !canManageRoom(user, room) || userId === room.ownerId) return;
|
||||
|
||||
await prisma.roomMember.deleteMany({ where: { roomId: room.id, userId } });
|
||||
await prisma.auditEvent.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
roomId: room.id,
|
||||
action: "room.member.remove",
|
||||
metadata: { userId }
|
||||
}
|
||||
});
|
||||
|
||||
revalidateRoom(room.slug);
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
|
||||
}
|
||||
|
||||
function canManageRoom(
|
||||
user: Awaited<ReturnType<typeof requireCurrentUser>>,
|
||||
room: { ownerId: string | null; members: Array<{ canManage: boolean }> }
|
||||
|
||||
@@ -45,10 +45,11 @@ export async function getCurrentUser() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prisma.user.findUnique({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { roles: { include: { role: true } } }
|
||||
});
|
||||
return user?.disabledAt ? null : user;
|
||||
}
|
||||
|
||||
export function userIsAdmin(user: Awaited<ReturnType<typeof getCurrentUser>>) {
|
||||
|
||||
@@ -16,34 +16,64 @@ function normalizeUsername(value: FormDataEntryValue | null) {
|
||||
|
||||
export async function registerUser(formData: FormData) {
|
||||
const settings = await getAppSettings();
|
||||
if (settings.registrationMode !== "OPEN") {
|
||||
if (settings.registrationMode === "DISABLED") {
|
||||
redirect("/register?error=closed");
|
||||
}
|
||||
|
||||
const username = normalizeUsername(formData.get("username"));
|
||||
const password = String(formData.get("password") || "");
|
||||
const inviteCode = String(formData.get("inviteCode") || "").trim();
|
||||
|
||||
if (!username || password.length < 10) {
|
||||
redirect("/register?error=invalid");
|
||||
}
|
||||
|
||||
const invite =
|
||||
settings.registrationMode === "INVITE_ONLY"
|
||||
? await prisma.invite.findUnique({ where: { code: inviteCode } })
|
||||
: null;
|
||||
|
||||
if (
|
||||
settings.registrationMode === "INVITE_ONLY" &&
|
||||
(!invite || invite.status !== "ACTIVE" || (invite.expiresAt && invite.expiresAt.getTime() < Date.now()))
|
||||
) {
|
||||
redirect("/register?error=invite");
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
let user: User;
|
||||
|
||||
try {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: username,
|
||||
passwordHash,
|
||||
ownedRooms: {
|
||||
create: {
|
||||
slug: `@${username}`,
|
||||
name: `${username}'s room`,
|
||||
visibility: "FRIENDS"
|
||||
user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: username,
|
||||
passwordHash,
|
||||
ownedRooms: {
|
||||
create: {
|
||||
slug: `@${username}`,
|
||||
name: `${username}'s room`,
|
||||
isPersonal: true,
|
||||
visibility: "FRIENDS"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (invite) {
|
||||
await tx.invite.update({
|
||||
where: { id: invite.id },
|
||||
data: { status: "USED", usedById: created.id, usedAt: new Date() }
|
||||
});
|
||||
if (invite.roomId) {
|
||||
await tx.roomMember.upsert({
|
||||
where: { roomId_userId: { roomId: invite.roomId, userId: created.id } },
|
||||
update: {},
|
||||
create: { roomId: invite.roomId, userId: created.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
@@ -64,6 +94,9 @@ export async function loginUser(formData: FormData) {
|
||||
if (!user) {
|
||||
redirect("/login?error=credentials");
|
||||
}
|
||||
if (user.disabledAt) {
|
||||
redirect("/login?error=disabled");
|
||||
}
|
||||
|
||||
const ok = await compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
|
||||
Reference in New Issue
Block a user