Close remaining web gaps: rich profile, login history, lightbox/slider, flash client, admin chatlog/DJ/chart/WYSIWYG, niche API

- Rich profile (/u/[username]): wallet (credits/duckets/diamonds), friends
  grid (messenger_friendships), and owned rooms sections.
- Login history: new website_login_logs table (model + migration 0007),
  recorded on every successful sign-in (ip + user-agent), surfaced on a new
  /settings/sessions page (with failed-attempt list from failed_logins).
- Photos lightbox + home article slider (client components, no Swiper dep).
- /client/flash launcher (SSO ticket like the Nitro page).
- Admin: private chatlogs section in /admin/logs, /admin/radio/moderation
  (shout moderation), a "users by rank" inline bar chart on the dashboard,
  and a TinyMCE rich-text editor on the article admin forms.
- Niche API: /api/values/[id], /api/guilds(+/[id]), /api/radio/auto-play.

Verified live (prod, amx_test): login recorded → /settings/sessions shows
it with device; profile renders wallet/friends/rooms; dashboard chart +
private-chat logs + /client/flash + /api/guilds all OK. Reverted test data.
tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-29 18:26:34 +02:00
1 parent f7b3845131
commit 4dfe698009
25 files changed
+1463 -64

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import { redirect } from "next/navigation";
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" };
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";
const browser = /Edg/.test(ua)
? "Edge"
: /OPR|Opera/.test(ua)
? "Opera"
: /Chrome/.test(ua)
? "Chrome"
: /Firefox/.test(ua)
? "Firefox"
: /Safari/.test(ua)
? "Safari"
: "Browser";
const os = /Windows/.test(ua)
? "Windows"
: /Android/.test(ua)
? "Android"
: /iPhone|iPad|iOS/.test(ua)
? "iOS"
: /Mac OS X|Macintosh/.test(ua)
? "macOS"
: /Linux/.test(ua)
? "Linux"
: "";
return os ? `${browser} on ${os}` : browser;
}
export default async function SessionsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
const logins = await prisma.websiteLoginLogs
.findMany({
where: { userId },
orderBy: { id: "desc" },
take: 30,
select: { id: true, ip: true, userAgent: true, createdAt: true },
})
.catch(() => []);
// Failed attempts on this username (failed_logins is keyless → raw query).
type Failed = { ip_address: string; attempted_at: Date | string };
const failed = await prisma.$queryRaw<Failed[]>`
SELECT 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" }}>
<ContentCard
icon="🔐"
title="Login activity"
subtitle="Recent sign-ins and failed attempts on your account"
/>
<ContentCard icon="✅" title="Recent sign-ins" padded={logins.length === 0}>
{logins.length === 0 ? (
<EmptyState icon="🔐">No sign-ins recorded yet.</EmptyState>
) : (
<table>
<thead>
<tr>
<th>When</th>
<th>IP</th>
<th>Device</th>
</tr>
</thead>
<tbody>
{logins.map((l) => (
<tr key={String(l.id)}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{fromDate(l.createdAt)}
</td>
<td className="muted">{l.ip}</td>
<td>{device(l.userAgent)}</td>
</tr>
))}
</tbody>
</table>
)}
</ContentCard>
{failed.length > 0 ? (
<ContentCard icon="⚠️" title="Failed attempts" subtitle="Wrong password on your username">
<table>
<thead>
<tr>
<th>When</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{failed.map((f, i) => (
<tr key={`${f.ip_address}-${i}`}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{typeof f.attempted_at === "string"
? f.attempted_at.slice(0, 16).replace("T", " ")
: fromDate(f.attempted_at)}
</td>
<td className="muted">{f.ip_address}</td>
</tr>
))}
</tbody>
</table>
</ContentCard>
) : null}
</main>
);
}