diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx deleted file mode 100644 index bdad9cfea..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { Button, Group, Stack } from "@mantine/core"; -import type { z } from "zod/v4"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { userChangeHomeBoardsSchema } from "@homarr/validation/user"; - -import type { Board } from "~/app/[locale]/boards/_types"; -import { BoardSelect } from "~/components/board/board-select"; - -interface ChangeHomeBoardFormProps { - user: RouterOutputs["user"]["getById"]; - boardsData: Pick[]; -} - -export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - homeBoardId: variables.homeBoardId, - mobileHomeBoardId: variables.mobileHomeBoardId, - }); - showSuccessNotification({ - message: t("user.action.changeHomeBoard.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changeHomeBoard.notification.error.message"), - }); - }, - }); - const form = useZodForm(userChangeHomeBoardsSchema, { - initialValues: { - homeBoardId: user.homeBoardId, - mobileHomeBoardId: user.mobileHomeBoardId, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - userId: user.id, - ...values, - }); - }; - - return ( -
- - - - - - - - -
- ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-search-preferences.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-search-preferences.tsx deleted file mode 100644 index afae60dc1..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-search-preferences.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { Button, Group, Select, Stack, Switch } from "@mantine/core"; -import type { z } from "zod/v4"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { userChangeSearchPreferencesSchema } from "@homarr/validation/user"; - -interface ChangeSearchPreferencesFormProps { - user: RouterOutputs["user"]["getById"]; - searchEnginesData: { value: string; label: string }[]; -} - -export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - defaultSearchEngineId: variables.defaultSearchEngineId, - openInNewTab: variables.openInNewTab, - ddgBangsEnabled: variables.ddgBangsEnabled, - }); - showSuccessNotification({ - message: t("user.action.changeSearchPreferences.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changeSearchPreferences.notification.error.message"), - }); - }, - }); - const form = useZodForm(userChangeSearchPreferencesSchema, { - initialValues: { - defaultSearchEngineId: user.defaultSearchEngineId, - openInNewTab: user.openSearchInNewTab, - ddgBangsEnabled: user.ddgBangs, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - userId: user.id, - ...values, - }); - }; - - return ( -
- - + + + + + + + + + {tGeneral("item.language")} + + + {showLanguageSelector && } + {tGeneral("item.firstDayOfWeek")} + firstDayOfWeekOnChange(parseInt(nextValue, 10))} + > + + {FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => ( + + ))} + + + + {tGeneral("item.accessibility")} + + + + + +
+ {form.isDirty() && ( + + + {t("common.unsavedChanges")} + + + + + + + )} +
+ +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx deleted file mode 100644 index d218ac39a..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_ping-icons-enabled.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { Button, Group, Stack, Switch } from "@mantine/core"; -import type { z } from "zod/v4"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { userPingIconsEnabledSchema } from "@homarr/validation/user"; - -interface PingIconsEnabledProps { - user: RouterOutputs["user"]["getById"]; -} - -export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({ - async onSettled() { - await revalidatePathActionAsync(`/manage/users/${user.id}`); - }, - onSuccess(_, variables) { - form.setInitialValues({ - pingIconsEnabled: variables.pingIconsEnabled, - }); - showSuccessNotification({ - message: t("user.action.changePingIconsEnabled.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: t("user.action.changePingIconsEnabled.notification.error.message"), - }); - }, - }); - const form = useZodForm(userPingIconsEnabledSchema, { - initialValues: { - pingIconsEnabled: user.pingIconsEnabled, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate({ - id: user.id, - ...values, - }); - }; - - return ( -
- - - - - - - -
- ); -}; - -type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 2137add34..809345a0f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -25,6 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }, }); const [opened, { toggle }] = useDisclosure(false); + const isMobile = useMediaQuery("(max-width: 48em)"); const { openConfirmModal } = useConfirmModal(); const t = useI18n(); const tManageAvatar = useScopedI18n("user.action.manageAvatar"); @@ -94,11 +95,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }, [mutate, user.id, openConfirmModal, tManageAvatar]); return ( - - + + - + - - )} - - - ); -}; - -type FormType = Omit; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index ee960b0f5..cf68c20e2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -6,18 +6,13 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; -import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { createMetaTitle } from "~/metadata"; import { canAccessUserEditPage } from "../access"; -import { ChangeHomeBoardForm } from "./_components/_change-home-board"; -import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences"; import { DeleteUserButton } from "./_components/_delete-user-button"; -import { FirstDayOfWeek } from "./_components/_first-day-of-week"; -import { PingIconsEnabled } from "./_components/_ping-icons-enabled"; +import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; -import { UserProfileForm } from "./_components/_profile-form"; interface Props { params: Promise<{ @@ -63,7 +58,7 @@ export default async function EditUserPage(props: Props) { const boards = await api.board.getAllBoards(); const searchEngines = await api.searchEngine.getSelectable(); - + const isSelf = session?.user.id === user.id; const isCredentialsUser = user.provider === "credentials"; return ( @@ -73,51 +68,25 @@ export default async function EditUserPage(props: Props) { {t("management.page.user.fieldsDisabledExternalProvider")} )} - {tGeneral("title")} - - - + + + ({ + id: board.id, + name: board.name, + logoImageUrl: board.logoImageUrl, + }))} + searchEnginesData={searchEngines} + showLanguageSelector={isSelf} + /> - + - {session?.user.id === user.id && ( - - {tGeneral("item.language")} - - - )} - - - {tGeneral("item.board.title")} - ({ - id: board.id, - name: board.name, - logoImageUrl: board.logoImageUrl, - }))} - /> - - - - {tGeneral("item.search")} - - - - - {tGeneral("item.firstDayOfWeek")} - - - - - {tGeneral("item.accessibility")} - - - { +export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => { const currentLocale = useCurrentLocale(); const { changeLocale, isPending } = useChangeLocale(); - return ; + return ( + + ); }; diff --git a/apps/nextjs/src/components/language/language-combobox.tsx b/apps/nextjs/src/components/language/language-combobox.tsx index 6f4b39b33..f6eaaac69 100644 --- a/apps/nextjs/src/components/language/language-combobox.tsx +++ b/apps/nextjs/src/components/language/language-combobox.tsx @@ -16,9 +16,17 @@ interface LanguageComboboxProps { onChange: (value: SupportedLanguage) => void; isPending?: boolean; width?: string; + withinPortal?: boolean; } -export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => { +export const LanguageCombobox = ({ + label, + value, + onChange, + isPending, + width, + withinPortal = false, +}: LanguageComboboxProps) => { const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption(), }); @@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L }, [combobox]); return ( - + { const handler = updateCheckerRequestHandler.handler({}); - await handler.getCachedOrUpdatedDataAsync({ - forceUpdate: true, - }); + await handler.getCachedOrUpdatedDataAsync({}); }); diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts index d9b8dfa90..975448914 100644 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -1,6 +1,7 @@ import dayjs from "dayjs"; import type { Duration } from "dayjs/plugin/duration"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { createChannelWithLatestAndEvents } from "@homarr/redis"; @@ -15,6 +16,7 @@ interface Options> { options: Options, ) => ReturnType>; cacheDuration: Duration; + fallbackToStaleOnError?: boolean; } export const createCachedRequestHandler = >( @@ -26,13 +28,33 @@ export const createCachedRequestHandler = { - const data = await options.requestAsync(input); - await channel.publishAndUpdateLastStateAsync(data); - return { - data, - timestamp: new Date(), - }; + try { + const data = await options.requestAsync(input); + await channel.publishAndUpdateLastStateAsync(data); + return { + data, + timestamp: new Date(), + }; + } catch (error) { + if (options.fallbackToStaleOnError && channelData) { + logger.warn( + new ErrorWithMetadata( + "Cached request handler using stale cache after fetch failure", + { + channel: channel.name, + queryKey: options.queryKey, + }, + { cause: error }, + ), + ); + return channelData; + } + + throw error; + } }; if (forceUpdate) { @@ -43,8 +65,6 @@ export const createCachedRequestHandler = options.cacheDuration.asMilliseconds(); diff --git a/packages/request-handler/src/test/update-checker.spec.ts b/packages/request-handler/src/test/update-checker.spec.ts index 4c8c1e94b..f615b6481 100644 --- a/packages/request-handler/src/test/update-checker.spec.ts +++ b/packages/request-handler/src/test/update-checker.spec.ts @@ -111,6 +111,24 @@ describe("getAvailableUpdatesAsync", () => { // Assert expect(result).toEqual([]); }); + + test("should propagate GitHub API auth errors without blocking callers", async () => { + const currentVersion = "v1.2.3"; + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockRejectedValue(new Error("Bad credentials")); + + await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("Bad credentials"); + }); + + test("should propagate GitHub API quota exhausted errors without blocking callers", async () => { + const currentVersion = "v1.2.3"; + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockRejectedValue( + new Error("Request quota exhausted for request GET /repos/{owner}/{repo}/releases"), + ); + + await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("quota exhausted"); + }); }); const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease); diff --git a/packages/request-handler/src/update-checker.ts b/packages/request-handler/src/update-checker.ts index bd09ce89d..8709f20c5 100644 --- a/packages/request-handler/src/update-checker.ts +++ b/packages/request-handler/src/update-checker.ts @@ -15,7 +15,8 @@ const logger = createLogger({ module: "updateCheckerRequestHandler" }); export const updateCheckerRequestHandler = createCachedRequestHandler({ queryKey: "homarr-update-checker", - cacheDuration: dayjs.duration(1, "hour"), + cacheDuration: dayjs.duration(1, "day"), + fallbackToStaleOnError: true, async requestAsync(_) { return { availableUpdates: await getAvailableUpdatesAsync(packageJson.version), @@ -58,6 +59,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => { request: { fetch: fetchWithTrustedCertificatesAsync, }, + throttle: { enabled: false }, }); const isCurrentPrerelease = isPrereleaseTag(currentVersion); @@ -100,7 +102,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => { const availableUpdates = semanticReleases .filter((release) => isCurrentPrerelease || !release.isPrerelease) .filter((release) => compareSemVer(release.tagName, currentVersion) > 0) - .sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName)); + .toSorted((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName)); if (availableUpdates.length === 0) { logger.debug("No available updates found", { currentVersion }); diff --git a/packages/ui/src/components/select-with-custom-items.tsx b/packages/ui/src/components/select-with-custom-items.tsx index b1fe554e4..d4a9545ab 100644 --- a/packages/ui/src/components/select-with-custom-items.tsx +++ b/packages/ui/src/components/select-with-custom-items.tsx @@ -20,6 +20,7 @@ export interface SelectWithCustomItemsProps onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; w?: string; + withinPortal?: boolean; } type Props = SelectWithCustomItemsProps & { @@ -35,6 +36,7 @@ export const SelectWithCustomItems = ({ SelectOption, w, clearable, + withinPortal = false, ...props }: Props) => { const combobox = useCombobox({ @@ -71,7 +73,7 @@ export const SelectWithCustomItems = ({ const _clearable = clearable && Boolean(_value); return ( - +