fix(admin): P0 integrity — permanent bans, ACL sidebar, rank guards
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
11004626c7
commit
75cdfdebe4
14 files changed
+458
-68
No files matched your search
@@ -15,8 +15,6 @@ const BAN_TYPES: ReadonlySet<string> = new Set([
|
|||||||
"machine",
|
"machine",
|
||||||
"super",
|
"super",
|
||||||
]);
|
]);
|
||||||
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
|
|
||||||
|
|
||||||
export async function createBan(formData: FormData): Promise<void> {
|
export async function createBan(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
@@ -30,8 +28,8 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const banExpire =
|
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||||
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
|
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -70,9 +71,7 @@ export const disconnectUser = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const username = ctx.data.username.normalize("NFC");
|
const username = ctx.data.username.normalize("NFC");
|
||||||
await requireRconOk(
|
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
|
||||||
await rcon.disconnectUser(ctx.data.userId, username),
|
|
||||||
);
|
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -191,14 +190,42 @@ export const setMotto = adminAction(
|
|||||||
|
|
||||||
const setRankSchema = z.object({
|
const setRankSchema = z.object({
|
||||||
userId: z.coerce.number().int().positive(),
|
userId: z.coerce.number().int().positive(),
|
||||||
rank: z.coerce.number().int().min(0).max(10),
|
rank: z.coerce.number().int().min(1).max(9999),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Set a user's rank (rcon: setrank). */
|
/** Set a user's rank (rcon: setrank). */
|
||||||
export const setRank = adminAction(
|
export const setRank = adminAction(
|
||||||
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
|
const staffRank = Number(ctx.session.user.rank);
|
||||||
|
const isSuper = ctx.permissions.isSuperAdmin;
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { id: ctx.data.userId },
|
||||||
|
select: { rank: true },
|
||||||
|
});
|
||||||
|
if (!target) throw new ActionError("User not found");
|
||||||
|
|
||||||
|
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
|
||||||
|
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
|
||||||
|
`.catch(() => [] as { id: number }[]);
|
||||||
|
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
|
||||||
|
|
||||||
|
if (!isSuper) {
|
||||||
|
if (target.rank >= staffRank) {
|
||||||
|
throw new ActionError(
|
||||||
|
"Cannot change rank of a user at or above your rank",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.data.rank >= staffRank) {
|
||||||
|
throw new ActionError("Cannot set a rank equal to or above your own");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: ctx.data.userId },
|
||||||
|
data: { rank: ctx.data.rank },
|
||||||
|
});
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
@@ -214,9 +241,7 @@ export const executeCommand = adminAction(
|
|||||||
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const command = ctx.data.command.normalize("NFC");
|
const command = ctx.data.command.normalize("NFC");
|
||||||
await requireRconOk(
|
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
|
||||||
await rcon.executeCommand(ctx.data.userId, command),
|
|
||||||
);
|
|
||||||
revalidatePath(PATH);
|
revalidatePath(PATH);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -12,15 +13,19 @@ export default async function BannedPage() {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
let reason = "";
|
let reason = "";
|
||||||
let expire = 0;
|
let expire = 0;
|
||||||
|
let hasBan = false;
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
try {
|
try {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const ban = await prisma.ban.findFirst({
|
const ban = await prisma.ban.findFirst({
|
||||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
where: {
|
||||||
orderBy: { banExpire: "desc" },
|
userId: Number(session.user.id),
|
||||||
|
...activeBanWhere(unixNow()),
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: "desc" },
|
||||||
select: { banReason: true, banExpire: true },
|
select: { banReason: true, banExpire: true },
|
||||||
});
|
});
|
||||||
if (ban) {
|
if (ban) {
|
||||||
|
hasBan = true;
|
||||||
reason = ban.banReason;
|
reason = ban.banReason;
|
||||||
expire = ban.banExpire;
|
expire = ban.banExpire;
|
||||||
}
|
}
|
||||||
@@ -29,17 +34,16 @@ export default async function BannedPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryText =
|
const expiryText = !hasBan
|
||||||
expire === 0
|
? ""
|
||||||
? ""
|
: expire === 0 || expire > FAR_FUTURE
|
||||||
: expire > FAR_FUTURE
|
? t("permanent")
|
||||||
? t("permanent")
|
: t("expires", {
|
||||||
: t("expires", {
|
date: new Date(expire * 1000)
|
||||||
date: new Date(expire * 1000)
|
.toISOString()
|
||||||
.toISOString()
|
.slice(0, 16)
|
||||||
.slice(0, 16)
|
.replace("T", " "),
|
||||||
.replace("T", " "),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -18,11 +19,11 @@ export default async function AdminBans() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations("pages.admin.bans");
|
const t = await getTranslations("pages.admin.bans");
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = unixNow();
|
||||||
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
|
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
|
||||||
try {
|
try {
|
||||||
bans = await prisma.ban.findMany({
|
bans = await prisma.ban.findMany({
|
||||||
where: { banExpire: { gt: now } },
|
where: activeBanWhere(now),
|
||||||
orderBy: { timestamp: "desc" },
|
orderBy: { timestamp: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
@@ -30,7 +31,6 @@ export default async function AdminBans() {
|
|||||||
bans = [];
|
bans = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// banExpire of 0 means permanent in the emulator schema.
|
|
||||||
const permanent = bans.filter((b) => b.banExpire === 0).length;
|
const permanent = bans.filter((b) => b.banExpire === 0).length;
|
||||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { AdminTopbar } from "@/components/admin/admin-topbar";
|
|||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
|
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||||
|
import { canAccess, getAdminContext } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -66,6 +68,13 @@ async function Sidebar({
|
|||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.admin.nav");
|
const t = await getTranslations("pages.admin.nav");
|
||||||
const initial = staff.username.charAt(0).toUpperCase();
|
const initial = staff.username.charAt(0).toUpperCase();
|
||||||
|
const { permissions } = await getAdminContext();
|
||||||
|
const isSuperAdmin = permissions.isSuperAdmin;
|
||||||
|
const allowedPermissions = isSuperAdmin
|
||||||
|
? []
|
||||||
|
: collectNavPermissionSlugs().filter((slug) =>
|
||||||
|
canAccess(permissions, slug, staff.rank),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -93,7 +102,10 @@ async function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminSidebarNav />
|
<AdminSidebarNav
|
||||||
|
allowedPermissions={allowedPermissions}
|
||||||
|
isSuperAdmin={isSuperAdmin}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import { activeBanWhere } from "@/lib/bans";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -31,7 +32,7 @@ export default async function AdminDashboard() {
|
|||||||
prisma.user.count({ where: { online: "1" } }),
|
prisma.user.count({ where: { online: "1" } }),
|
||||||
prisma.websiteArticles.count(),
|
prisma.websiteArticles.count(),
|
||||||
prisma.ban.count({
|
prisma.ban.count({
|
||||||
where: { banExpire: { gt: Math.floor(Date.now() / 1000) } },
|
where: activeBanWhere(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { logAudit } from "@/lib/services/audit";
|
|||||||
import { uploadSingleFurni } from "@/lib/services/upload-import";
|
import { uploadSingleFurni } from "@/lib/services/upload-import";
|
||||||
|
|
||||||
export const POST = withAdmin(
|
export const POST = withAdmin(
|
||||||
{ permission: PERMS.ASSETS_IMPORT },
|
{ permission: PERMS.ASSETS_IMPORT, maxBodyBytes: 52 * 1024 * 1024 },
|
||||||
async (request, ctx) => {
|
async (request, ctx) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const POST = withAdmin(
|
|||||||
{ permission: PERMS.USERS_EDIT },
|
{ permission: PERMS.USERS_EDIT },
|
||||||
async (request, context) => {
|
async (request, context) => {
|
||||||
const staffId = context.session.user.id;
|
const staffId = context.session.user.id;
|
||||||
|
const staffRank = context.session.user.rank;
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const username = String(formData.get("username") || "");
|
const username = String(formData.get("username") || "");
|
||||||
@@ -23,13 +24,56 @@ export const POST = withAdmin(
|
|||||||
|
|
||||||
if (action === "set_rank") {
|
if (action === "set_rank") {
|
||||||
const rank = Number(formData.get("rank") || "0");
|
const rank = Number(formData.get("rank") || "0");
|
||||||
if (!rank || rank < 0 || rank > 10) {
|
if (!Number.isInteger(rank) || rank < 1) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Invalid rank value" },
|
{ success: false, message: "Invalid rank value" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
|
||||||
|
SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1
|
||||||
|
`.catch(() => [] as { id: number }[]);
|
||||||
|
if (rankExists.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Rank does not exist" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { rank: true },
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "User not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuper = context.permissions.isSuperAdmin;
|
||||||
|
if (!isSuper) {
|
||||||
|
if (target.rank >= staffRank) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Cannot change rank of a user at or above your rank",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rank >= staffRank) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Cannot set a rank equal to or above your own",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||||
await rcon.setRank(userId, rank);
|
await rcon.setRank(userId, rank);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
||||||
import { ADMIN_NAV_GROUPS, type AdminNavItem } from "@/lib/admin-nav";
|
import {
|
||||||
|
ADMIN_NAV_GROUPS,
|
||||||
|
type AdminNavItem,
|
||||||
|
navItemIsAllowed,
|
||||||
|
} from "@/lib/admin-nav";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const STORAGE_KEY = "admin-nav-collapsed";
|
const STORAGE_KEY = "admin-nav-collapsed";
|
||||||
@@ -52,13 +56,35 @@ function persistCollapsed(next: Record<string, boolean>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSidebarNav() {
|
export function AdminSidebarNav({
|
||||||
|
allowedPermissions = [],
|
||||||
|
isSuperAdmin = false,
|
||||||
|
}: {
|
||||||
|
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */
|
||||||
|
allowedPermissions?: string[];
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
|
}) {
|
||||||
const t = useTranslations("pages.admin.nav");
|
const t = useTranslations("pages.admin.nav");
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
const groups = useMemo(() => ADMIN_NAV_GROUPS, []);
|
const allowed = useMemo(
|
||||||
|
() => new Set(allowedPermissions),
|
||||||
|
[allowedPermissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const access = {
|
||||||
|
isSuperAdmin,
|
||||||
|
has: (slug: string) => allowed.has(slug),
|
||||||
|
};
|
||||||
|
return ADMIN_NAV_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
||||||
|
})).filter((group) => group.items.length > 0);
|
||||||
|
}, [allowed, isSuperAdmin]);
|
||||||
|
|
||||||
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
|
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface OnlineUser {
|
export interface OnlineUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,12 +15,29 @@ export interface OnlineUser {
|
|||||||
|
|
||||||
type State = "ok" | "warn" | "danger" | "neutral";
|
type State = "ok" | "warn" | "danger" | "neutral";
|
||||||
|
|
||||||
|
const STATE_RING: Record<State, string> = {
|
||||||
|
ok: "ring-1 ring-[var(--admin-success-subtle)]/50 border-[var(--admin-success-subtle)]/40",
|
||||||
|
warn: "ring-1 ring-[var(--admin-accent)]/40 border-[var(--admin-accent)]/35",
|
||||||
|
danger:
|
||||||
|
"ring-1 ring-[var(--admin-error-subtle)]/50 border-[var(--admin-error-subtle)]/45",
|
||||||
|
neutral: "border-[var(--admin-border)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_ICON_BG: Record<State, string> = {
|
||||||
|
ok: "from-[var(--admin-success-subtle)]/25 to-[var(--admin-success-subtle)]/5 text-[var(--admin-success)]",
|
||||||
|
warn: "from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 text-[var(--admin-accent)]",
|
||||||
|
danger:
|
||||||
|
"from-[var(--admin-error-subtle)]/25 to-[var(--admin-error-subtle)]/5 text-[var(--admin-error)]",
|
||||||
|
neutral:
|
||||||
|
"from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 text-[var(--admin-accent)]",
|
||||||
|
};
|
||||||
|
|
||||||
/** A single live-status tile (online users, emulator, DB, …). */
|
/** A single live-status tile (online users, emulator, DB, …). */
|
||||||
export function StatusCard({
|
export function StatusCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
state: _state = "neutral",
|
state = "neutral",
|
||||||
icon,
|
icon,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -29,13 +47,23 @@ export function StatusCard({
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-[var(--admin-surface)] border rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200",
|
||||||
|
STATE_RING[state],
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
|
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 grid place-items-center text-[var(--admin-accent)] flex-none">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-8 h-8 rounded-xl bg-gradient-to-br grid place-items-center flex-none",
|
||||||
|
STATE_ICON_BG[state],
|
||||||
|
)}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { activeBanWhere } from "@/lib/bans";
|
||||||
import { safeRedirect } from "@/lib/foundation/security";
|
import { safeRedirect } from "@/lib/foundation/security";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -62,9 +63,11 @@ export async function enforceSiteAccess(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!target && session?.user?.id) {
|
if (!target && session?.user?.id) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const ban = await prisma.ban.findFirst({
|
const ban = await prisma.ban.findFirst({
|
||||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
where: {
|
||||||
|
userId: Number(session.user.id),
|
||||||
|
...activeBanWhere(),
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (ban) target = "/banned";
|
if (ban) target = "/banned";
|
||||||
|
|||||||
+231
-28
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { activeBanWhere, isBanActive } from "@/lib/bans";
|
||||||
|
|
||||||
|
describe("isBanActive", () => {
|
||||||
|
it("treats banExpire 0 as permanent", () => {
|
||||||
|
expect(isBanActive(0, 1_700_000_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats future expiry as active", () => {
|
||||||
|
expect(isBanActive(1_800_000_000, 1_700_000_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats past expiry as inactive", () => {
|
||||||
|
expect(isBanActive(1_600_000_000, 1_700_000_000)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activeBanWhere", () => {
|
||||||
|
it("includes permanent and future expires", () => {
|
||||||
|
expect(activeBanWhere(100)).toEqual({
|
||||||
|
OR: [{ banExpire: 0 }, { banExpire: { gt: 100 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Emulator-compatible ban expiry helpers.
|
||||||
|
* `banExpire === 0` means permanent (Arcturus / Plus-style).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function unixNow(): number {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBanActive(
|
||||||
|
banExpire: number,
|
||||||
|
now: number = unixNow(),
|
||||||
|
): boolean {
|
||||||
|
return banExpire === 0 || banExpire > now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma `where` fragment for currently active bans (incl. permanent). */
|
||||||
|
export function activeBanWhere(now: number = unixNow()) {
|
||||||
|
return {
|
||||||
|
OR: [{ banExpire: 0 }, { banExpire: { gt: now } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user