style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+201 -151
View File
@@ -1,171 +1,221 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { beginTwoFactor, confirmTwoFactor, disableTwoFactor } from "@/actions/twofactor";
import { getTranslations } from "next-intl/server";
import {
beginTwoFactor,
confirmTwoFactor,
disableTwoFactor,
} from "@/actions/twofactor";
import { ContentCard } from "@/components/public/ui";
import { env } from "@/env";
import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { totpKeyUri } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { env } from "@/env";
export const dynamic = "force-dynamic";
export default async function TwoFactorPage({
searchParams,
searchParams,
}: {
searchParams: Promise<{ error?: string; enabled?: string; disabled?: string }>;
searchParams: Promise<{
error?: string;
enabled?: string;
disabled?: string;
}>;
}) {
const t = await getTranslations("pages.settings2fa");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const sp = await searchParams;
const id = Number(session.user.id);
const t = await getTranslations("pages.settings2fa");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const sp = await searchParams;
const id = Number(session.user.id);
let user: {
twoFactorSecret: string | null;
twoFactorConfirmedAt: Date | null;
twoFactorRecoveryCodes: string | null;
} | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { twoFactorSecret: true, twoFactorConfirmedAt: true, twoFactorRecoveryCodes: true },
});
} catch {
user = null;
}
let user: {
twoFactorSecret: string | null;
twoFactorConfirmedAt: Date | null;
twoFactorRecoveryCodes: string | null;
} | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: {
twoFactorSecret: true,
twoFactorConfirmedAt: true,
twoFactorRecoveryCodes: true,
},
});
} catch {
user = null;
}
const hasAppKey = Boolean(env.APP_KEY);
const enabled = Boolean(user?.twoFactorConfirmedAt);
const pending = Boolean(user?.twoFactorSecret && !user?.twoFactorConfirmedAt);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
const hasAppKey = Boolean(env.APP_KEY);
const enabled = Boolean(user?.twoFactorConfirmedAt);
const pending = Boolean(user?.twoFactorSecret && !user?.twoFactorConfirmedAt);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
let secret = "";
let uri = "";
let recoveryCodes: string[] = [];
if (pending && hasAppKey && user?.twoFactorSecret) {
try {
secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
uri = totpKeyUri(secret, session.user.name ?? "user", hotelName);
if (user.twoFactorRecoveryCodes) {
recoveryCodes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
}
} catch {
secret = "";
}
}
let secret = "";
let uri = "";
let recoveryCodes: string[] = [];
if (pending && hasAppKey && user?.twoFactorSecret) {
try {
secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(
user.twoFactorSecret,
);
uri = totpKeyUri(secret, session.user.name ?? "user", hotelName);
if (user.twoFactorRecoveryCodes) {
recoveryCodes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
}
} catch {
secret = "";
}
}
return (
<main style={{ display: "grid", gap: "1.5rem", maxWidth: 560, margin: "0 auto" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/settings">{t("backToSettings")}</Link>
</p>
return (
<main
style={{
display: "grid",
gap: "1.5rem",
maxWidth: 560,
margin: "0 auto",
}}
>
<p className="muted" style={{ margin: 0 }}>
<Link href="/settings">{t("backToSettings")}</Link>
</p>
<ContentCard icon="🔒" title={t("title")} subtitle={t("subtitle")}>
{sp.enabled ? (
<>
<p style={{ color: "var(--color-accent)", marginTop: 0 }}>{t("nowEnabled")}</p>
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "1rem",
marginTop: "0.5rem",
}}
>
<h4 style={{ margin: "0 0 0.5rem" }}>Recovery Codes</h4>
<p className="muted" style={{ fontSize: "0.85rem", margin: "0 0 0.75rem" }}>
Store these one-time use codes in a safe place. Each can be used once if you lose access to
your authenticator app.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.25rem",
fontFamily: "monospace",
fontSize: "0.9rem",
}}
>
{recoveryCodes.map((code) => (
<code
key={code}
style={{
userSelect: "all",
padding: "0.25rem 0.5rem",
background: "var(--color-background)",
borderRadius: 4,
}}
>
{code}
</code>
))}
</div>
</div>
</>
) : null}
{sp.disabled ? (
<p className="muted" style={{ marginTop: 0 }}>
{t("disabledNotice")}
</p>
) : null}
{sp.error === "badcode" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>{t("badCode")}</p>
) : null}
{sp.error === "ratelimit" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>{t("rateLimit")}</p>
) : null}
<ContentCard icon="🔒" title={t("title")} subtitle={t("subtitle")}>
{sp.enabled ? (
<>
<p style={{ color: "var(--color-accent)", marginTop: 0 }}>
{t("nowEnabled")}
</p>
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "1rem",
marginTop: "0.5rem",
}}
>
<h4 style={{ margin: "0 0 0.5rem" }}>Recovery Codes</h4>
<p
className="muted"
style={{ fontSize: "0.85rem", margin: "0 0 0.75rem" }}
>
Store these one-time use codes in a safe place. Each can be used
once if you lose access to your authenticator app.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.25rem",
fontFamily: "monospace",
fontSize: "0.9rem",
}}
>
{recoveryCodes.map((code) => (
<code
key={code}
style={{
userSelect: "all",
padding: "0.25rem 0.5rem",
background: "var(--color-background)",
borderRadius: 4,
}}
>
{code}
</code>
))}
</div>
</div>
</>
) : null}
{sp.disabled ? (
<p className="muted" style={{ marginTop: 0 }}>
{t("disabledNotice")}
</p>
) : null}
{sp.error === "badcode" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
{t("badCode")}
</p>
) : null}
{sp.error === "ratelimit" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
{t("rateLimit")}
</p>
) : null}
{!hasAppKey ? (
<p style={{ margin: 0 }}>
{t("noAppKeyPrefix")} <code>APP_KEY</code> {t("noAppKeyMiddle")}
<code> APP_KEY</code> {t("noAppKeySuffix")}
</p>
) : enabled ? (
<>
<p style={{ marginTop: 0 }}>
<strong>{t("isEnabled")}</strong> {t("onYourAccount")}
</p>
<form action={disableTwoFactor} style={{ display: "flex", gap: "0.5rem" }}>
<input name="code" placeholder={t("codePlaceholder")} inputMode="numeric" required />
<button type="submit" className="btn btn-danger">
{t("disable")}
</button>
</form>
</>
) : pending ? (
<>
<h3 style={{ marginTop: 0 }}>{t("scanOrEnter")}</h3>
<p className="muted" style={{ marginTop: 0 }}>
{t("scanInstructions")}
</p>
<p>
{t("manualKey")} <code style={{ userSelect: "all" }}>{secret}</code>
</p>
<p className="muted" style={{ wordBreak: "break-all", fontSize: "0.8rem" }}>
{uri}
</p>
<form action={confirmTwoFactor} style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<input name="code" placeholder={t("codePlaceholder")} inputMode="numeric" required />
<button type="submit" className="btn btn-primary">
{t("confirm")}
</button>
</form>
</>
) : (
<>
<p style={{ marginTop: 0 }}>{t("enableIntro")}</p>
<form action={beginTwoFactor}>
<button type="submit" className="btn btn-primary">
{t("enable")}
</button>
</form>
</>
)}
</ContentCard>
</main>
);
{!hasAppKey ? (
<p style={{ margin: 0 }}>
{t("noAppKeyPrefix")} <code>APP_KEY</code> {t("noAppKeyMiddle")}
<code> APP_KEY</code> {t("noAppKeySuffix")}
</p>
) : enabled ? (
<>
<p style={{ marginTop: 0 }}>
<strong>{t("isEnabled")}</strong> {t("onYourAccount")}
</p>
<form
action={disableTwoFactor}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
name="code"
placeholder={t("codePlaceholder")}
inputMode="numeric"
required
/>
<button type="submit" className="btn btn-danger">
{t("disable")}
</button>
</form>
</>
) : pending ? (
<>
<h3 style={{ marginTop: 0 }}>{t("scanOrEnter")}</h3>
<p className="muted" style={{ marginTop: 0 }}>
{t("scanInstructions")}
</p>
<p>
{t("manualKey")}{" "}
<code style={{ userSelect: "all" }}>{secret}</code>
</p>
<p
className="muted"
style={{ wordBreak: "break-all", fontSize: "0.8rem" }}
>
{uri}
</p>
<form
action={confirmTwoFactor}
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
>
<input
name="code"
placeholder={t("codePlaceholder")}
inputMode="numeric"
required
/>
<button type="submit" className="btn btn-primary">
{t("confirm")}
</button>
</form>
</>
) : (
<>
<p style={{ marginTop: 0 }}>{t("enableIntro")}</p>
<form action={beginTwoFactor}>
<button type="submit" className="btn btn-primary">
{t("enable")}
</button>
</form>
</>
)}
</ContentCard>
</main>
);
}
+112 -101
View File
@@ -1,10 +1,10 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { env } from "@/env";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
export const dynamic = "force-dynamic";
@@ -13,114 +13,125 @@ export const dynamic = "force-dynamic";
// provider when both its id + secret env vars are set). When unavailable the
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
const PROVIDERS = [
{
key: "discord",
label: "Discord",
icon: "🎮",
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
},
{
key: "google",
label: "Google",
icon: "🔵",
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
},
{
key: "discord",
label: "Discord",
icon: "🎮",
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
},
{
key: "google",
label: "Google",
icon: "🔵",
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
},
] as const;
export default async function ConnectionsPage() {
const t = await getTranslations("pages.connections");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.connections");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
const id = Number(session.user.id);
// social_accounts is a CMS-only table that may not exist in every install
// (it has no bundled migration). Fall back to "nothing linked" on any error so
// the page still renders the generic connect buttons.
let linked: Set<string> = new Set();
let lookupFailed = false;
try {
const rows = await prisma.socialAccounts.findMany({
where: { userId: BigInt(id) },
select: { provider: true },
});
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
} catch {
lookupFailed = true;
}
// social_accounts is a CMS-only table that may not exist in every install
// (it has no bundled migration). Fall back to "nothing linked" on any error so
// the page still renders the generic connect buttons.
let linked: Set<string> = new Set();
let lookupFailed = false;
try {
const rows = await prisma.socialAccounts.findMany({
where: { userId: BigInt(id) },
select: { provider: true },
});
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
} catch {
lookupFailed = true;
}
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
const callbackUrl = "/settings/connections";
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
const callbackUrl = "/settings/connections";
return (
<main style={{ display: "grid", gap: "1.5rem", maxWidth: 620, margin: "0 auto" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/settings">{t("backToSettings")}</Link>
</p>
return (
<main
style={{
display: "grid",
gap: "1.5rem",
maxWidth: 620,
margin: "0 auto",
}}
>
<p className="muted" style={{ margin: 0 }}>
<Link href="/settings">{t("backToSettings")}</Link>
</p>
<ContentCard icon="🔗" title={t("title")} subtitle={t("subtitle")}>
<p className="muted" style={{ marginTop: 0 }}>
{t("matchByEmail")} <strong>{session.user.name}</strong>.
</p>
<ContentCard icon="🔗" title={t("title")} subtitle={t("subtitle")}>
<p className="muted" style={{ marginTop: 0 }}>
{t("matchByEmail")} <strong>{session.user.name}</strong>.
</p>
{lookupFailed ? (
<p className="muted" style={{ marginBottom: 0 }}>
{t("lookupFailed")}
</p>
) : null}
</ContentCard>
{lookupFailed ? (
<p className="muted" style={{ marginBottom: 0 }}>
{t("lookupFailed")}
</p>
) : null}
</ContentCard>
<div className="card-grid sm-2">
{PROVIDERS.map((p) => {
const isLinked = linked.has(p.key);
return (
<ContentCard
key={p.key}
icon={p.icon}
title={p.label}
subtitle={
!p.available
? t("statusUnavailable")
: isLinked
? t("statusConnected")
: t("statusNotConnected")
}
>
{isLinked ? (
<span
className="btn btn-outline"
style={{ cursor: "default", pointerEvents: "none" }}
aria-disabled="true"
>
{t("connected")}
</span>
) : p.available ? (
// Plain link into NextAuth's built-in sign-in handler. Re-using
// the same OAuth flow as /login binds the provider to the
// matching hotel account (see auth.ts signIn callback).
<a
className="btn btn-primary"
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(callbackUrl)}`}
>
{t("connectProvider", { provider: p.label })}
</a>
) : (
<span
className="btn"
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
aria-disabled="true"
>
{t("unavailable")}
</span>
)}
</ContentCard>
);
})}
</div>
<div className="card-grid sm-2">
{PROVIDERS.map((p) => {
const isLinked = linked.has(p.key);
return (
<ContentCard
key={p.key}
icon={p.icon}
title={p.label}
subtitle={
!p.available
? t("statusUnavailable")
: isLinked
? t("statusConnected")
: t("statusNotConnected")
}
>
{isLinked ? (
<span
className="btn btn-outline"
style={{ cursor: "default", pointerEvents: "none" }}
aria-disabled="true"
>
{t("connected")}
</span>
) : p.available ? (
// Plain link into NextAuth's built-in sign-in handler. Re-using
// the same OAuth flow as /login binds the provider to the
// matching hotel account (see auth.ts signIn callback).
<a
className="btn btn-primary"
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(callbackUrl)}`}
>
{t("connectProvider", { provider: p.label })}
</a>
) : (
<span
className="btn"
style={{
cursor: "not-allowed",
opacity: 0.55,
pointerEvents: "none",
}}
aria-disabled="true"
>
{t("unavailable")}
</span>
)}
</ContentCard>
);
})}
</div>
<p className="muted" style={{ margin: 0 }}>
{t("footerNote")}
</p>
</main>
);
<p className="muted" style={{ margin: 0 }}>
{t("footerNote")}
</p>
</main>
);
}
+123 -84
View File
@@ -1,106 +1,145 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { updateMotto } from "@/actions/user-settings";
import { ContentCard } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const t = await getTranslations("pages.settings");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.settings");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
const id = Number(session.user.id);
let user: {
username: string;
motto: string;
look: string;
mail: string | null;
} | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { username: true, motto: true, look: true, mail: true },
});
} catch {
user = null;
}
let user: {
username: string;
motto: string;
look: string;
mail: string | null;
} | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { username: true, motto: true, look: true, mail: true },
});
} catch {
user = null;
}
if (!user) redirect("/login");
if (!user) redirect("/login");
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try {
imagerBase = (await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
} catch {
// fall back to the default imager base
}
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try {
imagerBase =
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
} catch {
// fall back to the default imager base
}
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="⚙️" title={t("accountSettings")} subtitle={t("accountSettingsSubtitle")} />
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="⚙️"
title={t("accountSettings")}
subtitle={t("accountSettingsSubtitle")}
/>
<ContentCard icon="🙂" title={user.username} subtitle={t("publicProfile")}>
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center", flexWrap: "wrap" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="avatar" src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
<div style={{ minWidth: 0 }}>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
{user.motto || <span className="muted">{t("noMotto")}</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
{user.mail || t("noEmail")}
</p>
</div>
</div>
</ContentCard>
<ContentCard
icon="🙂"
title={user.username}
subtitle={t("publicProfile")}
>
<div
style={{
display: "flex",
gap: "1.5rem",
alignItems: "center",
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${user.username} avatar`}
width={100}
height={150}
/>
<div style={{ minWidth: 0 }}>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
{user.motto || <span className="muted">{t("noMotto")}</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
{user.mail || t("noEmail")}
</p>
</div>
</div>
</ContentCard>
<ContentCard icon="✏️" title={t("changeMotto")} subtitle={t("changeMottoSubtitle")}>
<form
action={updateMotto}
style={{ display: "flex", flexDirection: "column", gap: "0.75rem", maxWidth: 520 }}
>
<input
name="motto"
defaultValue={user.motto}
maxLength={127}
placeholder={t("mottoPlaceholder")}
aria-label="Motto"
/>
<div>
<button type="submit" className="btn btn-primary">
{t("saveMotto")}
</button>
</div>
</form>
</ContentCard>
<ContentCard
icon="✏️"
title={t("changeMotto")}
subtitle={t("changeMottoSubtitle")}
>
<form
action={updateMotto}
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
maxWidth: 520,
}}
>
<input
name="motto"
defaultValue={user.motto}
maxLength={127}
placeholder={t("mottoPlaceholder")}
aria-label="Motto"
/>
<div>
<button type="submit" className="btn btn-primary">
{t("saveMotto")}
</button>
</div>
</form>
</ContentCard>
<div className="card-grid sm-2">
<ContentCard icon="🔒" title={t("securityTitle")} subtitle={t("securitySubtitle")}>
<p className="muted" style={{ marginTop: 0 }}>
{t("securityDescription")}
</p>
<Link href="/settings/2fa" className="btn btn-outline">
{t("twoFactorLink")}
</Link>
</ContentCard>
<div className="card-grid sm-2">
<ContentCard
icon="🔒"
title={t("securityTitle")}
subtitle={t("securitySubtitle")}
>
<p className="muted" style={{ marginTop: 0 }}>
{t("securityDescription")}
</p>
<Link href="/settings/2fa" className="btn btn-outline">
{t("twoFactorLink")}
</Link>
</ContentCard>
<ContentCard icon="🔗" title={t("connectionsTitle")} subtitle={t("connectionsSubtitle")}>
<p className="muted" style={{ marginTop: 0 }}>
{t("connectionsDescription")}
</p>
<Link href="/settings/connections" className="btn btn-outline">
{t("manageConnections")}
</Link>
</ContentCard>
</div>
</main>
);
<ContentCard
icon="🔗"
title={t("connectionsTitle")}
subtitle={t("connectionsSubtitle")}
>
<p className="muted" style={{ marginTop: 0 }}>
{t("connectionsDescription")}
</p>
<Link href="/settings/connections" className="btn btn-outline">
{t("manageConnections")}
</Link>
</ContentCard>
</div>
</main>
);
}
+106 -98
View File
@@ -7,117 +7,125 @@ 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", " ") : "—";
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;
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 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(() => []);
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[]>`
// 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"
/>
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>
<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>
);
{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>
);
}