mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-26 05:51:17 -07:00
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
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
@@ -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 →</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} →</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 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user