Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec7b5ebf94 | ||
|
|
ae4afabfea | ||
|
|
114fb22eb8 | ||
|
|
9671218936 | ||
|
|
e1ff84830f |
No files matched your search
@@ -30,9 +30,8 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
turbopackFileSystemCacheForDev: false,
|
||||
webpackMemoryOptimizations: false,
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -18,12 +18,7 @@ export const CustomMantineProvider = ({
|
||||
const manager = useColorSchemeManager();
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={theme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</DirectionProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -33,12 +33,7 @@ export const BoardMantineProvider = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userInitSchema } from "@homarr/validation/user";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
passwordInputProps={form.getInputProps("password")}
|
||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
|
||||
@@ -107,23 +107,6 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
enableDensityToggle: false,
|
||||
@@ -142,11 +125,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
),
|
||||
renderTopToolbarCustomActions: () => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
|
||||
+26
-6
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useState } from "react";
|
||||
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Stepper,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -11,11 +24,10 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||
import type { IsValid } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||
import { userPasswordSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -155,10 +167,18 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
<form>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<UserCreatePasswordFields
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={tUserField("password.label")}
|
||||
variant="filled"
|
||||
passwordInputProps={securityForm.getInputProps("password")}
|
||||
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={tUserField("passwordConfirm.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const bytesToMiB = (bytes: number) => Number((bytes / (1024 * 1024)).toFixed(2));
|
||||
|
||||
const getActiveHandlesCount = () => {
|
||||
const processWithHandles = process as NodeJS.Process & {
|
||||
_getActiveHandles?: () => unknown[];
|
||||
};
|
||||
return processWithHandles._getActiveHandles?.().length ?? null;
|
||||
};
|
||||
|
||||
const getActiveRequestsCount = () => {
|
||||
const processWithRequests = process as NodeJS.Process & {
|
||||
_getActiveRequests?: () => unknown[];
|
||||
};
|
||||
return processWithRequests._getActiveRequests?.().length ?? null;
|
||||
};
|
||||
|
||||
export const getTasksRuntimeDiagnostics = () => {
|
||||
const memory = process.memoryUsage();
|
||||
const resources = process.resourceUsage();
|
||||
|
||||
return {
|
||||
rssMiB: bytesToMiB(memory.rss),
|
||||
heapUsedMiB: bytesToMiB(memory.heapUsed),
|
||||
heapTotalMiB: bytesToMiB(memory.heapTotal),
|
||||
externalMiB: bytesToMiB(memory.external),
|
||||
arrayBuffersMiB: bytesToMiB(memory.arrayBuffers),
|
||||
uptimeSeconds: Number(process.uptime().toFixed(0)),
|
||||
userCpuMicros: resources.userCPUTime,
|
||||
systemCpuMicros: resources.systemCPUTime,
|
||||
maxRssKiB: resources.maxRSS,
|
||||
activeHandles: getActiveHandlesCount(),
|
||||
activeRequests: getActiveRequestsCount(),
|
||||
};
|
||||
};
|
||||
@@ -2,11 +2,41 @@
|
||||
import "./overrides";
|
||||
|
||||
import { jobGroup } from "@homarr/cron-jobs";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { getTasksRuntimeDiagnostics } from "./diagnostics";
|
||||
import { onStartAsync } from "./on-start";
|
||||
|
||||
const logger = createLogger({ module: "tasksMain" });
|
||||
const RUNTIME_DIAGNOSTICS_INTERVAL_MS = 60_000;
|
||||
|
||||
const scheduleRuntimeDiagnostics = () => {
|
||||
setInterval(() => {
|
||||
logger.info("Tasks runtime diagnostics", {
|
||||
diagnostics: getTasksRuntimeDiagnostics(),
|
||||
});
|
||||
}, RUNTIME_DIAGNOSTICS_INTERVAL_MS).unref();
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
logger.info("Tasks runtime bootstrap started", {
|
||||
diagnostics: getTasksRuntimeDiagnostics(),
|
||||
});
|
||||
|
||||
await onStartAsync();
|
||||
logger.info("Tasks on-start hooks completed", {
|
||||
diagnostics: getTasksRuntimeDiagnostics(),
|
||||
});
|
||||
|
||||
await jobGroup.initializeAsync();
|
||||
logger.info("Cron job group initialized in tasks runtime", {
|
||||
diagnostics: getTasksRuntimeDiagnostics(),
|
||||
});
|
||||
|
||||
await jobGroup.startAllAsync();
|
||||
logger.info("Cron job group started in tasks runtime", {
|
||||
diagnostics: getTasksRuntimeDiagnostics(),
|
||||
jobs: jobGroup.getKeys(),
|
||||
});
|
||||
scheduleRuntimeDiagnostics();
|
||||
})();
|
||||
@@ -1,5 +1,8 @@
|
||||
import { setGlobalDispatcher } from "undici";
|
||||
|
||||
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
const logger = createLogger({ module: "tasksOverrides" });
|
||||
setGlobalDispatcher(new UndiciHttpAgent());
|
||||
logger.info("Configured global undici dispatcher for tasks runtime");
|
||||
@@ -81,7 +81,13 @@ describe("initUser should initialize the first user", () => {
|
||||
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
|
||||
});
|
||||
|
||||
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
|
||||
it.each([
|
||||
["aB2%"], // too short
|
||||
["abc123DEF"], // does not contain special characters
|
||||
["abcDEFghi+"], // does not contain numbers
|
||||
["ABC123+/-"], // does not contain lowercase
|
||||
["abc123+/-"], // does not contain uppercase
|
||||
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
@@ -97,55 +103,7 @@ describe("initUser should initialize the first user", () => {
|
||||
confirmPassword: password,
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should accept passwords without complexity requirements", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "abc123DEF",
|
||||
confirmPassword: "abc123DEF",
|
||||
});
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept passwords with special characters like LoveHomarr<3", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "LoveHomarr<3",
|
||||
confirmPassword: "LoveHomarr<3",
|
||||
});
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
await expect(actAsync()).rejects.toThrow("passwordRequirements");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,18 @@ import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { UndiciHttpAgent } from "./http-agent";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
const CERTIFICATE_DISPATCHER_TTL_MS = 5 * 60 * 1000;
|
||||
let trustedCertificateDispatcher: {
|
||||
dispatcher: UndiciHttpAgent;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
let creatingTrustedCertificateDispatcherPromise: Promise<UndiciHttpAgent> | null = null;
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
@@ -41,6 +48,36 @@ export const createCertificateAgentAsync = async (override?: {
|
||||
});
|
||||
};
|
||||
|
||||
const getSharedCertificateAgentAsync = async () => {
|
||||
const now = Date.now();
|
||||
if (trustedCertificateDispatcher && trustedCertificateDispatcher.expiresAt > now) {
|
||||
return trustedCertificateDispatcher.dispatcher;
|
||||
}
|
||||
if (creatingTrustedCertificateDispatcherPromise) {
|
||||
return await creatingTrustedCertificateDispatcherPromise;
|
||||
}
|
||||
|
||||
creatingTrustedCertificateDispatcherPromise = createCertificateAgentAsync(undefined)
|
||||
.then((dispatcher) => {
|
||||
const previousDispatcher = trustedCertificateDispatcher?.dispatcher;
|
||||
trustedCertificateDispatcher = {
|
||||
dispatcher,
|
||||
expiresAt: Date.now() + CERTIFICATE_DISPATCHER_TTL_MS,
|
||||
};
|
||||
if (previousDispatcher) {
|
||||
void previousDispatcher.close().catch(() => {
|
||||
previousDispatcher.destroy();
|
||||
});
|
||||
}
|
||||
return dispatcher;
|
||||
})
|
||||
.finally(() => {
|
||||
creatingTrustedCertificateDispatcherPromise = null;
|
||||
});
|
||||
|
||||
return await creatingTrustedCertificateDispatcherPromise;
|
||||
};
|
||||
|
||||
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
|
||||
return new HttpsAgent({
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
@@ -63,7 +100,7 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
const agent = await getSharedCertificateAgentAsync();
|
||||
if (options?.timeout) {
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import type { Logger } from "./logger";
|
||||
import type { ValidateCron } from "./validation";
|
||||
import { createProcessDiagnosticsDelta, createProcessDiagnosticsSnapshot } from "./diagnostics";
|
||||
|
||||
export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
|
||||
beforeCallback?: (name: TAllowedNames) => MaybePromise<void>;
|
||||
@@ -33,19 +34,25 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 2500;
|
||||
return (callback: () => MaybePromise<void>) => {
|
||||
const catchingCallbackAsync = async () => {
|
||||
const beforeExecutionSnapshot = createProcessDiagnosticsSnapshot();
|
||||
try {
|
||||
creatorOptions.logger.logDebug("The callback of cron job started", {
|
||||
name,
|
||||
diagnostics: beforeExecutionSnapshot,
|
||||
});
|
||||
const stopwatch = new Stopwatch();
|
||||
await creatorOptions.beforeCallback?.(name);
|
||||
const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
|
||||
await callback();
|
||||
const afterExecutionSnapshot = createProcessDiagnosticsSnapshot();
|
||||
const callbackTook = stopwatch.getElapsedInHumanWords();
|
||||
creatorOptions.logger.logDebug("The callback of cron job succeeded", {
|
||||
name,
|
||||
beforeCallbackTook,
|
||||
callbackTook,
|
||||
diagnosticsBefore: beforeExecutionSnapshot,
|
||||
diagnosticsAfter: afterExecutionSnapshot,
|
||||
diagnosticsDelta: createProcessDiagnosticsDelta(beforeExecutionSnapshot, afterExecutionSnapshot),
|
||||
});
|
||||
|
||||
const durationInMillis = stopwatch.getElapsedInMilliseconds();
|
||||
@@ -58,11 +65,15 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
}
|
||||
await creatorOptions.onCallbackSuccess?.(name);
|
||||
} catch (error) {
|
||||
const failedExecutionSnapshot = createProcessDiagnosticsSnapshot();
|
||||
creatorOptions.logger.logError(
|
||||
new ErrorWithMetadata(
|
||||
"The callback of cron job failed",
|
||||
{
|
||||
name,
|
||||
diagnosticsBefore: beforeExecutionSnapshot,
|
||||
diagnosticsAfter: failedExecutionSnapshot,
|
||||
diagnosticsDelta: createProcessDiagnosticsDelta(beforeExecutionSnapshot, failedExecutionSnapshot),
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const bytesToMiB = (bytes: number) => Number((bytes / (1024 * 1024)).toFixed(2));
|
||||
|
||||
const getActiveHandlesCount = () => {
|
||||
const processWithHandles = process as NodeJS.Process & {
|
||||
_getActiveHandles?: () => unknown[];
|
||||
};
|
||||
return processWithHandles._getActiveHandles?.().length ?? null;
|
||||
};
|
||||
|
||||
const getActiveRequestsCount = () => {
|
||||
const processWithRequests = process as NodeJS.Process & {
|
||||
_getActiveRequests?: () => unknown[];
|
||||
};
|
||||
return processWithRequests._getActiveRequests?.().length ?? null;
|
||||
};
|
||||
|
||||
export interface ProcessDiagnosticsSnapshot {
|
||||
rssMiB: number;
|
||||
heapUsedMiB: number;
|
||||
heapTotalMiB: number;
|
||||
externalMiB: number;
|
||||
arrayBuffersMiB: number;
|
||||
uptimeSeconds: number;
|
||||
userCpuMicros: number;
|
||||
systemCpuMicros: number;
|
||||
maxRssKiB: number;
|
||||
activeHandles: number | null;
|
||||
activeRequests: number | null;
|
||||
}
|
||||
|
||||
export const createProcessDiagnosticsSnapshot = (): ProcessDiagnosticsSnapshot => {
|
||||
const memory = process.memoryUsage();
|
||||
const resources = process.resourceUsage();
|
||||
|
||||
return {
|
||||
rssMiB: bytesToMiB(memory.rss),
|
||||
heapUsedMiB: bytesToMiB(memory.heapUsed),
|
||||
heapTotalMiB: bytesToMiB(memory.heapTotal),
|
||||
externalMiB: bytesToMiB(memory.external),
|
||||
arrayBuffersMiB: bytesToMiB(memory.arrayBuffers),
|
||||
uptimeSeconds: Number(process.uptime().toFixed(0)),
|
||||
userCpuMicros: resources.userCPUTime,
|
||||
systemCpuMicros: resources.systemCPUTime,
|
||||
maxRssKiB: resources.maxRSS,
|
||||
activeHandles: getActiveHandlesCount(),
|
||||
activeRequests: getActiveRequestsCount(),
|
||||
};
|
||||
};
|
||||
|
||||
export const createProcessDiagnosticsDelta = (
|
||||
before: ProcessDiagnosticsSnapshot,
|
||||
after: ProcessDiagnosticsSnapshot,
|
||||
) => {
|
||||
return {
|
||||
rssMiB: Number((after.rssMiB - before.rssMiB).toFixed(2)),
|
||||
heapUsedMiB: Number((after.heapUsedMiB - before.heapUsedMiB).toFixed(2)),
|
||||
heapTotalMiB: Number((after.heapTotalMiB - before.heapTotalMiB).toFixed(2)),
|
||||
externalMiB: Number((after.externalMiB - before.externalMiB).toFixed(2)),
|
||||
arrayBuffersMiB: Number((after.arrayBuffersMiB - before.arrayBuffersMiB).toFixed(2)),
|
||||
uptimeSeconds: Number((after.uptimeSeconds - before.uptimeSeconds).toFixed(0)),
|
||||
userCpuMicros: after.userCpuMicros - before.userCpuMicros,
|
||||
systemCpuMicros: after.systemCpuMicros - before.systemCpuMicros,
|
||||
maxRssKiB: after.maxRssKiB - before.maxRssKiB,
|
||||
activeHandles:
|
||||
before.activeHandles === null || after.activeHandles === null ? null : after.activeHandles - before.activeHandles,
|
||||
activeRequests:
|
||||
before.activeRequests === null || after.activeRequests === null
|
||||
? null
|
||||
: after.activeRequests - before.activeRequests,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import type { JobCallback } from "./creator";
|
||||
import { createProcessDiagnosticsSnapshot } from "./diagnostics";
|
||||
import type { Logger } from "./logger";
|
||||
import { jobRegistry } from "./registry";
|
||||
|
||||
@@ -38,6 +39,10 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
|
||||
return {
|
||||
initializeAsync: async () => {
|
||||
options.logger.logInfo("Initializing cron job group", {
|
||||
registeredJobs: [...jobRegistry.keys()],
|
||||
diagnostics: createProcessDiagnosticsSnapshot(),
|
||||
});
|
||||
const configurations = await db.query.cronJobConfigurations.findMany();
|
||||
for (const job of jobRegistry.values()) {
|
||||
const configuration = configurations.find(({ name }) => name === job.name);
|
||||
@@ -51,7 +56,15 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
const scheduledTask = await job.createTaskAsync();
|
||||
|
||||
tasks.set(job.name, scheduledTask);
|
||||
options.logger.logDebug("Created scheduled task for cron job", {
|
||||
name: job.name,
|
||||
diagnostics: createProcessDiagnosticsSnapshot(),
|
||||
});
|
||||
}
|
||||
options.logger.logInfo("Cron job group initialized", {
|
||||
taskCount: tasks.size,
|
||||
diagnostics: createProcessDiagnosticsSnapshot(),
|
||||
});
|
||||
},
|
||||
startAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
@@ -65,6 +78,10 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
tasks.get(name as string)?.resume();
|
||||
},
|
||||
startAllAsync: async () => {
|
||||
options.logger.logInfo("Starting all cron jobs", {
|
||||
taskCount: tasks.size,
|
||||
diagnostics: createProcessDiagnosticsSnapshot(),
|
||||
});
|
||||
for (const job of jobRegistry.values()) {
|
||||
if (!tasks.has(job.name)) {
|
||||
continue;
|
||||
@@ -76,6 +93,10 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
await job.onStartAsync();
|
||||
tasks.get(job.name)?.resume();
|
||||
}
|
||||
options.logger.logInfo("Started all cron jobs", {
|
||||
taskCount: tasks.size,
|
||||
diagnostics: createProcessDiagnosticsSnapshot(),
|
||||
});
|
||||
},
|
||||
runManuallyAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
|
||||
@@ -5,5 +5,3 @@ export * from "./icon-picker/icon-picker";
|
||||
export * from "./new-app/icon-matcher";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
|
||||
export * from "./user-create/user-create-password-fields";
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordInput, Stack } from "@mantine/core";
|
||||
import type { PasswordInputProps } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
|
||||
interface UserCreatePasswordFieldsProps {
|
||||
passwordInputProps: PasswordInputProps;
|
||||
confirmPasswordInputProps: PasswordInputProps;
|
||||
variant?: PasswordInputProps["variant"];
|
||||
}
|
||||
|
||||
export const UserCreatePasswordFields = ({
|
||||
passwordInputProps,
|
||||
confirmPasswordInputProps,
|
||||
variant,
|
||||
}: UserCreatePasswordFieldsProps) => {
|
||||
const t = useScopedI18n("user.field");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("password.label")}
|
||||
variant={variant}
|
||||
{...passwordInputProps}
|
||||
/>
|
||||
<PasswordInput label={t("passwordConfirm.label")} variant={variant} {...confirmPasswordInputProps} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createGetSetChannel } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "imageProxy" });
|
||||
const IMAGE_PROXY_ENTRY_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
const createHmacChannel = (hmac: `${string}.${string}`) => createGetSetChannel<string>(`image-proxy:hmac:${hmac}`);
|
||||
const createUrlByIdChannel = (id: string) =>
|
||||
@@ -31,12 +32,17 @@ export class ImageProxy {
|
||||
public async createImageAsync(url: string, headers?: Record<string, string>): Promise<string> {
|
||||
const existingId = await this.getExistingIdAsync(url, headers);
|
||||
if (existingId) {
|
||||
logger.debug("Image already exists in the proxy", {
|
||||
id: existingId,
|
||||
url: this.redactUrl(url),
|
||||
headers: this.redactHeaders(headers ?? null),
|
||||
});
|
||||
return this.createImageUrl(existingId);
|
||||
const existingImageData = await createUrlByIdChannel(existingId).getAsync();
|
||||
if (!existingImageData) {
|
||||
await this.removeHashForImageAsync(url, headers);
|
||||
} else {
|
||||
logger.debug("Image already exists in the proxy", {
|
||||
id: existingId,
|
||||
url: this.redactUrl(url),
|
||||
headers: this.redactHeaders(headers ?? null),
|
||||
});
|
||||
return this.createImageUrl(existingId);
|
||||
}
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
@@ -116,8 +122,10 @@ export class ImageProxy {
|
||||
await urlHeaderChannel.setAsync({
|
||||
url: encryptSecret(url),
|
||||
headers: encryptSecret(JSON.stringify(headers ?? null)),
|
||||
}, {
|
||||
ttlSeconds: IMAGE_PROXY_ENTRY_TTL_SECONDS,
|
||||
});
|
||||
await hashChannel.setAsync(id);
|
||||
await hashChannel.setAsync(id, { ttlSeconds: IMAGE_PROXY_ENTRY_TTL_SECONDS });
|
||||
|
||||
logger.debug("Stored image in the proxy", {
|
||||
id,
|
||||
@@ -126,6 +134,13 @@ export class ImageProxy {
|
||||
});
|
||||
}
|
||||
|
||||
private async removeHashForImageAsync(url: string, headers: Record<string, string> | undefined): Promise<void> {
|
||||
const urlHash = this.hashSecret(url);
|
||||
const headerHash = this.hashSecret(JSON.stringify(headers ?? null));
|
||||
|
||||
await createHmacChannel(`${urlHash}.${headerHash}`).removeAsync();
|
||||
}
|
||||
|
||||
private hashSecret(value: string): string {
|
||||
return createHmac("sha256", ImageProxy.getHmacKey()).update(value).digest("hex");
|
||||
}
|
||||
|
||||
@@ -113,8 +113,14 @@ export const createGetSetChannel = <TData>(name: string) => {
|
||||
* Set data in the channel
|
||||
* @param data data to be stored in the channel
|
||||
*/
|
||||
setAsync: async (data: TData) => {
|
||||
await getSetClient.set(name, superjson.stringify(data));
|
||||
setAsync: async (data: TData, options?: { ttlSeconds?: number }) => {
|
||||
const ttlSeconds = options?.ttlSeconds;
|
||||
const serializedData = superjson.stringify(data);
|
||||
if (ttlSeconds && ttlSeconds > 0) {
|
||||
await getSetClient.set(name, serializedData, "EX", Math.ceil(ttlSeconds));
|
||||
return;
|
||||
}
|
||||
await getSetClient.set(name, serializedData);
|
||||
},
|
||||
/**
|
||||
* Remove data from the channel
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Contrasenya",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Inclou com a mínim 8 caràcters",
|
||||
"lowercase": "Inclou minúscules",
|
||||
"uppercase": "Inclou majúscules",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "密码",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "至少包含 8 个字符",
|
||||
"lowercase": "包含小写字母",
|
||||
"uppercase": "包含大写字母",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "crwdns26:0crwdne26:0",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "crwdns28:0crwdne28:0",
|
||||
"lowercase": "crwdns30:0crwdne30:0",
|
||||
"uppercase": "crwdns32:0crwdne32:0",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Heslo",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Obsahuje alespoň 8 znaků",
|
||||
"lowercase": "Obsahuje malé písmeno",
|
||||
"uppercase": "Obsahuje velké písmeno",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Adgangskode",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Inkluderer mindst 8 tegn",
|
||||
"lowercase": "Inkluderer små bogstaver",
|
||||
"uppercase": "Inkluderer store bogstaver",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Passwort",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Enthält mindestens 8 Zeichen",
|
||||
"lowercase": "Enthält Kleinbuchstaben",
|
||||
"uppercase": "Enthält Großbuchstaben",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Passwort",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Enthält mindestens 8 Zeichen",
|
||||
"lowercase": "Enthält Kleinbuchstaben",
|
||||
"uppercase": "Enthält Großbuchstaben",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Κωδικός",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "Περιλαμβάνει πεζό γράμμα",
|
||||
"uppercase": "Περιλαμβάνει κεφαλαίο γράμμα",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Includes a minimum of 8 characters",
|
||||
"lowercase": "Includes a lower-case letter",
|
||||
"uppercase": "Includes an upper-case letter",
|
||||
|
||||
@@ -132,9 +132,8 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestionsTitle": "Password suggestions",
|
||||
"suggestion": {
|
||||
"length": "At least 8 characters",
|
||||
"requirement": {
|
||||
"length": "Includes at least 8 characters",
|
||||
"lowercase": "Includes lowercase letter",
|
||||
"uppercase": "Includes uppercase letter",
|
||||
"number": "Includes number",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Incluye al menos 8 caracteres",
|
||||
"lowercase": "Incluye al menos una letra minúscula",
|
||||
"uppercase": "Incluye al menos una letra mayúscula",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "",
|
||||
"uppercase": "",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Mot de passe",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Contient au moins 8 caractères",
|
||||
"lowercase": "Inclut une lettre minuscule",
|
||||
"uppercase": "Inclut une lettre majuscule",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "סיסמה",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "כולל לפחות 8 תווים",
|
||||
"lowercase": "אפשר אותיות קטנות",
|
||||
"uppercase": "אפשר אותיות גדולות",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Lozinka",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "Uključuje mala slova",
|
||||
"uppercase": "Uključuje veliko slovo",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Jelszó",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Tartalmazzon legalább 8 karaktert",
|
||||
"lowercase": "Tartalmazzon kisbetűt",
|
||||
"uppercase": "Tartalmazzon nagybetűt",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Include almeno 8 caratteri",
|
||||
"lowercase": "Include lettera minuscola",
|
||||
"uppercase": "Include lettera maiuscola",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "パスワード",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "少なくとも 8 文字以上を含む",
|
||||
"lowercase": "小文字を含む",
|
||||
"uppercase": "大文字を含む",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "비밀번호",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "소문자 포함",
|
||||
"uppercase": "대문자 포함",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Slaptažodis",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "",
|
||||
"uppercase": "",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Parole",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "Ietver mazo burtu",
|
||||
"uppercase": "Ietver lielo burtu",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Wachtwoord",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Bevat ten minste 8 tekens",
|
||||
"lowercase": "Inclusief kleine letter",
|
||||
"uppercase": "Inclusief hoofdletter",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Passord",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Inkluderer minst 8 tegn",
|
||||
"lowercase": "Inkluderer liten bokstav",
|
||||
"uppercase": "Inkluderer stor bokstav",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Hasło",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Zawiera co najmniej 8 znaków",
|
||||
"lowercase": "Zawiera małą literę",
|
||||
"uppercase": "Zawiera wielką literę",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Senha",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Inclua pelo menos 8 caracteres",
|
||||
"lowercase": "Inclui letras minúsculas",
|
||||
"uppercase": "Inclui letras maiúsculas",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "",
|
||||
"uppercase": "",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Parola",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "Să includă litere mici",
|
||||
"uppercase": "Să includă litere mari",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Содержит не менее 8 символов",
|
||||
"lowercase": "Содержит строчную букву",
|
||||
"uppercase": "Содержит заглавную букву",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Heslo",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Obsahuje aspoň 8 znakov",
|
||||
"lowercase": "Zahŕňa malé písmeno",
|
||||
"uppercase": "Zahŕňa veľké písmená",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Geslo",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"lowercase": "Vključuje male črke",
|
||||
"uppercase": "Vključuje velike tiskane črke",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Lösenord",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Använd minst 8 tecken",
|
||||
"lowercase": "Använd liten bokstav",
|
||||
"uppercase": "Använd stor bokstav",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Şifre",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "En az 8 karakter içermeli",
|
||||
"lowercase": "Küçük harf içermeli",
|
||||
"uppercase": "Büyük harf içermeli",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Включає принаймні 8 символів",
|
||||
"lowercase": "Включає малу літеру",
|
||||
"uppercase": "Включає велику літеру",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Mật khẩu",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "Bao gồm ít nhất 8 kí tự",
|
||||
"lowercase": "Bao gồm chữ thường",
|
||||
"uppercase": "Bao gồm chữ in hoa",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "密碼",
|
||||
"suggestion": {
|
||||
"requirement": {
|
||||
"length": "至少包含 8 個字符",
|
||||
"lowercase": "包含小寫字母",
|
||||
"uppercase": "包含大寫字母",
|
||||
|
||||
@@ -20,15 +20,15 @@ export const CustomPasswordInput = ({ withPasswordRequirements, ...props }: Cust
|
||||
};
|
||||
|
||||
const WithPasswordRequirements = (props: PasswordInputProps) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
props.onChange?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<PasswordRequirementsPopover password={password}>
|
||||
<PasswordRequirementsPopover password={value}>
|
||||
<PasswordInput {...props} onChange={onChange} />
|
||||
</PasswordRequirementsPopover>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { rem, Text } from "@mantine/core";
|
||||
import { IconCheck, IconPoint } from "@tabler/icons-react";
|
||||
|
||||
const getRequirementDisplay = (meets: boolean) =>
|
||||
({
|
||||
true: { color: "teal", Icon: IconCheck },
|
||||
false: { color: "dimmed", Icon: IconPoint },
|
||||
})[String(meets) as "true" | "false"];
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
|
||||
export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||
const display = getRequirementDisplay(meets);
|
||||
const Icon = display.Icon;
|
||||
|
||||
return (
|
||||
<Text c={display.color} display="flex" style={{ alignItems: "center" }} size="sm">
|
||||
<Icon style={{ width: rem(14), height: rem(14) }} />
|
||||
<Text c={meets ? "teal" : "red"} display="flex" style={{ alignItems: "center" }} size="sm">
|
||||
{meets ? (
|
||||
<IconCheck style={{ width: rem(14), height: rem(14) }} />
|
||||
) : (
|
||||
<IconX style={{ width: rem(14), height: rem(14) }} />
|
||||
)}
|
||||
<Text span ml={10}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { Popover, Progress, Text } from "@mantine/core";
|
||||
import { Popover, Progress } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { passwordRequirements } from "@homarr/validation/user";
|
||||
|
||||
import { PasswordRequirement } from "./password-requirement";
|
||||
|
||||
const strengthColorMap = [
|
||||
{ min: 100, color: "teal" },
|
||||
{ min: 51, color: "yellow" },
|
||||
{ min: 0, color: "dimmed" },
|
||||
] as const;
|
||||
|
||||
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
|
||||
const tPassword = useScopedI18n("user.field.password");
|
||||
const requirements = useRequirements();
|
||||
const strength = useStrength(password);
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const strengthColor = strengthColorMap.find(({ min }) => strength >= min)?.color ?? "dimmed";
|
||||
const checks = (
|
||||
<>
|
||||
{requirements.map((requirement) => (
|
||||
@@ -27,6 +19,8 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
|
||||
</>
|
||||
);
|
||||
|
||||
const color = strength === 100 ? "teal" : strength > 50 ? "yellow" : "red";
|
||||
|
||||
return (
|
||||
<Popover opened={popoverOpened} position="bottom" width="target" transitionProps={{ transition: "pop" }}>
|
||||
<Popover.Target>
|
||||
@@ -35,10 +29,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Progress color={strengthColor} value={strength} size={5} mb="xs" />
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{tPassword("suggestionsTitle")}
|
||||
</Text>
|
||||
<Progress color={color} value={strength} size={5} mb="xs" />
|
||||
{checks}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
@@ -46,7 +37,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
|
||||
};
|
||||
|
||||
const useRequirements = () => {
|
||||
const t = useScopedI18n("user.field.password.suggestion");
|
||||
const t = useScopedI18n("user.field.password.requirement");
|
||||
|
||||
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
|
||||
};
|
||||
|
||||
@@ -19,10 +19,24 @@ export const passwordRequirements = [
|
||||
{ check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!\-~`"_/\\[\]{}]/), value: "special" },
|
||||
] satisfies {
|
||||
check: (value: string) => boolean;
|
||||
value: keyof TranslationObject["user"]["field"]["password"]["suggestion"];
|
||||
value: keyof TranslationObject["user"]["field"]["password"]["requirement"];
|
||||
}[];
|
||||
|
||||
export const userPasswordSchema = z.string().min(8).max(255);
|
||||
export const userPasswordSchema = z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(255)
|
||||
.refine(
|
||||
(value) => {
|
||||
return passwordRequirements.every((requirement) => requirement.check(value));
|
||||
},
|
||||
{
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordRequirements",
|
||||
params: {},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const addConfirmPasswordRefinement = <
|
||||
TSchema extends z.ZodObject<{ password: z.core.$ZodString; confirmPassword: z.core.$ZodString }, z.core.$strip>,
|
||||
|
||||
Generated
+14
-14
@@ -388,8 +388,8 @@ catalogs:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
mantine-react-table:
|
||||
specifier: npm:mantine-react-table-open@9.0.3
|
||||
version: 9.0.3
|
||||
specifier: 2.0.0-beta.9
|
||||
version: 2.0.0-beta.9
|
||||
maria2:
|
||||
specifier: ^0.4.1
|
||||
version: 0.4.1
|
||||
@@ -819,7 +819,7 @@ importers:
|
||||
version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.15)(react@19.2.6)
|
||||
mantine-react-table:
|
||||
specifier: 'catalog:'
|
||||
version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 2.0.0-beta.9(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.100.0)
|
||||
@@ -2204,7 +2204,7 @@ importers:
|
||||
version: 4.3.1
|
||||
mantine-react-table:
|
||||
specifier: 'catalog:'
|
||||
version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 2.0.0-beta.9(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.100.0)
|
||||
@@ -2259,7 +2259,7 @@ importers:
|
||||
version: 4.2.0
|
||||
mantine-react-table:
|
||||
specifier: 'catalog:'
|
||||
version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 2.0.0-beta.9(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.100.0)
|
||||
@@ -2450,7 +2450,7 @@ importers:
|
||||
version: 1.11.21
|
||||
mantine-react-table:
|
||||
specifier: 'catalog:'
|
||||
version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 2.0.0-beta.9(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.100.0)
|
||||
@@ -7442,18 +7442,18 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
mantine-react-table-open@9.0.3:
|
||||
resolution: {integrity: sha512-HsrBU4KZluj96FPvvH0wcLzog0m5LIMdwB/59oj6BpftLGEibiLxX00G+tDM06PL2jXuiRxiGKPJ4oQOV+VK8w==}
|
||||
mantine-react-table@2.0.0-beta.9:
|
||||
resolution: {integrity: sha512-ZdfcwebWaPERoDvAuk43VYcBCzamohARVclnbuepT0PHZ0wRcDPMBR+zgaocL+pFy8EXUGwvWTOKNh25ITpjNQ==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
'@mantine/core': ^9.0.0
|
||||
'@mantine/dates': ^9.0.0
|
||||
'@mantine/hooks': ^9.0.0
|
||||
'@mantine/core': ^7.9
|
||||
'@mantine/dates': ^7.9
|
||||
'@mantine/hooks': ^7.9
|
||||
'@tabler/icons-react': '>=2.23.0'
|
||||
clsx: '>=2'
|
||||
dayjs: '>=1.11'
|
||||
react: '>=19.2'
|
||||
react-dom: '>=19.2'
|
||||
react: '>=18.0'
|
||||
react-dom: '>=18.0'
|
||||
|
||||
maria2@0.4.1:
|
||||
resolution: {integrity: sha512-3lr8UeB+R+BN9c7eOBkeH1GlHf7+0Kzt0pcEgsT9xoXIA5gfXAzjd1niYiYDsvb62v5rLPqCqDiyYxtmmhGJ3g==}
|
||||
@@ -14795,7 +14795,7 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
mantine-react-table@2.0.0-beta.9(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ catalog:
|
||||
json5: ^2.2.3
|
||||
ldapts: 8.1.8
|
||||
lodash.clonedeep: ^4.5.0
|
||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||
mantine-react-table: 2.0.0-beta.9
|
||||
maria2: ^0.4.1
|
||||
mysql2: 3.22.4
|
||||
next: 16.2.6
|
||||
|
||||
Reference in new issue
Block a user