feat(board): add mobile home board (#1910)

* feat(board): add mobile home board

* fix: add missing translations

* fix: mysql key reference with other datatype

* fix: format issue

* fix: missing trpc context arguments in tests

* fix: missing trpc context arguments in tests
This commit is contained in:
Meier Lukas
2025-01-14 19:54:55 +01:00
committed by GitHub
parent ec3bda34e0
commit e01d74f4f8
32 changed files with 3634 additions and 90 deletions

View File

@@ -101,7 +101,11 @@ export default async function BoardSettingsPage(props: Props) {
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
<DangerZoneSettingsContent
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor>
</>
)}

View File

@@ -3,7 +3,7 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -43,6 +43,12 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await revalidatePathActionAsync("/");
},
});
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
@@ -68,6 +74,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
const handleSetMobileHomeBoard = useCallback(async () => {
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
@@ -85,6 +95,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}

View File

@@ -15,7 +15,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -88,6 +88,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{t("action.setMobileHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />

View File

@@ -37,6 +37,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
)}
{...form.getInputProps("homeBoardId")}
/>
<SelectWithCustomItems
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("mobileHomeBoardId")}
/>
</>
)}
</CommonSettingsForm>

View File

@@ -18,13 +18,14 @@ interface ChangeHomeBoardFormProps {
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
mobileHomeBoardId: variables.mobileHomeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
@@ -36,9 +37,10 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
});
},
});
const form = useZodForm(validation.user.changeHomeBoard, {
const form = useZodForm(validation.user.changeHomeBoards, {
initialValues: {
homeBoardId: user.homeBoardId ?? "",
mobileHomeBoardId: user.mobileHomeBoardId ?? "",
},
});
@@ -52,7 +54,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
<Select
label={t("management.page.user.setting.general.item.board.type.general")}
w="100%"
data={boardsData}
{...form.getInputProps("homeBoardId")}
/>
<Select
label={t("management.page.user.setting.general.item.board.type.mobile")}
w="100%"
data={boardsData}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
@@ -64,4 +77,4 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
);
};
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
type FormType = z.infer<typeof validation.user.changeHomeBoards>;

View File

