This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+201
-151
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user