feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 4m23s

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:
SimoandCursor committed 2026-07-21 21:21:02 +02:00
1 parent ed7db6e048
commit 803e8f36c1
47 files changed
+2793 -358

No files matched your search

+58 -22
View File
@@ -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", " ")