Complete WatchLink V1 realtime features
Some checks failed
Template Compliance / compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Failing after 1m8s
Build / build (push) Failing after 1m15s

This commit is contained in:
MrSphay
2026-05-15 23:27:18 +02:00
parent 04d75c386f
commit c1ac6e4142
25 changed files with 1775 additions and 253 deletions

97
src/lib/admin-actions.ts Normal file
View 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");
}

View File

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

View File

@@ -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()
}

View File

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

View File

@@ -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 }> }

View File

@@ -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>>) {

View File

@@ -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) {