Add account restrictions, appeals, and admin moderation tools

This commit is contained in:
2026-10-13 12:32:37 +05:00
parent 018be5049e
commit cfe5820802
28 changed files with 1936 additions and 125 deletions

30
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
import { getI18n } from "@/lib/i18n";
import { staffPage } from "@/lib/staff-page";
export const metadata = { title: "Admin Tools" };
export default async function AdminPage() {
const [{ t }] = await Promise.all([getI18n(), staffPage(true)]);
return (
<>
<Header />
<main className="section page-content moderation-page">
<span className="eyebrow">MINEGEO ADMIN</span>
<h1>{t("Admin Tools.")}</h1>
<p className="lead">
{t(
"Manage accounts, site settings, integrations and community reports.",
)}
</p>
<p>
<Link className="button" href="/moderation">
{t("Open Moderation Tools")}
</Link>
</p>
<Moderation mode="admin" />
</main>
<Footer />
</>
);
}

View File

@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { PasswordForm } from "@/components/PasswordForm";
import { authClient, user } from "@/lib/supabase";
import { authClient, rawUser } from "@/lib/supabase";
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
export async function generateMetadata() {
const { t } = await getI18n();
@@ -12,7 +12,7 @@ export async function generateMetadata() {
export default async function ResetPassword() {
const { t } = await getI18n();
const [account, jar, client] = await Promise.all([
user(),
rawUser(),
cookies(),
authClient(),
]);

View File

@@ -0,0 +1,67 @@
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.heading h2,
.heading h3 {
margin: 0;
}
.heading {
margin-bottom: 18px;
}
.search {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 16px;
margin: 24px 0;
}
.search label {
flex: 1 1 240px;
min-width: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.list {
display: grid;
gap: 24px;
margin-top: 24px;
min-width: 0;
}
.record {
border: 1px solid var(--line);
border-radius: 12px;
padding: clamp(16px, 3vw, 24px);
min-width: 0;
}
.record code,
.record h3,
.record p {
overflow-wrap: anywhere;
}
.record h3 {
font-size: 22px;
}
.reason {
white-space: pre-wrap;
}
.confirm {
border-top: 1px solid var(--line);
padding-top: 24px;
margin-top: 24px;
}
.confirm label {
margin-top: 20px;
}
.page {
max-width: 900px;
}
.page p {
line-height: 1.8;
}

44
app/ban/appeal/page.tsx Normal file
View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { AccountAppeal } from "@/components/AccountAppeal";
import { rawUser } from "@/lib/supabase";
import { getI18n } from "@/lib/i18n";
import styles from "./page.module.css";
export const metadata = { title: "Account access & appeals" };
export const dynamic = "force-dynamic";
export default async function AppealPage() {
const [account, { locale }] = await Promise.all([rawUser(), getI18n()]);
const ru = locale === "ru";
return (
<>
<Header />
<main className={`section page-content ${styles.page}`}>
<span className="eyebrow">MINEGEO</span>
<h1>{ru ? "Доступ и апелляции." : "Account access & appeals."}</h1>
{account ? (
<AccountAppeal />
) : (
<>
<p className="lead">
{ru
? "Войдите в заблокированный аккаунт, чтобы прочитать причину и подать апелляцию."
: "Log in to the affected account to see the reason and submit a ban appeal."}
</p>
<Link
className="button primary"
href="/log-in?next=%2Fban%2Fappeal"
>
{ru ? "Войти" : "Log in"}
</Link>
<p>
<Link href="/docs?page=account-bans">
{ru ? "Как работают апелляции" : "How appeals work"}
</Link>
</p>
</>
)}
</main>
<Footer />
</>
);
}

View File

@@ -21,7 +21,7 @@ import "./community.css";
import { themeInitScript } from "@/shared/theme";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase";
import { rawUser, accountState } from "@/lib/supabase";
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n();
@@ -41,7 +41,10 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
const [{ locale, preference, detectedLocale }, settings, account] =
await Promise.all([getI18n(), initialSiteSettings(), user()]);
await Promise.all([getI18n(), initialSiteSettings(), rawUser()]);
const active = account
? (await accountState(account.id)) === "active"
: false;
return (
<html lang={locale} data-theme="dark" suppressHydrationWarning>
<head>
@@ -56,7 +59,7 @@ export default async function RootLayout({
<SiteSettingsProvider initialSettings={settings}>
{children}
<SiteCursor />
<LegacyPasswordNotice enabled={!!account?.email} />
<LegacyPasswordNotice enabled={active && !!account?.email} />
</SiteSettingsProvider>
</I18nProvider>
<Analytics />

View File

@@ -2,7 +2,7 @@ import { getI18n } from "@/lib/i18n";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { SignIn } from "@/components/SignIn";
import { user } from "@/lib/supabase";
import { rawUser, accountState } from "@/lib/supabase";
import { authDestination } from "@/shared/account";
export async function generateMetadata() {
const { t } = await getI18n();
@@ -15,8 +15,10 @@ export default async function LogIn({
}) {
const query = await searchParams;
const next = authDestination(query.next);
if (await user())
redirect(next === "/auth/reset-password" ? "/settings" : next);
const account = await rawUser();
if (account && (await accountState(account.id)) !== "active")
redirect("/ban/appeal");
if (account) redirect(next === "/auth/reset-password" ? "/settings" : next);
return (
<>
<Header />

View File

@@ -1,19 +1,28 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
import Link from "next/link";
import { staffPage } from "@/lib/staff-page";
import { isAdministrator } from "@/shared/permissions";
export const metadata = { title: "Moderation Tools" };
export default async function Review() {
const { t } = await getI18n();
const [{ t }, staff] = await Promise.all([getI18n(), staffPage()]);
return (
<>
<Header />
<main className="section page-content moderation-page">
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
<h1>{t("Map moderation.")}</h1>
<h1>{t("Moderation Tools.")}</h1>
<p className="lead">
{t(
"Review map quality and coverage before a world enters the public catalog.",
)}
{t("Review pending maps, map updates and account ban appeals.")}
</p>
{isAdministrator(staff.role) && (
<p>
<Link className="button" href="/admin">
{t("Open Admin Tools")}
</Link>
</p>
)}
<Moderation />
</main>
<Footer />