Feature: homepage auth (#6769)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled

This commit is contained in:
shamoon
2026-06-12 11:15:58 -07:00
committed by GitHub
parent 55f132c7dc
commit f670f46526
12 changed files with 778 additions and 37 deletions
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { nextAuthMock } = vi.hoisted(() => ({
nextAuthMock: vi.fn((options) => ({ options })),
}));
vi.mock("next-auth", () => ({
default: nextAuthMock,
}));
describe("pages/api/auth/[...nextauth]", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
nextAuthMock.mockClear();
process.env = { ...originalEnv };
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
});
it("configures no providers when auth is disabled", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
expect(nextAuthMock).toHaveBeenCalledTimes(1);
expect(mod.default.options.providers).toEqual([]);
expect(mod.default.options.pages?.signIn).toBe("/auth/signin");
});
it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
expect(mod.default.options.secret).toBe("secret");
});
it("throws when auth is enabled but no provider settings are present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/Password auth is enabled but required settings are missing/i,
);
});
it("builds a password provider when auth is enabled without OIDC config", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider.id).toBe("credentials");
expect(provider.name).toBe("Credentials");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
});
it("builds an OIDC provider when enabled and maps profile fields", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id";
process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.HOMEPAGE_OIDC_NAME = "My OIDC";
process.env.HOMEPAGE_OIDC_SCOPE = "openid email";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider).toMatchObject({
id: "homepage-oidc",
name: "My OIDC",
type: "oauth",
idToken: true,
issuer: "https://issuer.example",
wellKnown: "https://issuer.example/.well-known/openid-configuration",
clientId: "client-id",
clientSecret: "client-secret",
});
expect(provider.authorization.params.scope).toBe("openid email");
expect(
provider.profile({
sub: "sub",
preferred_username: "user",
email: "user@example.com",
picture: "https://example.com/p.png",
}),
).toEqual({
id: "sub",
name: "user",
email: "user@example.com",
image: "https://example.com/p.png",
});
expect(
provider.profile({
id: "id",
name: "name",
}),
).toEqual({
id: "id",
name: "name",
email: null,
image: null,
});
});
it("throws when only partial OIDC settings are provided", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i,
);
});
});
+78
View File
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { getSettingsMock } = vi.hoisted(() => ({
getSettingsMock: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
getSettings: getSettingsMock,
}));
vi.mock("next/router", () => ({
useRouter: () => ({
query: {},
}),
}));
import { getProviders } from "next-auth/react";
import SignInPage, { getServerSideProps } from "pages/auth/signin";
describe("pages/auth/signin", () => {
it("renders an error state when no providers are configured", async () => {
render(
<SignInPage
providers={{}}
settings={{
theme: "dark",
color: "slate",
title: "Homepage",
}}
/>,
);
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
expect(document.documentElement.classList.contains("theme-slate")).toBe(true);
});
});
it("renders provider buttons when providers are available", () => {
render(
<SignInPage
providers={{
oidc: { id: "oidc", name: "OIDC" },
}}
settings={{
theme: "light",
color: "emerald",
title: "My Dashboard",
}}
/>,
);
expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
});
it("getServerSideProps returns providers and settings", async () => {
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
const res = await getServerSideProps({});
expect(getProviders).toHaveBeenCalled();
expect(getSettingsMock).toHaveBeenCalled();
expect(res).toEqual({
props: {
providers: { foo: { id: "foo", name: "Foo" } },
settings: { theme: "dark" },
},
});
});
});
+20 -2
View File
@@ -1,6 +1,10 @@
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
export function middleware(req) {
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET;
export async function middleware(req) {
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
const host = req.headers.get("host");
const port = process.env.PORT || 3000;
@@ -15,9 +19,23 @@ export function middleware(req) {
);
return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 });
}
if (authEnabled) {
const token = await getToken({ req, secret: authSecret });
if (!token) {
const signInUrl = new URL("/auth/signin", req.url);
signInUrl.searchParams.set("callbackUrl", "/");
return NextResponse.redirect(signInUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
// Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints.
matcher: [
"/",
"/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)",
],
};
+57 -11
View File
@@ -1,18 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { NextResponse } = vi.hoisted(() => ({
const { NextResponse, getToken } = vi.hoisted(() => ({
NextResponse: {
json: vi.fn((body, init) => ({ type: "json", body, init })),
next: vi.fn(() => ({ type: "next" })),
redirect: vi.fn((url) => ({ type: "redirect", url })),
},
getToken: vi.fn(),
}));
vi.mock("next/server", () => ({ NextResponse }));
vi.mock("next-auth/jwt", () => ({ getToken }));
import { middleware } from "./middleware";
async function loadMiddleware() {
vi.resetModules();
const mod = await import("./middleware");
return mod.middleware;
}
function createReq(host) {
function createReq(host = "localhost:3000", url = "http://localhost:3000/") {
return {
url,
headers: {
get: (key) => (key === "host" ? host : null),
},
@@ -29,42 +37,80 @@ describe("middleware", () => {
console.error = originalConsoleError;
});
it("allows requests for default localhost hosts", () => {
it("allows requests for default localhost hosts when auth is disabled", async () => {
process.env.PORT = "3000";
const res = middleware(createReq("localhost:3000"));
const middleware = await loadMiddleware();
const res = await middleware(createReq("localhost:3000"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
});
it("blocks requests when host is not allowed", () => {
it("blocks requests when host is not allowed", async () => {
process.env.PORT = "3000";
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const res = middleware(createReq("evil.com"));
const middleware = await loadMiddleware();
const res = await middleware(createReq("evil.com"));
expect(errSpy).toHaveBeenCalled();
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: "Host validation failed. See logs for more details." },
{ status: 400 },
);
expect(getToken).not.toHaveBeenCalled();
expect(res.type).toBe("json");
expect(res.init.status).toBe(400);
});
it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => {
it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", async () => {
process.env.HOMEPAGE_ALLOWED_HOSTS = "*";
const res = middleware(createReq("anything.example"));
const middleware = await loadMiddleware();
const res = await middleware(createReq("anything.example"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
});
it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => {
it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", async () => {
process.env.PORT = "3000";
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000";
const res = middleware(createReq("example.com:3000"));
const middleware = await loadMiddleware();
const res = await middleware(createReq("example.com:3000", "http://example.com:3000/"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
});
it("redirects to signin when auth is enabled and no token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
getToken.mockResolvedValueOnce(null);
const middleware = await loadMiddleware();
const res = await middleware(createReq("localhost:3000", "http://localhost:3000/some"));
expect(getToken).toHaveBeenCalledWith({
req: expect.objectContaining({ url: "http://localhost:3000/some" }),
secret: "secret",
});
expect(NextResponse.redirect).toHaveBeenCalled();
expect(res.type).toBe("redirect");
expect(String(res.url)).toContain("/auth/signin");
});
it("allows requests when auth is enabled and a token is present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "secret";
getToken.mockResolvedValueOnce({ sub: "user" });
const middleware = await loadMiddleware();
const res = await middleware(createReq("localhost:3000", "http://localhost:3000/"));
expect(NextResponse.next).toHaveBeenCalled();
expect(res).toEqual({ type: "next" });
+25 -22
View File
@@ -1,4 +1,5 @@
/* eslint-disable react/jsx-props-no-spreading */
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next/pages";
import Head from "next/head";
import "styles/globals.css";
@@ -72,28 +73,30 @@ const tailwindSafelist = [
function MyApp({ Component, pageProps }) {
return (
<SWRConfig
value={{
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
}}
>
<Head>
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
</Head>
<ColorProvider>
<ThemeProvider>
<SettingsProvider>
<TabProvider>
<Component {...pageProps} />
</TabProvider>
</SettingsProvider>
</ThemeProvider>
</ColorProvider>
</SWRConfig>
<SessionProvider session={pageProps.session}>
<SWRConfig
value={{
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
}}
>
<Head>
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
</Head>
<ColorProvider>
<ThemeProvider>
<SettingsProvider>
<TabProvider>
<Component {...pageProps} />
</TabProvider>
</SettingsProvider>
</ThemeProvider>
</ColorProvider>
</SWRConfig>
</SessionProvider>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { timingSafeEqual } from "node:crypto";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
// Map HOMEPAGE_* envs to what NextAuth expects
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
}
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
process.env.NEXTAUTH_URL = homepageExternalUrl;
}
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
if (authEnabled) {
if (hasOidcConfig) {
if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
throw new Error("OIDC auth is enabled but required settings are missing.");
}
} else if (hasAnyOidcConfig) {
throw new Error("OIDC auth is enabled but required settings are missing.");
} else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) {
throw new Error("Password auth is enabled but required settings are missing.");
}
}
let providers = [];
if (authEnabled) {
if (hasOidcConfig) {
providers = [
{
id: "homepage-oidc",
name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC",
type: "oauth",
idToken: true,
issuer: cleanedIssuer,
wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`,
clientId,
clientSecret,
authorization: {
params: {
scope: defaultScope,
},
},
profile(profile) {
return {
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email,
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email,
email: profile.email ?? null,
image: profile.picture ?? null,
};
},
},
];
} else {
providers = [
CredentialsProvider({
name: "Password",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const provided = credentials?.password ?? "";
const expected = homepageAuthPassword ?? "";
if (!expected || provided.length !== expected.length) {
return null;
}
const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!isMatch) {
return null;
}
return {
id: "homepage",
name: "Homepage",
};
},
}),
];
}
}
export default NextAuth({
providers,
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: "/auth/signin",
},
debug: true,
logger: {
error: (...args) => console.error("[nextauth][error]", ...args),
warn: (...args) => console.warn("[nextauth][warn]", ...args),
debug: (...args) => console.debug("[nextauth][debug]", ...args),
},
events: {
signIn: async (message) => console.debug("[nextauth][event][signIn]", message),
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
error: async (message) => console.error("[nextauth][event][error]", message),
},
});
+210
View File
@@ -0,0 +1,210 @@
import classNames from "classnames";
import { getProviders, signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { BiShieldQuarter } from "react-icons/bi";
import { getSettings } from "utils/config/config";
export default function SignIn({ providers, settings }) {
const router = useRouter();
const [password, setPassword] = useState("");
const theme = settings?.theme || "dark";
const color = settings?.color || "slate";
const title = settings?.title || "Homepage";
const callbackUrl = useMemo(() => {
const value = router.query?.callbackUrl;
return typeof value === "string" ? value : "/";
}, [router.query?.callbackUrl]);
const error = router.query?.error;
let backgroundImage = "";
let opacity = settings?.backgroundOpacity ?? 0;
let backgroundBlur = false;
let backgroundSaturate = false;
let backgroundBrightness = false;
if (settings?.background) {
const bg = settings.background;
if (typeof bg === "object") {
backgroundImage = bg.image || "";
if (bg.opacity !== undefined) {
opacity = 1 - bg.opacity / 100;
}
backgroundBlur = bg.blur !== undefined;
backgroundSaturate = bg.saturate !== undefined;
backgroundBrightness = bg.brightness !== undefined;
} else {
backgroundImage = bg;
}
}
useEffect(() => {
const html = document.documentElement;
const body = document.body;
html.classList.remove("dark", "scheme-dark", "scheme-light");
html.classList.toggle("dark", theme === "dark");
html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light");
const desiredThemeClass = `theme-${color}`;
const themeClassesToRemove = Array.from(html.classList).filter(
(cls) => cls.startsWith("theme-") && cls !== desiredThemeClass,
);
if (themeClassesToRemove.length) {
html.classList.remove(...themeClassesToRemove);
}
if (!html.classList.contains(desiredThemeClass)) {
html.classList.add(desiredThemeClass);
}
body.style.backgroundImage = "";
body.style.backgroundColor = "";
body.style.backgroundAttachment = "";
}, [color, theme]);
if (!providers || Object.keys(providers).length === 0) {
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main
className={classNames(
"relative flex min-h-screen items-center justify-center px-6 py-12",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="relative w-full max-w-xl overflow-hidden rounded-3xl border border-white/40 bg-white/80 p-10 text-center shadow-2xl shadow-black/10 dark:border-white/10 dark:bg-slate-900/70">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-theme-500/20 via-theme-500/5 to-transparent"
/>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
<BiShieldQuarter className="h-6 w-6" />
</div>
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
Authentication not configured
</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
</div>
</main>
</>
);
}
const passwordProvider = providers
? Object.values(providers).find((provider) => provider.type === "credentials")
: null;
const hasPasswordProvider = Boolean(passwordProvider);
return (
<>
{backgroundImage && (
<div
id="background"
aria-hidden="true"
style={{
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
}}
/>
)}
<main className="relative flex min-h-screen items-center justify-center px-6 py-12">
<div
className={classNames(
"relative w-full max-w-4xl overflow-hidden rounded-3xl border border-white/50 bg-white/80 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70",
backgroundBlur &&
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
)}
>
<div className="pointer-events-none absolute -left-24 -top-20 h-64 w-64 rounded-full bg-theme-500/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-theme-500/10 blur-3xl" />
<div className="grid gap-10 px-8 py-12 md:grid-cols-[1.2fr_1fr] md:px-12">
<section className="flex flex-col justify-between">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-theme-500/30 bg-theme-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-theme-600 dark:text-theme-300">
Login Required
</div>
<h1 className="mt-6 text-3xl font-semibold text-gray-900 dark:text-slate-100">{title}</h1>
<p className="mt-3 text-sm text-gray-600 dark:text-slate-300">Login to view your dashboard.</p>
</div>
</section>
<section className="flex flex-col justify-center gap-6">
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
<div className="mt-6 space-y-3">
{hasPasswordProvider && (
<form
className="space-y-3"
onSubmit={async (event) => {
event.preventDefault();
await signIn(passwordProvider?.id ?? "credentials", {
redirect: true,
callbackUrl,
password,
});
}}
>
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
<input
type="password"
name="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100"
required
/>
<button
type="submit"
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Sign in &rarr;</span>
</button>
</form>
)}
{!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => signIn(provider.id, { callbackUrl })}
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Login via {provider.name} &rarr;</span>
</button>
))}
</div>
{hasPasswordProvider && error && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-200">
Invalid password. Please try again.
</p>
)}
</div>
</section>
</div>
</div>
</main>
</>
);
}
export async function getServerSideProps(context) {
const providers = await getProviders();
const settings = getSettings();
return {
props: { providers, settings },
};
}