feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ed7db6e048
commit
803e8f36c1
47 files changed
+2793
-358
No files matched your search
@@ -1,18 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { signOutEverywhere } from "@/actions/sessions";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Login activity" };
|
||||
|
||||
type SearchParams = Promise<{ signedOutAll?: string }>;
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
|
||||
function device(ua: string | null): string {
|
||||
if (!ua) return "Unknown device";
|
||||
function deviceLabel(ua: string | null, unknown: string): string {
|
||||
if (!ua) return unknown;
|
||||
const browser = /Edg/.test(ua)
|
||||
? "Edge"
|
||||
: /OPR|Opera/.test(ua)
|
||||
@@ -38,10 +41,21 @@ function device(ua: string | null): string {
|
||||
return os ? `${browser} on ${os}` : browser;
|
||||
}
|
||||
|
||||
export default async function SessionsPage() {
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("pages.sessions");
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function SessionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.sessions");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
const userId = Number(session.user.id);
|
||||
const { signedOutAll } = await searchParams;
|
||||
|
||||
const logins = await prisma.websiteLoginLogs
|
||||
.findMany({
|
||||
@@ -52,35 +66,57 @@ export default async function SessionsPage() {
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Failed attempts on this username (failed_logins is keyless → raw query).
|
||||
type Failed = { ip_address: string; attempted_at: Date | string };
|
||||
type Failed = { id: number; ip_address: string; attempted_at: Date | string };
|
||||
const failed = await prisma.$queryRaw<Failed[]>`
|
||||
SELECT ip_address, attempted_at FROM failed_logins
|
||||
SELECT id, ip_address, attempted_at FROM failed_logins
|
||||
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
|
||||
`.catch(() => [] as Failed[]);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{signedOutAll === "1" ? (
|
||||
<p
|
||||
role="status"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-primary)",
|
||||
borderLeft: "4px solid var(--color-primary)",
|
||||
}}
|
||||
>
|
||||
{t("successSignedOutAll")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🔐"
|
||||
title="Login activity"
|
||||
subtitle="Recent sign-ins and failed attempts on your account"
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
|
||||
<ContentCard
|
||||
icon="✅"
|
||||
title="Recent sign-ins"
|
||||
padded={logins.length === 0}
|
||||
icon="🚪"
|
||||
title={t("signOutEverywhereTitle")}
|
||||
subtitle={t("signOutEverywhereDescription")}
|
||||
>
|
||||
<form action={signOutEverywhere} style={{ padding: "1rem" }}>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("signOutEverywhereButton")}
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard icon="✅" title={t("recentTitle")} padded={logins.length === 0}>
|
||||
{logins.length === 0 ? (
|
||||
<EmptyState icon="🔐">No sign-ins recorded yet.</EmptyState>
|
||||
<EmptyState icon="🔐">{t("recentEmpty")}</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>IP</th>
|
||||
<th>Device</th>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colIp")}</th>
|
||||
<th>{t("colDevice")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -90,7 +126,7 @@ export default async function SessionsPage() {
|
||||
{fromDate(l.createdAt)}
|
||||
</td>
|
||||
<td className="muted">{l.ip}</td>
|
||||
<td>{device(l.userAgent)}</td>
|
||||
<td>{deviceLabel(l.userAgent, t("unknownDevice"))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -101,19 +137,19 @@ export default async function SessionsPage() {
|
||||
{failed.length > 0 ? (
|
||||
<ContentCard
|
||||
icon="⚠️"
|
||||
title="Failed attempts"
|
||||
subtitle="Wrong password on your username"
|
||||
title={t("failedTitle")}
|
||||
subtitle={t("failedSubtitle")}
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>IP</th>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colIp")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failed.map((f) => (
|
||||
<tr key={f.ip_address}>
|
||||
<tr key={f.id}>
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{typeof f.attempted_at === "string"
|
||||
? f.attempted_at.slice(0, 16).replace("T", " ")
|
||||
|
||||
Reference in new issue
Block a user