@@ -89,7 +89,7 @@ export default async function EditUserPage(props: Props) {
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board")}</Title>
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({

View File

@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, InferInsertModel, SQL } from "@homarr/db";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -121,6 +122,7 @@ export const boardRouter = createTRPCRouter({
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
search: publicProcedure
@@ -194,6 +196,7 @@ export const boardRouter = createTRPCRouter({
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
@@ -336,7 +339,10 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
@@ -358,30 +364,30 @@ export const boardRouter = createTRPCRouter({
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
? await ctx.db.query.users.findFirst({
? ((await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
})
})) ?? null)
: null;
// 1. user home board, 2. home board, 3. not found
let boardWhere: SQL<unknown> | null = null;
if (user?.homeBoardId) {
boardWhere = eq(boards.id, user.homeBoardId);
} else {
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
}
const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
if (!boardWhere) {
if (!homeBoardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No home board found",
});
}
const boardWhere = eq(boards.id, homeBoardId);
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
@@ -692,6 +698,29 @@ export const boardRouter = createTRPCRouter({
}),
});
/**
* Get the home board id of the user with the given device type
* For an example of a user with deviceType = 'mobile' it would go through the following order:
* 1. user.mobileHomeBoardId
* 2. user.homeBoardId
* 3. serverSettings.mobileHomeBoardId
* 4. serverSettings.homeBoardId
* 5. show NOT_FOUND error
*/
const getHomeIdBoardAsync = async (
db: Database,
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
) => {
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (user?.[settingKey] || user?.homeBoardId) {
return user[settingKey] ?? user.homeBoardId;
} else {
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
}
};
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
const boards = await db.query.boards.findMany({
columns: {

View File

@@ -24,6 +24,7 @@ describe("all should return all apps", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(),
});
@@ -55,6 +56,7 @@ describe("all should return all apps", () => {
// Arrange
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: null,
});
@@ -72,6 +74,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
@@ -103,6 +106,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await db.insert(apps).values([
@@ -128,6 +132,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -145,6 +150,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
@@ -171,6 +177,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
@@ -199,6 +206,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
@@ -237,6 +245,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
@@ -261,6 +270,7 @@ describe("delete should delete an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-full-all"]),
});

View File

@@ -52,7 +52,7 @@ describe("getAllBoards should return all boards accessable to the current user",
test("without session it should return only public boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: null });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: null });
const user1 = await createRandomUserAsync(db);
const user2 = await createRandomUserAsync(db);
@@ -85,6 +85,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: {
user: {
id: defaultCreatorId,
@@ -124,7 +125,7 @@ describe("getAllBoards should return all boards accessable to the current user",
test("with session user beeing creator it should return all private boards of them", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const user1 = await createRandomUserAsync(db);
const user2 = await createRandomUserAsync(db);
@@ -168,6 +169,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -232,6 +234,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -290,7 +293,7 @@ describe("createBoard should create a new board", () => {
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, session });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
await db.insert(users).values({
id: defaultCreatorId,
@@ -316,7 +319,7 @@ describe("createBoard should create a new board", () => {
test("should throw error when user has no board-create permission", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
@@ -330,7 +333,7 @@ describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -358,7 +361,7 @@ describe("rename board should rename board", () => {
test("should throw error when similar board name exists", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await db.insert(users).values({
id: defaultCreatorId,
@@ -385,7 +388,7 @@ describe("rename board should rename board", () => {
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
@@ -401,7 +404,7 @@ describe("changeBoardVisibility should change board visibility", () => {
async (visibility) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -436,7 +439,7 @@ describe("deleteBoard should delete board", () => {
test("should delete board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -463,7 +466,7 @@ describe("deleteBoard should delete board", () => {
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
@@ -478,7 +481,7 @@ describe("getHomeBoard should return home board", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db
@@ -502,7 +505,7 @@ describe("getHomeBoard should return home board", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db.insert(serverSettings).values({
@@ -523,7 +526,7 @@ describe("getHomeBoard should return home board", () => {
test("should throw error when home board not configured in serverSettings", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "home");
// Act
@@ -539,7 +542,7 @@ describe("getBoardByName should return board by name", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, name);
@@ -557,7 +560,7 @@ describe("getBoardByName should return board by name", () => {
it("should throw error when not present", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "default");
// Act
@@ -573,7 +576,7 @@ describe("savePartialBoardSettings should save general settings", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const newPageTitle = "newPageTitle";
const newMetaTitle = "newMetaTitle";
@@ -633,7 +636,7 @@ describe("savePartialBoardSettings should save general settings", () => {
it("should throw error when board not found", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const actAsync = async () =>
await caller.savePartialBoardSettings({
@@ -652,7 +655,7 @@ describe("saveBoard should save full board", () => {
it("should remove section when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -689,7 +692,7 @@ describe("saveBoard should save full board", () => {
it("should remove item when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
@@ -744,7 +747,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const anotherIntegration = {
id: createId(),
kind: "adGuardHome",
@@ -814,7 +817,7 @@ describe("saveBoard should save full board", () => {
async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -867,7 +870,7 @@ describe("saveBoard should save full board", () => {
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -931,7 +934,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const integration = {
id: createId(),
kind: "plex",
@@ -998,7 +1001,7 @@ describe("saveBoard should save full board", () => {
});
it("should update section when present in input", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
@@ -1056,7 +1059,7 @@ describe("saveBoard should save full board", () => {
it("should update item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
@@ -1112,7 +1115,7 @@ describe("saveBoard should save full board", () => {
});
it("should fail when board not found", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const actAsync = async () =>
await caller.saveBoard({
@@ -1128,7 +1131,7 @@ describe("getBoardPermissions should return board permissions", () => {
test("should return board permissions", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUserAsync(db);
@@ -1202,7 +1205,7 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUserAsync(db);
@@ -1244,7 +1247,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({

View File

@@ -52,6 +52,7 @@ describe("All procedures should only be accessible for users with admin permissi
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions("admin"),
});
@@ -68,6 +69,7 @@ describe("All procedures should only be accessible for users with admin permissi
);
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
});
@@ -81,6 +83,7 @@ describe("All procedures should only be accessible for users with admin permissi
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: null,
});

View File

@@ -37,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
@@ -60,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
@@ -81,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("groups should contain id, name, email and image of members", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
await db.insert(users).values(user);
@@ -117,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
@@ -140,7 +140,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getPaginated({});
@@ -154,7 +154,7 @@ describe("byId should return group by id including members and permissions", ()
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
const groupId = "1";
@@ -197,7 +197,7 @@ describe("byId should return group by id including members and permissions", ()
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: "2",
@@ -214,7 +214,7 @@ describe("byId should return group by id including members and permissions", ()
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getById({ id: "1" });
@@ -228,7 +228,7 @@ describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user);
@@ -252,7 +252,7 @@ describe("create should create group in database", () => {
test("with more than 64 characters name it should fail while validation", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const longName = "a".repeat(65);
// Act
@@ -273,7 +273,7 @@ describe("create should create group in database", () => {
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -290,7 +290,7 @@ describe("create should create group in database", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createGroup({ name: "test" });
@@ -307,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => {
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -340,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => {
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -368,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => {
test("with non existing id it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -389,7 +389,7 @@ describe("update should update name with value that is no duplicate", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -407,7 +407,7 @@ describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
@@ -437,7 +437,7 @@ describe("savePermissions should save permissions for group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -458,7 +458,7 @@ describe("savePermissions should save permissions for group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -476,7 +476,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const newUserId = createId();
@@ -513,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -534,7 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -552,7 +552,7 @@ describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -581,7 +581,7 @@ describe("deleteGroup should delete group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -601,7 +601,7 @@ describe("deleteGroup should delete group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -620,7 +620,7 @@ describe("addMember should add member to group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -658,7 +658,7 @@ describe("addMember should add member to group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -679,7 +679,7 @@ describe("addMember should add member to group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -697,7 +697,7 @@ describe("addMember should add member to group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -735,7 +735,7 @@ describe("removeMember should remove member from group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -776,7 +776,7 @@ describe("removeMember should remove member from group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -797,7 +797,7 @@ describe("removeMember should remove member from group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -815,7 +815,7 @@ describe("removeMember should remove member from group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();

View File

@@ -33,6 +33,7 @@ describe("all should return all integrations", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
@@ -63,6 +64,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -89,6 +91,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -100,6 +103,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -147,6 +151,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
@@ -172,6 +177,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
@@ -206,6 +212,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
@@ -249,6 +256,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
const input = {
@@ -272,6 +280,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -346,6 +355,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -364,6 +374,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
@@ -386,6 +397,7 @@ describe("delete should delete an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -419,6 +431,7 @@ describe("delete should delete an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});

View File

@@ -38,6 +38,7 @@ describe("all should return all existing invites without sensitive informations"
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -72,6 +73,7 @@ describe("all should return all existing invites without sensitive informations"
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -111,6 +113,7 @@ describe("create should create a new invite expiring on the specified date with
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
@@ -142,6 +145,7 @@ describe("delete should remove invite by id", () => {
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -179,6 +183,7 @@ describe("delete should remove invite by id", () => {
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});

View File

@@ -26,6 +26,7 @@ describe("getAll server settings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -44,6 +45,7 @@ describe("getAll server settings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -58,6 +60,7 @@ describe("saveSettings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});

View File

@@ -42,6 +42,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -65,6 +66,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -89,6 +91,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -109,6 +112,7 @@ describe("register should create a user with valid invitation", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -164,6 +168,7 @@ describe("register should create a user with valid invitation", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -206,6 +211,7 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -242,6 +248,7 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -277,6 +284,7 @@ describe("delete should delete user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});

View File

@@ -18,6 +18,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
@@ -37,6 +38,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ statusCode: 200 }));

View File

@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, like } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
import type { SupportedAuthProvider } from "@homarr/definitions";
@@ -18,6 +18,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
@@ -209,6 +210,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
@@ -232,6 +234,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
@@ -373,8 +376,8 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeHomeBoardId: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))))
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.changeHomeBoards.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
@@ -401,10 +404,13 @@ export const userRouter = createTRPCRouter({
});
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.userId), "view");
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
})
.where(eq(users.id, input.userId));
}),

View File

@@ -12,6 +12,7 @@ import type { OpenApiMeta } from "trpc-to-openapi";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -39,6 +40,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
return {
session,
deviceType: userAgent(opts.headers).device.type,
db,
};
};

View File

@@ -1,2 +1,3 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";

View File

@@ -0,0 +1,11 @@
import { userAgent as userAgentNextServer } from "next/server";
import type { Modify } from "./types";
export const userAgent = (headers: Headers) => {
return userAgentNextServer({ headers }) as Omit<ReturnType<typeof userAgentNextServer>, "device"> & {
device: Modify<ReturnType<typeof userAgentNextServer>["device"], { type: DeviceType }>;
};
};
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `mobile_home_board_id` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,13 @@
"when": 1735651231818,
"tag": "0019_crazy_marvel_zombies",
"breakpoints": true
},
{
"idx": 20,
"version": "5",
"when": 1736514409126,
"tag": "0020_salty_doorman",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `mobile_home_board_id` text REFERENCES board(id);

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,13 @@
"when": 1735651175378,
"tag": "0019_steady_darkhawk",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1736510755691,
"tag": "0020_empty_hellfire_club",
"breakpoints": true
}
]
}

View File

@@ -62,6 +62,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null",
}),

View File

@@ -45,6 +45,9 @@ export const users = sqliteTable("user", {
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: text().references(() => searchEngines.id, {
onDelete: "set null",
}),

View File

@@ -27,6 +27,7 @@ export const defaultServerSettings = {
},
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,

View File

@@ -1,5 +1,5 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
@@ -59,6 +59,30 @@ const boardChildrenOptions = createChildrenOptions<Board>({
};
},
},
{
key: "mobileBoard",
Component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconDeviceMobile stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.mobileBoard.label")}</Text>
</Group>
);
},
useInteraction(option) {
const { mutateAsync } = clientApi.board.setMobileHomeBoard.useMutation();
return {
type: "javaScript",
// eslint-disable-next-line no-restricted-syntax
async onSelect() {
await mutateAsync({ id: option.id });
},
};
},
},
{
key: "settings",
Component: () => {

View File

@@ -2153,6 +2153,13 @@
"tooltip": "This board will show as your home board"
}
},
"setMobileHomeBoard": {
"label": "Set as your mobile board",
"badge": {
"label": "Mobile",
"tooltip": "This board will show as your mobile board"
}
},
"duplicate": {
"label": "Duplicate board"
},
@@ -2189,7 +2196,13 @@
"title": "General",
"item": {
"language": "Language & Region",
"board": "Home board",
"board": {
"title": "Home board",
"type": {
"general": "General",
"mobile": "Mobile"
}
},
"defaultSearchEngine": "Default search engine",
"firstDayOfWeek": "First day of the week",
"accessibility": "Accessibility"
@@ -2349,6 +2362,7 @@
"title": "Boards",
"homeBoard": {
"label": "Global home board",
"mobileLabel": "Global mobile board",
"description": "Only public boards are available for selection"
}
},
@@ -2738,6 +2752,9 @@
"homeBoard": {
"label": "Set as home board"
},
"mobileBoard": {
"label": "Set as mobile board"
},
"settings": {
"label": "Open settings"
}

View File

@@ -106,7 +106,8 @@ const changePasswordSchema = addConfirmPasswordRefinement(baseChangePasswordSche
const changePasswordApiSchema = addConfirmPasswordRefinement(baseChangePasswordSchema);
const changeHomeBoardSchema = z.object({
homeBoardId: z.string().min(1),
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
});
const changeDefaultSearchEngineSchema = z.object({
@@ -135,7 +136,7 @@ export const userSchemas = {
password: passwordSchema,
editProfile: editProfileSchema,
changePassword: changePasswordSchema,
changeHomeBoard: changeHomeBoardSchema,
changeHomeBoards: changeHomeBoardSchema,
changeDefaultSearchEngine: changeDefaultSearchEngineSchema,
changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema,