Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b218910476 | ||
|
|
cf0e469465 | ||
|
|
8e7fa782b3 |
No files matched your search
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
if (
|
||||||
|
!session?.user.permissions.includes("board-modify-all") ||
|
||||||
|
!session?.user.permissions.includes("app-modify-all")
|
||||||
|
) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
const app = await api.app.byId({ id: params.id });
|
const app = await api.app.byId({ id: params.id });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
export default async function AppNewPage() {
|
export default async function AppNewPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("app-create")) {
|
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
}
|
}
|
||||||
|
if (!session.user.permissions.includes("board-modify-all")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
label: t("items.apps"),
|
label: t("items.apps"),
|
||||||
hidden: !session,
|
hidden: !session?.user.permissions.includes("board-modify-all"),
|
||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
|
|||||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure
|
getPaginated: permissionRequiredProcedure
|
||||||
|
.requiresPermission("board-modify-all")
|
||||||
.input(paginatedSchema)
|
.input(paginatedSchema)
|
||||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.session?.user) {
|
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||||
statistics.push({
|
statistics.push({
|
||||||
titleKey: "app",
|
titleKey: "app",
|
||||||
subtitleKey: "resources",
|
subtitleKey: "resources",
|
||||||
|
|||||||
@@ -76,6 +76,33 @@ describe("all should return all apps", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getPaginated should require board modification access", () => {
|
||||||
|
test("should throw FORBIDDEN without board-modify-all", async () => {
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
db: createDb(),
|
||||||
|
deviceType: undefined,
|
||||||
|
session: createDefaultSession(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return apps with board-modify-all", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
db,
|
||||||
|
deviceType: undefined,
|
||||||
|
session: createDefaultSession(["board-modify-all"]),
|
||||||
|
});
|
||||||
|
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||||
|
|
||||||
|
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
|
||||||
|
totalCount: 1,
|
||||||
|
items: [{ id: "1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("byId should return an app by id", () => {
|
describe("byId should return an app by id", () => {
|
||||||
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
|||||||
users: await integrationInstance.getUsersAsync(),
|
users: await integrationInstance.getUsersAsync(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
});
|
});
|
||||||
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
|||||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||||
const res = await withTimeoutAsync(async (signal) => {
|
const res = await withTimeoutAsync(async (signal) => {
|
||||||
return await fetchWithTrustedCertificatesAsync(
|
return await fetchWithTrustedCertificatesAsync(
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max¤t_weather=true&timezone=auto`,
|
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
|
|||||||
sunset: weather.daily.sunset[index],
|
sunset: weather.daily.sunset[index],
|
||||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||||
|
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
} satisfies Weather;
|
} satisfies Weather;
|
||||||
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
|
|||||||
sunset: z.array(z.string()),
|
sunset: z.array(z.string()),
|
||||||
wind_speed_10m_max: z.array(z.number()),
|
wind_speed_10m_max: z.array(z.number()),
|
||||||
wind_gusts_10m_max: z.array(z.number()),
|
wind_gusts_10m_max: z.array(z.number()),
|
||||||
|
relative_humidity_2m_mean: z.array(z.number()),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,5 +68,6 @@ export interface Weather {
|
|||||||
sunset: string | undefined;
|
sunset: string | undefined;
|
||||||
maxWindSpeed: number | undefined;
|
maxWindSpeed: number | undefined;
|
||||||
maxWindGusts: number | undefined;
|
maxWindGusts: number | undefined;
|
||||||
|
humidity: number | undefined;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,10 @@ export const appIntegrationBoardMode = {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
|
return groups.concat(
|
||||||
|
session.user.permissions.includes("board-modify-all")
|
||||||
|
? [appsSearchGroup, integrationsSearchGroup]
|
||||||
|
: [integrationsSearchGroup],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup<Command>({
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
name: tOption("newApp.label"),
|
name: tOption("newApp.label"),
|
||||||
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
||||||
hidden: !session?.user.permissions.includes("app-create"),
|
hidden:
|
||||||
|
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commandKey: "newIntegration",
|
commandKey: "newIntegration",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
path: "/manage/apps",
|
path: "/manage/apps",
|
||||||
name: t("manageApp.label"),
|
name: t("manageApp.label"),
|
||||||
hidden: !session,
|
hidden: !session?.user.permissions.includes("board-modify-all"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPlug,
|
icon: IconPlug,
|
||||||
|
|||||||
@@ -2257,7 +2257,8 @@
|
|||||||
"sunrise": "Sunrise",
|
"sunrise": "Sunrise",
|
||||||
"sunset": "Sunset",
|
"sunset": "Sunset",
|
||||||
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
|
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
|
||||||
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
|
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
|
||||||
|
"humidity": "Humidity: {humidity}%"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||||
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
|
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
@@ -87,6 +87,12 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
{weather.daily[0]?.humidity !== undefined && (
|
||||||
|
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
|
||||||
|
<IconDroplets size={16} />
|
||||||
|
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
|
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<IconArrowUpRight size={16} />
|
<IconArrowUpRight size={16} />
|
||||||
@@ -204,6 +210,7 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
|
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
|
||||||
maxWindSpeed={dayWeather.maxWindSpeed}
|
maxWindSpeed={dayWeather.maxWindSpeed}
|
||||||
maxWindGusts={dayWeather.maxWindGusts}
|
maxWindGusts={dayWeather.maxWindGusts}
|
||||||
|
humidity={dayWeather.humidity}
|
||||||
/>
|
/>
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IconCloudRain,
|
IconCloudRain,
|
||||||
IconCloudSnow,
|
IconCloudSnow,
|
||||||
IconCloudStorm,
|
IconCloudStorm,
|
||||||
|
IconDroplets,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconSnowflake,
|
IconSnowflake,
|
||||||
@@ -51,6 +52,7 @@ interface WeatherDescriptionProps {
|
|||||||
sunset?: string;
|
sunset?: string;
|
||||||
maxWindSpeed?: number;
|
maxWindSpeed?: number;
|
||||||
maxWindGusts?: number;
|
maxWindGusts?: number;
|
||||||
|
humidity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +80,7 @@ export const WeatherDescription = ({
|
|||||||
sunset,
|
sunset,
|
||||||
maxWindSpeed,
|
maxWindSpeed,
|
||||||
maxWindGusts,
|
maxWindGusts,
|
||||||
|
humidity,
|
||||||
}: WeatherDescriptionProps) => {
|
}: WeatherDescriptionProps) => {
|
||||||
const t = useScopedI18n("widget.weather");
|
const t = useScopedI18n("widget.weather");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
@@ -97,6 +100,9 @@ export const WeatherDescription = ({
|
|||||||
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
||||||
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
||||||
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
||||||
|
{humidity !== undefined && (
|
||||||
|
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
|
||||||
|
)}
|
||||||
{maxWindSpeed !== undefined && (
|
{maxWindSpeed !== undefined && (
|
||||||
<List.Item icon={<IconWind size={15} />}>
|
<List.Item icon={<IconWind size={15} />}>
|
||||||
{t("dailyForecast.maxWindSpeed", {
|
{t("dailyForecast.maxWindSpeed", {
|
||||||
|
|||||||
Reference in new issue
Block a user