mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 00:40:58 +01:00
chore(release): automatic release v1.7.0
This commit is contained in:
@@ -12,6 +12,8 @@ AUTH_SECRET="supersecret"
|
||||
# or starting the project without any (which will show a randomly generated one).
|
||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
LOG_LEVEL='info'
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -31,6 +31,7 @@ body:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.6.0
|
||||
- 1.5.0
|
||||
- 1.4.0
|
||||
- 1.3.1
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -6,7 +6,7 @@
|
||||
|
||||
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
|
||||
|
||||
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
|
||||
- [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
|
||||
- [ ] Pull request targets ``dev`` branch
|
||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
|
||||
|
||||
@@ -96,10 +96,11 @@ jobs:
|
||||
run: |
|
||||
git config user.name "Releases Homarr"
|
||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git merge ${{ github.ref_name }}
|
||||
git rebase ${{ github.ref_name }}
|
||||
git push origin dev
|
||||
deploy:
|
||||
name: Deploy docker image
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/db/env";
|
||||
import "@homarr/common/env";
|
||||
import "@homarr/log/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.ts";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
@@ -26,6 +30,7 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.12.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
@@ -43,18 +48,17 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.16.3",
|
||||
"@mantine/core": "^7.16.3",
|
||||
"@mantine/dropzone": "^7.16.3",
|
||||
"@mantine/hooks": "^7.16.3",
|
||||
"@mantine/modals": "^7.16.3",
|
||||
"@mantine/tiptap": "^7.16.3",
|
||||
"@mantine/colors-generator": "^7.17.0",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/dropzone": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/tiptap": "^7.17.0",
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-devtools": "^5.66.0",
|
||||
"@tanstack/react-query-next-experimental": "^5.66.0",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-query-devtools": "^5.66.9",
|
||||
"@tanstack/react-query-next-experimental": "^5.66.9",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -68,8 +72,8 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"flag-icons": "^7.3.2",
|
||||
"glob": "^11.0.1",
|
||||
"jotai": "^2.12.0",
|
||||
"mantine-react-table": "2.0.0-beta.8",
|
||||
"jotai": "^2.12.1",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.1.7",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
@@ -79,7 +83,7 @@
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.85.0",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.18.3",
|
||||
"swagger-ui-react": "^5.19.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
@@ -90,13 +94,13 @@
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.20.1",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.5.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ import type { SuperJSONResult } from "superjson";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { env } from "@homarr/common/env";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -33,7 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
|
||||
colors: {
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Group,
|
||||
InputWrapper,
|
||||
isLightColor,
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -39,6 +40,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
},
|
||||
});
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
@@ -98,6 +101,26 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
@@ -23,10 +23,10 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
@@ -91,7 +91,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="color" icon={IconBrush}>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { logger } from "@homarr/log";
|
||||
@@ -32,8 +33,14 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
logger.warn(error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ import type { z } from "zod";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation } from "@homarr/validation";
|
||||
|
||||
import { AppForm } from "../../_form";
|
||||
|
||||
interface AppEditFormProps {
|
||||
app: RouterOutputs["app"]["byId"];
|
||||
}
|
||||
@@ -58,6 +57,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
showBackToOverview
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { AppNewForm } from "@homarr/forms-collection";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.create.title")}</Title>
|
||||
<AppNewForm />
|
||||
<AppNewForm showBackToOverview showCreateAnother />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMediaButton = () => {
|
||||
const t = useI18n();
|
||||
@@ -27,45 +23,3 @@ export const UploadMediaButton = () => {
|
||||
</UploadMedia>
|
||||
);
|
||||
};
|
||||
|
||||
interface UploadMediaProps {
|
||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||
onSettled?: () => MaybePromise<void>;
|
||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
const handleFileUploadAsync = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
async onSuccess(mediaId) {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
await onSuccess?.({
|
||||
id: mediaId,
|
||||
url: `/api/user-medias/${mediaId}`,
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("media.action.upload.notification.error.message"),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await onSettled?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
@@ -113,11 +113,12 @@ const Row = async ({ media }: RowProps) => {
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
<Image
|
||||
// Switched to mantine image because next/image doesn't support svgs
|
||||
src={createLocalImageUrl(media.id)}
|
||||
alt={media.name}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: "contain" }}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { z } from "zod";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { searchEngineTypes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||
|
||||
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
||||
|
||||
interface SearchEngineFormProps {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Switch, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
@@ -18,42 +17,19 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
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>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SelectWithCustomItems
|
||||
|
||||
<BoardSelect
|
||||
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>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1434,26 +1434,30 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: linear-gradient(310deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: linear-gradient(210deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: linear-gradient(30deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
@@ -1681,28 +1685,32 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: { value: string; label: string }[];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}`}
|
||||
label={tGroup("setting.general.title")}
|
||||
icon={<IconId size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}/settings`}
|
||||
label={tGroup("setting.setting.title")}
|
||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface GroupHomeBoardsProps {
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
|
||||
const t = useI18n();
|
||||
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
|
||||
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
|
||||
initialValues: {
|
||||
homeBoardId,
|
||||
mobileHomeBoardId,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
id: groupId,
|
||||
settings: values,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
form.setInitialValues(values);
|
||||
showSuccessNotification({
|
||||
title: t("group.action.settings.board.notification.success.title"),
|
||||
message: t("group.action.settings.board.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("group.action.settings.board.notification.error.title"),
|
||||
message: t("group.action.settings.board.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<BoardSelect
|
||||
label={t("group.field.homeBoard.label")}
|
||||
description={t("group.field.homeBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={t("group.field.mobileBoard.label")}
|
||||
description={t("group.field.mobileBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { GroupHomeBoards } from "./_group-home-boards";
|
||||
|
||||
interface GroupSettingsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.setting.title")}</Title>
|
||||
|
||||
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t("management.page.group.setting.setting.alert")}
|
||||
</Alert>
|
||||
|
||||
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
|
||||
|
||||
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
65
apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx
Normal file
65
apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { GroupsTable } from "./_groups-table";
|
||||
|
||||
interface GroupsListProps {
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
}
|
||||
|
||||
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const initialGroupIds = useMemo(
|
||||
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||
[groups],
|
||||
);
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((groupA, groupB) => groupA.position - groupB.position),
|
||||
[groups, search],
|
||||
);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={`${t("group.search")}...`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<AddGroup />
|
||||
</Group>
|
||||
|
||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
interface GroupsTableProps {
|
||||
initialGroupIds: string[];
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
hasFilter: boolean;
|
||||
}
|
||||
|
||||
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
|
||||
const t = useI18n();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
|
||||
const isDirty = useMemo(
|
||||
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
|
||||
[groupIds, initialGroupIds],
|
||||
);
|
||||
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
|
||||
const handleSavePositionsAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ positions: groupIds },
|
||||
{
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/users/groups");
|
||||
},
|
||||
onError() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroupIds((groupIds) => {
|
||||
const oldIndex = groupIds.indexOf(active.id as string);
|
||||
const newIndex = groupIds.indexOf(over.id as string);
|
||||
return arrayMove(groupIds, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!activeId) return null;
|
||||
|
||||
const current = groups.find((group) => group.id === activeId);
|
||||
if (!current) return null;
|
||||
|
||||
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
|
||||
}, [activeId, groups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
id="groups-table"
|
||||
>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||
{groupIds.map((groupId) => {
|
||||
const group = groups.find(({ id }) => id === groupId);
|
||||
if (!group) return null;
|
||||
|
||||
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<Table w="100%">
|
||||
<TableTbody>{selectedRow}</TableTbody>
|
||||
</Table>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<SaveAffix
|
||||
visible={isDirty}
|
||||
onDiscard={() => setGroupIds(initialGroupIds)}
|
||||
isPending={isPending}
|
||||
onSave={handleSavePositionsAsync}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DraggableRowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: group.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd colSpan={2}> </TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
group={group}
|
||||
setNodeRef={setNodeRef}
|
||||
style={style}
|
||||
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
handle?: ReactNode;
|
||||
setNodeRef?: (node: HTMLElement | null) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd>
|
||||
<Group>
|
||||
{handle}
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes | undefined;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
|
||||
if (disabled) {
|
||||
return <Box w={40} h="100%" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
w={40}
|
||||
style={{ cursor: active ? "grabbing" : "grab" }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical size={18} stroke={1.5} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
visible: boolean;
|
||||
isPending: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={visible}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} onClick={onDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.everyoneGroup {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.everyoneGroup:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
import { z } from "zod";
|
||||
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AddGroup } from "./_add-group";
|
||||
import { GroupsList } from "./_client";
|
||||
import classes from "./groups.module.css";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface GroupsListPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
export default async function GroupsListPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
const groups = await api.group.getAll();
|
||||
const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
|
||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
|
||||
<AddGroup />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{groups.map((group) => (
|
||||
<Row key={group.id} group={group} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
{dbEveryoneGroup && (
|
||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||
<Card className={classes.everyoneGroup}>
|
||||
<Group align="center">
|
||||
<ThemeIcon radius="xl" variant="light">
|
||||
<IconUsersGroup size={16} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={0} flex={1}>
|
||||
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("group.defaultGroup.description", { name: everyoneGroup })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconChevronRight size={20} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
<GroupsList groups={groupsWithoutEveryone} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ group }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Center } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
interface Props {
|
||||
|
||||
33
apps/nextjs/src/components/board/board-select.tsx
Normal file
33
apps/nextjs/src/components/board/board-select.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "@homarr/ui";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
|
||||
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
{...props}
|
||||
data={boards.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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
|
||||
@@ -27,7 +27,13 @@ export const BoardCategorySection = ({ section }: Props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}>
|
||||
<Card
|
||||
style={{ "--opacity": board.opacity / 100 }}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
|
||||
@@ -26,6 +26,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
<GridStack section={section} className="min-row" />
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
PORT: z.coerce.number().default(3000),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
},
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
||||
* built with invalid env vars.
|
||||
*/
|
||||
server: {},
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
PORT: process.env.PORT,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
@@ -47,8 +47,8 @@
|
||||
"@types/node": "^22.13.4",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"prettier": "^3.5.1",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"tsx": "4.19.2",
|
||||
"ws": "^8.18.0"
|
||||
"tsx": "4.19.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.5.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Project Title -->
|
||||

|
||||
[](https://homarr.dev/)
|
||||
|
||||
<!-- Badges -->
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
</p>
|
||||
|
||||
|
||||

|
||||
[](https://homarr.dev/)
|
||||
|
||||
- 🖌️ Highly customizable with an extensive drag and drop grid system
|
||||
- ✨ Integrates seamlessly with your favorite self-hosted applications
|
||||
@@ -53,7 +53,7 @@
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
[](https://homarr.dev/docs/category/widgets)
|
||||
|
||||
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard.
|
||||
|
||||
@@ -88,7 +88,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
[](https://homarr.dev/docs/category/installation-1)
|
||||
|
||||
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
|
||||
|
||||
@@ -101,7 +101,7 @@ Since we are updating Homarr very frequently, we recommend reading our official
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
[](https://opencollective.com/homarr)
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class OnboardingActions {
|
||||
await this.db.insert(sqliteSchema.groups).values({
|
||||
id: createId(),
|
||||
name: input.group,
|
||||
position: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -40,20 +40,20 @@
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.4.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.5",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"@vitest/ui": "^3.0.6",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.2",
|
||||
"prettier": "^3.5.1",
|
||||
"semantic-release": "^24.2.3",
|
||||
"testcontainers": "^10.18.0",
|
||||
"turbo": "^2.4.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.0",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"engines": {
|
||||
"node": ">=22.14.0"
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.5.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,16 +111,19 @@ export const appRouter = createTRPCRouter({
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(validation.app.manage)
|
||||
.output(z.void())
|
||||
.output(z.object({ appId: z.string() }))
|
||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(apps).values({
|
||||
id: createId(),
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
});
|
||||
|
||||
return { appId: id };
|
||||
}),
|
||||
createMany: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
@@ -57,6 +58,37 @@ export const boardRouter = createTRPCRouter({
|
||||
where: eq(boards.isPublic, true),
|
||||
});
|
||||
}),
|
||||
getBoardsForGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ groupId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
|
||||
where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
|
||||
with: {
|
||||
boardPermissions: true,
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const distinctPermissions = new Set(
|
||||
dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
|
||||
);
|
||||
const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
|
||||
|
||||
const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
|
||||
group.boardPermissions.map(({ boardId }) => boardId),
|
||||
);
|
||||
const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
|
||||
|
||||
return await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
where: boardWhere,
|
||||
});
|
||||
}),
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
@@ -89,6 +121,7 @@ export const boardRouter = createTRPCRouter({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
@@ -478,12 +511,14 @@ export const boardRouter = createTRPCRouter({
|
||||
primaryColor: input.primaryColor,
|
||||
secondaryColor: input.secondaryColor,
|
||||
opacity: input.opacity,
|
||||
iconColor: input.iconColor,
|
||||
|
||||
// custom css
|
||||
customCss: input.customCss,
|
||||
|
||||
// layout settings
|
||||
columnCount: input.columnCount,
|
||||
itemRadius: input.itemRadius,
|
||||
|
||||
// Behavior settings
|
||||
disableStatus: input.disableStatus,
|
||||
@@ -975,9 +1010,13 @@ export const boardRouter = createTRPCRouter({
|
||||
* 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
|
||||
* 3. group.mobileHomeBoardId of the lowest positions group
|
||||
* 4. group.homeBoardId of the lowest positions group
|
||||
* 5. everyoneGroup.mobileHomeBoardId
|
||||
* 6. everyoneGroup.homeBoardId
|
||||
* 7. serverSettings.mobileHomeBoardId
|
||||
* 8. serverSettings.homeBoardId
|
||||
* 9. show NOT_FOUND error
|
||||
*/
|
||||
const getHomeIdBoardAsync = async (
|
||||
db: Database,
|
||||
@@ -985,12 +1024,46 @@ const getHomeIdBoardAsync = async (
|
||||
deviceType: DeviceType,
|
||||
) => {
|
||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||
if (user?.[settingKey] || user?.homeBoardId) {
|
||||
return user[settingKey] ?? user.homeBoardId;
|
||||
} else {
|
||||
|
||||
if (!user) {
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||
}
|
||||
|
||||
if (user[settingKey]) return user[settingKey];
|
||||
if (user.homeBoardId) return user.homeBoardId;
|
||||
|
||||
const lowestGroupExceptEveryone = await db
|
||||
.select({
|
||||
homeBoardId: groups.homeBoardId,
|
||||
mobileHomeBoardId: groups.mobileHomeBoardId,
|
||||
})
|
||||
.from(groups)
|
||||
.leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
|
||||
.where(
|
||||
and(
|
||||
eq(groupMembers.userId, user.id),
|
||||
not(eq(groups.name, everyoneGroup)),
|
||||
not(isNull(groups[settingKey])),
|
||||
not(isNull(groups.homeBoardId)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(groups.position))
|
||||
.limit(1)
|
||||
.then((result) => result[0]);
|
||||
|
||||
if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
|
||||
if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
|
||||
|
||||
const dbEveryoneGroup = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
});
|
||||
|
||||
if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
|
||||
if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
|
||||
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||
};
|
||||
|
||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||
import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { validation } from "@homarr/validation";
|
||||
@@ -12,6 +13,30 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return dbGroups.map((group) => ({
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
}));
|
||||
}),
|
||||
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.common.paginated)
|
||||
@@ -153,10 +178,13 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
@@ -172,10 +200,13 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const id = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
@@ -197,6 +228,43 @@ export const groupRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
savePartialSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePartialSettings)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
homeBoardId: input.settings.homeBoardId,
|
||||
mobileHomeBoardId: input.settings.mobileHomeBoardId,
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
savePositions: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePositions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
handleAsync: async (db, schema) => {
|
||||
await db.transaction(async (trx) => {
|
||||
for (const { id, position } of positions) {
|
||||
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync: (db) => {
|
||||
db.transaction((trx) => {
|
||||
for (const { id, position } of positions) {
|
||||
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
savePermissions: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePermissions)
|
||||
|
||||
@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
await db.insert(groupMembers).values({
|
||||
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
await db.insert(boardGroupPermissions).values({
|
||||
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
id: number.toString(),
|
||||
name: `Group ${number}`,
|
||||
position: number,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
id: number.toString(),
|
||||
name: `Group ${number}`,
|
||||
position: number,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
||||
id: index.toString(),
|
||||
name: key,
|
||||
position: index + 1,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
|
||||
{
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Another group",
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
await db.insert(groupMembers).values({
|
||||
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
await db.insert(groups).values({
|
||||
id: "2",
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: similarName,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
{
|
||||
id: groupId,
|
||||
name: initialValue,
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Third",
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
{
|
||||
id: groupId,
|
||||
name: "Something",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: initialDuplicate,
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "something",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
|
||||
{
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Another group",
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
@@ -31,12 +32,14 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
import { supportedAuthProviders } from "@homarr/definitions";
|
||||
import { createEnv } from "@homarr/env";
|
||||
import { createBooleanSchema, createDurationSchema } from "@homarr/env/schemas";
|
||||
|
||||
const authProvidersSchema = z
|
||||
.string()
|
||||
@@ -22,8 +22,7 @@ const authProvidersSchema = z
|
||||
)
|
||||
.default("credentials");
|
||||
|
||||
const skipValidation = shouldSkipEnvValidation();
|
||||
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
|
||||
const authProviders = authProvidersSchema.safeParse(process.env.AUTH_PROVIDERS).data ?? [];
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
@@ -59,32 +58,5 @@ export const env = createEnv({
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
client: {},
|
||||
runtimeEnv: {
|
||||
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
|
||||
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
|
||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
|
||||
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
|
||||
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
|
||||
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG,
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
|
||||
AUTH_LDAP_SEARCH_SCOPE: process.env.AUTH_LDAP_SEARCH_SCOPE,
|
||||
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
|
||||
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
|
||||
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
|
||||
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: process.env.AUTH_OIDC_GROUPS_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
|
||||
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
|
||||
},
|
||||
skipValidation,
|
||||
emptyStringAsUndefined: true,
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.3.1",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.5.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
||||
},
|
||||
];
|
||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||
await db.insert(groups).values({ id: "1", name: "" });
|
||||
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
||||
},
|
||||
];
|
||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||
await db.insert(groups).values({ id: "1", name: "" });
|
||||
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
||||
|
||||
@@ -379,7 +379,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
||||
];
|
||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||
await db.insert(boards).values(createMockBoard({ id: "2" }));
|
||||
await db.insert(groups).values({ id: "1", name: "" });
|
||||
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
|
||||
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
|
||||
|
||||
@@ -301,6 +301,7 @@ describe("authorizeWithLdapCredentials", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "homarr_example",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("getCurrentUserPermissions", () => {
|
||||
await db.insert(groups).values({
|
||||
id: "2",
|
||||
name: "test",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId: "2",
|
||||
@@ -51,6 +52,7 @@ describe("getCurrentUserPermissions", () => {
|
||||
await db.insert(groups).values({
|
||||
id: "2",
|
||||
name: "test",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId: "2",
|
||||
@@ -81,6 +83,7 @@ describe("getCurrentUserPermissions", () => {
|
||||
await db.insert(groups).values({
|
||||
id: mockId,
|
||||
name: "test",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
userId: mockId,
|
||||
|
||||
@@ -259,4 +259,5 @@ const createGroupAsync = async (db: Database, name = "test") =>
|
||||
await db.insert(groups).values({
|
||||
id: "1",
|
||||
name,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
@@ -6,10 +6,11 @@ import { rootCertificates } from "node:tls";
|
||||
import axios from "axios";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { LoggingAgent } from "@homarr/common/server";
|
||||
|
||||
const getCertificateFolder = () => {
|
||||
return process.env.NODE_ENV === "production"
|
||||
return env.NODE_ENV === "production"
|
||||
? path.join("/appdata", "trusted-certificates")
|
||||
: process.env.LOCAL_CERTIFICATE_PATH;
|
||||
};
|
||||
|
||||
36
packages/cli/src/commands/fix-usernames.ts
Normal file
36
packages/cli/src/commands/fix-usernames.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { command } from "@drizzle-team/brocli";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema";
|
||||
|
||||
export const fixUsernames = command({
|
||||
name: "fix-usernames",
|
||||
desc: "Changes all credentials usernames to lowercase",
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
handler: async () => {
|
||||
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
|
||||
console.error("Credentials provider is not enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialUsers = await db.query.users.findMany({
|
||||
where: eq(users.provider, "credentials"),
|
||||
});
|
||||
|
||||
for (const user of credentialUsers) {
|
||||
if (!user.name) continue;
|
||||
if (user.name !== user.name.toLowerCase()) continue;
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
name: user.name.toLowerCase(),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
console.log(`Changed username from ${user.name} to ${user.name.toLowerCase()}`);
|
||||
}
|
||||
|
||||
console.log("All usernames have been fixed");
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { run } from "@drizzle-team/brocli";
|
||||
|
||||
import { fixUsernames } from "./commands/fix-usernames";
|
||||
import { resetPassword } from "./commands/reset-password";
|
||||
|
||||
const commands = [resetPassword];
|
||||
const commands = [resetPassword, fixUsernames];
|
||||
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "./src/env-validation";
|
||||
import { createEnv } from "@homarr/env";
|
||||
|
||||
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
},
|
||||
server: {
|
||||
SECRET_ENCRYPTION_KEY: z
|
||||
.string({
|
||||
@@ -24,7 +26,6 @@ export const env = createEnv({
|
||||
},
|
||||
runtimeEnv: {
|
||||
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.1.7",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import updateLocale from "dayjs/plugin/updateLocale";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.extend(duration);
|
||||
|
||||
dayjs.updateLocale("en", {
|
||||
relativeTime: {
|
||||
future: "in %s",
|
||||
@@ -38,6 +41,10 @@ export class Stopwatch {
|
||||
return dayjs().millisecond(this.startTime).fromNow(true);
|
||||
}
|
||||
|
||||
getElapsedInMilliseconds() {
|
||||
return performance.now() - this.startTime;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.startTime = performance.now();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
|
||||
|
||||
interface CreateCronJobOptions {
|
||||
runOnStart?: boolean;
|
||||
expectedMaximumDurationInMillis?: number;
|
||||
beforeStart?: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
options: CreateCronJobOptions,
|
||||
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
|
||||
) => {
|
||||
const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 1000;
|
||||
return (callback: () => MaybePromise<void>) => {
|
||||
const catchingCallbackAsync = async () => {
|
||||
try {
|
||||
@@ -34,9 +36,16 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
|
||||
await callback();
|
||||
const callbackTook = stopwatch.getElapsedInHumanWords();
|
||||
creatorOptions.logger.logInfo(
|
||||
creatorOptions.logger.logDebug(
|
||||
`The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`,
|
||||
);
|
||||
|
||||
const durationInMillis = stopwatch.getElapsedInMilliseconds();
|
||||
if (durationInMillis > expectedMaximumDurationInMillis) {
|
||||
creatorOptions.logger.logWarning(
|
||||
`The callback of '${name}' succeeded but took ${(durationInMillis - expectedMaximumDurationInMillis).toFixed(2)}ms longer than expected (${expectedMaximumDurationInMillis}ms). This may indicate that your network performance, host performance or something else is too slow. If this happens too often, it should be looked into.`,
|
||||
);
|
||||
}
|
||||
await creatorOptions.onCallbackSuccess?.(name);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Logger {
|
||||
logDebug(message: string): void;
|
||||
logInfo(message: string): void;
|
||||
logError(error: unknown): void;
|
||||
logWarning(message: string): void;
|
||||
}
|
||||
|
||||
export class ConsoleLogger implements Logger {
|
||||
@@ -16,4 +17,8 @@ export class ConsoleLogger implements Logger {
|
||||
public logError(error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
public logWarning(message: string) {
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createCronJob } from "../lib";
|
||||
|
||||
export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
expectedMaximumDurationInMillis: 10 * 1000,
|
||||
}).withCallback(async () => {
|
||||
logger.info("Updating icon repository cache...");
|
||||
const stopWatch = new Stopwatch();
|
||||
|
||||
@@ -16,6 +16,10 @@ class WinstonCronJobLogger implements Logger {
|
||||
logError(error: unknown) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
logWarning(message: string) {
|
||||
logger.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const { createCronJob, createCronJobGroup } = createCronJobFunctions<
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
import { env as commonEnv } from "@homarr/common/env";
|
||||
import { createEnv } from "@homarr/env";
|
||||
|
||||
const drivers = {
|
||||
betterSqlite3: "better-sqlite3",
|
||||
@@ -29,7 +29,7 @@ export const env = createEnv({
|
||||
? {
|
||||
DB_URL:
|
||||
// Fallback to the default sqlite file path in production
|
||||
process.env.NODE_ENV === "production" && isDriver("better-sqlite3")
|
||||
commonEnv.NODE_ENV === "production" && isDriver("better-sqlite3")
|
||||
? z.string().default("/appdata/db/db.sqlite")
|
||||
: z.string().nonempty(),
|
||||
}
|
||||
@@ -49,18 +49,5 @@ export const env = createEnv({
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DB_DRIVER: process.env.DB_DRIVER,
|
||||
DB_URL: process.env.DB_URL,
|
||||
DB_HOST: process.env.DB_HOST,
|
||||
DB_USER: process.env.DB_USER,
|
||||
DB_PASSWORD: process.env.DB_PASSWORD,
|
||||
DB_NAME: process.env.DB_NAME,
|
||||
DB_PORT: process.env.DB_PORT,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
emptyStringAsUndefined: true,
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE `group` ADD `home_board_id` varchar(64);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `group` ADD `mobile_home_board_id` varchar(64);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `group` ADD `position` smallint;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `temp_group` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`position` smallint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `temp_group`(`id`, `name`, `position`) SELECT `id`, `name`, ROW_NUMBER() OVER(ORDER BY `name`) FROM `group` WHERE `name` != 'everyone';
|
||||
--> statement-breakpoint
|
||||
UPDATE `group` SET `position`=(SELECT `position` FROM `temp_group` WHERE `temp_group`.`id`=`group`.`id`);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `temp_group`;
|
||||
--> statement-breakpoint
|
||||
UPDATE `group` SET `position` = -1 WHERE `name` = 'everyone';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `group` MODIFY `position` smallint NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `group` ADD CONSTRAINT `group_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `group` ADD CONSTRAINT `group_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
1
packages/db/migrations/mysql/0026_add-border-radius.sql
Normal file
1
packages/db/migrations/mysql/0026_add-border-radius.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `board` ADD `item_radius` text DEFAULT ('lg') NOT NULL;
|
||||
1
packages/db/migrations/mysql/0027_acoustic_karma.sql
Normal file
1
packages/db/migrations/mysql/0027_acoustic_karma.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `board` ADD `icon_color` text;
|
||||
1811
packages/db/migrations/mysql/meta/0025_snapshot.json
Normal file
1811
packages/db/migrations/mysql/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1819
packages/db/migrations/mysql/meta/0026_snapshot.json
Normal file
1819
packages/db/migrations/mysql/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1826
packages/db/migrations/mysql/meta/0027_snapshot.json
Normal file
1826
packages/db/migrations/mysql/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,27 @@
|
||||
"when": 1738961147412,
|
||||
"tag": "0024_mean_vin_gonzales",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "5",
|
||||
"when": 1739469710187,
|
||||
"tag": "0025_add-group-home-board-settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "5",
|
||||
"when": 1739907771355,
|
||||
"tag": "0026_add-border-radius",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "5",
|
||||
"when": 1739915526818,
|
||||
"tag": "0027_acoustic_karma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: everyoneGroup,
|
||||
position: -1,
|
||||
});
|
||||
console.log("Created group 'everyone' through seed");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = OFF;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `__new_group` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`owner_id` text,
|
||||
`home_board_id` text,
|
||||
`mobile_home_board_id` text,
|
||||
`position` integer NOT NULL,
|
||||
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", -1 FROM `group` WHERE "name" = 'everyone';
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", ROW_NUMBER() OVER(ORDER BY "name") FROM `group` WHERE "name" != 'everyone';
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `group`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__new_group` RENAME TO `group`;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
|
||||
--> statement-breakpoint
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = ON;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
1
packages/db/migrations/sqlite/0026_add-border-radius.sql
Normal file
1
packages/db/migrations/sqlite/0026_add-border-radius.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `board` ADD `item_radius` text DEFAULT 'lg' NOT NULL;
|
||||
1
packages/db/migrations/sqlite/0027_wooden_blizzard.sql
Normal file
1
packages/db/migrations/sqlite/0027_wooden_blizzard.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `board` ADD `icon_color` text;
|
||||
1736
packages/db/migrations/sqlite/meta/0025_snapshot.json
Normal file
1736
packages/db/migrations/sqlite/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1744
packages/db/migrations/sqlite/meta/0026_snapshot.json
Normal file
1744
packages/db/migrations/sqlite/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1751
packages/db/migrations/sqlite/meta/0027_snapshot.json
Normal file
1751
packages/db/migrations/sqlite/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,27 @@
|
||||
"when": 1738961178990,
|
||||
"tag": "0024_bitter_scrambler",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1739468826756,
|
||||
"tag": "0025_add-group-home-board-settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "6",
|
||||
"when": 1739907755789,
|
||||
"tag": "0026_add-border-radius",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "6",
|
||||
"when": 1739915486467,
|
||||
"tag": "0027_wooden_blizzard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,10 +40,11 @@
|
||||
"@auth/core": "^0.37.4",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@testcontainers/mysql": "^10.18.0",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -59,8 +60,8 @@
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"prettier": "^3.5.1",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/db/queries/group.ts
Normal file
11
packages/db/queries/group.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { max } from "drizzle-orm";
|
||||
|
||||
import type { HomarrDatabase } from "../driver";
|
||||
import { groups } from "../schema";
|
||||
|
||||
export const getMaxGroupPositionAsync = async (db: HomarrDatabase) => {
|
||||
return await db
|
||||
.select({ value: max(groups.position) })
|
||||
.from(groups)
|
||||
.then((result) => result[0]?.value ?? 1);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./item";
|
||||
export * from "./server-setting";
|
||||
export * from "./group";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { relations } from "drizzle-orm";
|
||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
int,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
tinyint,
|
||||
@@ -150,6 +152,13 @@ export const groups = mysqlTable("group", {
|
||||
ownerId: varchar({ length: 64 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
homeBoardId: varchar({ length: 64 }).references(() => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
mobileHomeBoardId: varchar({ length: 64 }).references(() => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
position: smallint().notNull(),
|
||||
});
|
||||
|
||||
export const groupPermissions = mysqlTable("groupPermission", {
|
||||
@@ -272,6 +281,8 @@ export const boards = mysqlTable("board", {
|
||||
opacity: int().default(100).notNull(),
|
||||
customCss: text(),
|
||||
columnCount: int().default(10).notNull(),
|
||||
iconColor: text(),
|
||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||
disableStatus: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -499,6 +510,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
||||
fields: [groups.ownerId],
|
||||
references: [users.id],
|
||||
}),
|
||||
homeBoard: one(boards, {
|
||||
fields: [groups.homeBoardId],
|
||||
references: [boards.id],
|
||||
relationName: "groupRelations__board__homeBoardId",
|
||||
}),
|
||||
mobileHomeBoard: one(boards, {
|
||||
fields: [groups.mobileHomeBoardId],
|
||||
references: [boards.id],
|
||||
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||
@@ -574,6 +595,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||
}),
|
||||
userPermissions: many(boardUserPermissions),
|
||||
groupPermissions: many(boardGroupPermissions),
|
||||
groupHomes: many(groups, {
|
||||
relationName: "groupRelations__board__homeBoardId",
|
||||
}),
|
||||
mobileHomeBoard: many(groups, {
|
||||
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
@@ -133,6 +134,13 @@ export const groups = sqliteTable("group", {
|
||||
ownerId: text().references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
homeBoardId: text().references(() => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
mobileHomeBoardId: text().references(() => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
position: int().notNull(),
|
||||
});
|
||||
|
||||
export const groupPermissions = sqliteTable("groupPermission", {
|
||||
@@ -258,6 +266,8 @@ export const boards = sqliteTable("board", {
|
||||
opacity: int().default(100).notNull(),
|
||||
customCss: text(),
|
||||
columnCount: int().default(10).notNull(),
|
||||
iconColor: text(),
|
||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -486,6 +496,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
||||
fields: [groups.ownerId],
|
||||
references: [users.id],
|
||||
}),
|
||||
homeBoard: one(boards, {
|
||||
fields: [groups.homeBoardId],
|
||||
references: [boards.id],
|
||||
relationName: "groupRelations__board__homeBoardId",
|
||||
}),
|
||||
mobileHomeBoard: one(boards, {
|
||||
fields: [groups.mobileHomeBoardId],
|
||||
references: [boards.id],
|
||||
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||
@@ -561,6 +581,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||
}),
|
||||
userPermissions: many(boardUserPermissions),
|
||||
groupPermissions: many(boardGroupPermissions),
|
||||
groupHomes: many(groups, {
|
||||
relationName: "groupRelations__board__homeBoardId",
|
||||
}),
|
||||
mobileHomeBoard: many(groups, {
|
||||
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@homarr/env": "workspace:^0.1.0",
|
||||
"dockerode": "^4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
import { createEnv } from "@homarr/env";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
@@ -9,10 +8,5 @@ export const env = createEnv({
|
||||
DOCKER_HOSTNAMES: z.string().optional(),
|
||||
DOCKER_PORTS: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
|
||||
DOCKER_PORTS: process.env.DOCKER_PORTS,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
emptyStringAsUndefined: true,
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
|
||||
9
packages/env/eslint.config.js
vendored
Normal file
9
packages/env/eslint.config.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
1
packages/env/index.ts
vendored
Normal file
1
packages/env/index.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
36
packages/env/package.json
vendored
Normal file
36
packages/env/package.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@homarr/env",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./schemas": "./src/schemas.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.20.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
9
packages/env/src/index.ts
vendored
Normal file
9
packages/env/src/index.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createEnv as createEnvT3 } from "@t3-oss/env-nextjs";
|
||||
|
||||
export const defaultEnvOptions = {
|
||||
emptyStringAsUndefined: true,
|
||||
skipValidation:
|
||||
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
|
||||
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
|
||||
|
||||
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });
|
||||
39
packages/env/src/schemas.ts
vendored
Normal file
39
packages/env/src/schemas.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const trueStrings = ["1", "yes", "t", "true"];
|
||||
const falseStrings = ["0", "no", "f", "false"];
|
||||
|
||||
export const createBooleanSchema = (defaultValue: boolean) =>
|
||||
z
|
||||
.string()
|
||||
.default(defaultValue.toString())
|
||||
.transform((value, ctx) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (trueStrings.includes(normalized)) return true;
|
||||
if (falseStrings.includes(normalized)) return false;
|
||||
|
||||
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
|
||||
});
|
||||
|
||||
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d+[smhd]?$/)
|
||||
.default(defaultValue)
|
||||
.transform((duration) => {
|
||||
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
|
||||
if (!isNaN(Number(lastChar))) {
|
||||
return Number(defaultValue);
|
||||
}
|
||||
|
||||
const multipliers = {
|
||||
s: 1,
|
||||
m: 60,
|
||||
h: 60 * 60,
|
||||
d: 60 * 60 * 24,
|
||||
};
|
||||
const numberDuration = Number(duration.slice(0, -1));
|
||||
const multiplier = multipliers[lastChar];
|
||||
|
||||
return numberDuration * multiplier;
|
||||
});
|
||||
8
packages/env/tsconfig.json
vendored
Normal file
8
packages/env/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/form": "^7.16.3",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
4
packages/forms-collection/eslint.config.js
Normal file
4
packages/forms-collection/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
1
packages/forms-collection/index.ts
Normal file
1
packages/forms-collection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
43
packages/forms-collection/package.json
Normal file
43
packages/forms-collection/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@homarr/forms-collection",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"react": "19.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.20.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media";
|
||||
import { UploadMedia } from "../upload-media/upload-media";
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
@@ -124,12 +124,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||
) : null
|
||||
}
|
||||
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
6
packages/forms-collection/src/index.tsx
Normal file
6
packages/forms-collection/src/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./new-app/_app-new-form";
|
||||
export * from "./new-app/_form";
|
||||
|
||||
export * from "./icon-picker/icon-picker";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
@@ -10,9 +10,15 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation } from "@homarr/validation";
|
||||
|
||||
import { AppForm } from "../_form";
|
||||
import { AppForm } from "./_form";
|
||||
|
||||
export const AppNewForm = () => {
|
||||
export const AppNewForm = ({
|
||||
showCreateAnother,
|
||||
showBackToOverview,
|
||||
}: {
|
||||
showCreateAnother: boolean;
|
||||
showBackToOverview: boolean;
|
||||
}) => {
|
||||
const tScoped = useScopedI18n("app.page.create.notification");
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -52,8 +58,9 @@ export const AppNewForm = () => {
|
||||
<AppForm
|
||||
buttonLabels={{
|
||||
submit: t("common.action.create"),
|
||||
submitAndCreateAnother: t("common.action.createAnother"),
|
||||
submitAndCreateAnother: showCreateAnother ? t("common.action.createAnother") : undefined,
|
||||
}}
|
||||
showBackToOverview={showBackToOverview}
|
||||
handleSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user