fix: issue with color scheme in layout (#1168)
* fix: issue with color scheme in layout * test: add and adjust unit tests for sign-in-callback
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
import { createTheme, isMantineColorScheme, MantineProvider } from "@mantine/core";
|
import { createTheme, isMantineColorScheme, MantineProvider } from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
|
|
||||||
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
||||||
const manager = useColorSchemeManager();
|
const manager = useColorSchemeManager();
|
||||||
@@ -50,7 +52,8 @@ function useColorSchemeManager(): MantineColorSchemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return (window.localStorage.getItem(key) as MantineColorScheme | undefined) ?? defaultValue;
|
const cookies = parseCookies(document.cookie);
|
||||||
|
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
|
||||||
} catch {
|
} catch {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
@@ -61,6 +64,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
|
|||||||
if (session) {
|
if (session) {
|
||||||
mutateColorScheme({ colorScheme: value });
|
mutateColorScheme({ colorScheme: value });
|
||||||
}
|
}
|
||||||
|
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate() });
|
||||||
window.localStorage.setItem(key, value);
|
window.localStorage.setItem(key, value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[@mantine/core] Local storage color scheme manager was unable to save color scheme.", error);
|
console.warn("[@mantine/core] Local storage color scheme manager was unable to save color scheme.", error);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import "@homarr/spotlight/styles.css";
|
|||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
import "~/styles/scroll-area.scss";
|
import "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env.mjs";
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
@@ -53,7 +55,7 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const colorScheme = session?.user.colorScheme;
|
const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light";
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
(innerProps) => {
|
(innerProps) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import type { Adapter } from "@auth/core/adapters";
|
import type { Adapter } from "@auth/core/adapters";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import type { NextAuthConfig } from "next-auth";
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
@@ -52,12 +53,12 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createSignInCallback =
|
export const createSignInCallback =
|
||||||
(adapter: Adapter, isCredentialsRequest: boolean): NextAuthCallbackOf<"signIn"> =>
|
(adapter: Adapter, db: Database, isCredentialsRequest: boolean): NextAuthCallbackOf<"signIn"> =>
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
if (!isCredentialsRequest) return true;
|
if (!isCredentialsRequest) return true;
|
||||||
|
|
||||||
// https://github.com/nextauthjs/next-auth/issues/6106
|
// https://github.com/nextauthjs/next-auth/issues/6106
|
||||||
if (!adapter.createSession) {
|
if (!adapter.createSession || !user.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +67,7 @@ export const createSignInCallback =
|
|||||||
|
|
||||||
await adapter.createSession({
|
await adapter.createSession({
|
||||||
sessionToken,
|
sessionToken,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
userId: user.id,
|
||||||
userId: user.id!,
|
|
||||||
expires: sessionExpires,
|
expires: sessionExpires,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +79,21 @@ export const createSignInCallback =
|
|||||||
secure: true,
|
secure: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dbUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, user.id),
|
||||||
|
columns: {
|
||||||
|
colorScheme: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser) return false;
|
||||||
|
|
||||||
|
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
|
||||||
|
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
|
||||||
|
path: "/",
|
||||||
|
expires: dayjs().add(1, "year").toDate(),
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read
|
|||||||
]),
|
]),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
session: createSessionCallback(db),
|
session: createSessionCallback(db),
|
||||||
signIn: createSignInCallback(adapter, isCredentialsRequest),
|
signIn: createSignInCallback(adapter, db, isCredentialsRequest),
|
||||||
},
|
},
|
||||||
redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
|
redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
|
||||||
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cookies } from "next/headers";
|
|||||||
import type { Adapter, AdapterUser } from "@auth/core/adapters";
|
import type { Adapter, AdapterUser } from "@auth/core/adapters";
|
||||||
import type { Account } from "next-auth";
|
import type { Account } from "next-auth";
|
||||||
import type { JWT } from "next-auth/jwt";
|
import type { JWT } from "next-auth/jwt";
|
||||||
import { describe, expect, it, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
@@ -15,6 +15,7 @@ import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsA
|
|||||||
|
|
||||||
describe("getCurrentUserPermissions", () => {
|
describe("getCurrentUserPermissions", () => {
|
||||||
test("should return empty permissions when non existing user requested", async () => {
|
test("should return empty permissions when non existing user requested", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
@@ -30,11 +31,16 @@ describe("getCurrentUserPermissions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userId = "1";
|
const userId = "1";
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await getCurrentUserPermissionsAsync(db, userId);
|
const result = await getCurrentUserPermissionsAsync(db, userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty permissions when user has no groups", async () => {
|
test("should return empty permissions when user has no groups", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const userId = "1";
|
const userId = "1";
|
||||||
|
|
||||||
@@ -50,11 +56,15 @@ describe("getCurrentUserPermissions", () => {
|
|||||||
id: userId,
|
id: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await getCurrentUserPermissionsAsync(db, userId);
|
const result = await getCurrentUserPermissionsAsync(db, userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return permissions for user", async () => {
|
test("should return permissions for user", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const getPermissionsWithChildrenMock = vi
|
const getPermissionsWithChildrenMock = vi
|
||||||
.spyOn(definitions, "getPermissionsWithChildren")
|
.spyOn(definitions, "getPermissionsWithChildren")
|
||||||
@@ -77,14 +87,18 @@ describe("getCurrentUserPermissions", () => {
|
|||||||
permission: "admin",
|
permission: "admin",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await getCurrentUserPermissionsAsync(db, mockId);
|
const result = await getCurrentUserPermissionsAsync(db, mockId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result).toEqual(["board-create"]);
|
expect(result).toEqual(["board-create"]);
|
||||||
expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]);
|
expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("session callback", () => {
|
describe("session callback", () => {
|
||||||
it("should add id and name to session user", async () => {
|
test("should add id and name to session user", async () => {
|
||||||
|
// Arrange
|
||||||
const user: AdapterUser = {
|
const user: AdapterUser = {
|
||||||
id: "id",
|
id: "id",
|
||||||
name: "name",
|
name: "name",
|
||||||
@@ -94,6 +108,8 @@ describe("session callback", () => {
|
|||||||
const token: JWT = {};
|
const token: JWT = {};
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const callback = createSessionCallback(db);
|
const callback = createSessionCallback(db);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await callback({
|
const result = await callback({
|
||||||
session: {
|
session: {
|
||||||
user: {
|
user: {
|
||||||
@@ -112,6 +128,8 @@ describe("session callback", () => {
|
|||||||
trigger: "update",
|
trigger: "update",
|
||||||
newSession: {},
|
newSession: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result.user).toBeDefined();
|
expect(result.user).toBeDefined();
|
||||||
expect(result.user!.id).toEqual(user.id);
|
expect(result.user!.id).toEqual(user.id);
|
||||||
expect(result.user!.name).toEqual(user.name);
|
expect(result.user!.name).toEqual(user.name);
|
||||||
@@ -169,37 +187,56 @@ vi.mock("next/headers", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createSignInCallback", () => {
|
describe("createSignInCallback", () => {
|
||||||
it("should return true if not credentials request", async () => {
|
test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => {
|
||||||
|
// Arrange
|
||||||
const isCredentialsRequest = false;
|
const isCredentialsRequest = false;
|
||||||
const signInCallback = createSignInCallback(createAdapter(), isCredentialsRequest);
|
const db = await prepareDbForSigninAsync("1");
|
||||||
|
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await signInCallback({
|
const result = await signInCallback({
|
||||||
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||||
account: {} as Account,
|
account: {} as Account,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false if no adapter.createSession", async () => {
|
test("should return false if no adapter.createSession", async () => {
|
||||||
|
// Arrange
|
||||||
const isCredentialsRequest = true;
|
const isCredentialsRequest = true;
|
||||||
|
const db = await prepareDbForSigninAsync("1");
|
||||||
const signInCallback = createSignInCallback(
|
const signInCallback = createSignInCallback(
|
||||||
// https://github.com/nextauthjs/next-auth/issues/6106
|
// https://github.com/nextauthjs/next-auth/issues/6106
|
||||||
{ createSession: undefined } as unknown as Adapter,
|
{ createSession: undefined } as unknown as Adapter,
|
||||||
|
db,
|
||||||
isCredentialsRequest,
|
isCredentialsRequest,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await signInCallback({
|
const result = await signInCallback({
|
||||||
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||||
account: {} as Account,
|
account: {} as Account,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should call adapter.createSession with correct input", async () => {
|
test("should call adapter.createSession with correct input", async () => {
|
||||||
|
// Arrange
|
||||||
const adapter = createAdapter();
|
const adapter = createAdapter();
|
||||||
const isCredentialsRequest = true;
|
const isCredentialsRequest = true;
|
||||||
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
|
const db = await prepareDbForSigninAsync("1");
|
||||||
|
const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest);
|
||||||
const user = { id: "1", emailVerified: new Date("2023-01-13") };
|
const user = { id: "1", emailVerified: new Date("2023-01-13") };
|
||||||
const account = {} as Account;
|
const account = {} as Account;
|
||||||
|
|
||||||
|
// Act
|
||||||
await signInCallback({ user, account });
|
await signInCallback({ user, account });
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(adapter.createSession).toHaveBeenCalledWith({
|
expect(adapter.createSession).toHaveBeenCalledWith({
|
||||||
sessionToken: mockSessionToken,
|
sessionToken: mockSessionToken,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -213,4 +250,52 @@ describe("createSignInCallback", () => {
|
|||||||
secure: true,
|
secure: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should set colorScheme from db as cookie", async () => {
|
||||||
|
// Arrange
|
||||||
|
const isCredentialsRequest = false;
|
||||||
|
const db = await prepareDbForSigninAsync("1");
|
||||||
|
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await signInCallback({
|
||||||
|
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||||
|
account: {} as Account,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(cookies().set).toHaveBeenCalledWith(
|
||||||
|
"homarr-color-scheme",
|
||||||
|
"dark",
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "/",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false if user not found in db", async () => {
|
||||||
|
// Arrange
|
||||||
|
const isCredentialsRequest = true;
|
||||||
|
const db = await prepareDbForSigninAsync("other-id");
|
||||||
|
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await signInCallback({
|
||||||
|
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||||
|
account: {} as Account,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prepareDbForSigninAsync = async (userId: string) => {
|
||||||
|
const db = createDb();
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
colorScheme: "dark",
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { CookieSerializeOptions } from "cookie";
|
||||||
|
import { serialize } from "cookie";
|
||||||
|
|
||||||
export function parseCookies(cookieString: string) {
|
export function parseCookies(cookieString: string) {
|
||||||
const list: Record<string, string> = {};
|
const list: Record<string, string> = {};
|
||||||
const cookieHeader = cookieString;
|
const cookieHeader = cookieString;
|
||||||
@@ -15,3 +18,7 @@ export function parseCookies(cookieString: string) {
|
|||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setClientCookie(name: string, value: string, options: CookieSerializeOptions = {}) {
|
||||||
|
document.cookie = serialize(name, value, options);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user