Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7ee3263c | ||
|
|
e49e53a32f | ||
|
|
25bd88f30a | ||
|
|
7e93380140 | ||
|
|
78da525883 | ||
|
|
3bbe7f2a98 | ||
|
|
6728d0a63d | ||
|
|
8caf00da79 | ||
|
|
930d9fc489 | ||
|
|
0839007431 | ||
|
|
2cfa806176 | ||
|
|
315245e048 | ||
|
|
ebdfeb724b | ||
|
|
e842177d9b | ||
|
|
e3a713d2df | ||
|
|
e7879ed3a2 | ||
|
|
19e72fc757 | ||
|
|
38fc5dd582 | ||
|
|
6d284adb98 |
No files matched your search
@@ -92,3 +92,6 @@ homarr-backup*.zip
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
/scripts/benchmarks/
|
||||
|
||||
# PR description screenshots belong on the pull request, not in the repository
|
||||
.screenshots/
|
||||
@@ -72,6 +72,34 @@ API key authentication enables programmatic access for:
|
||||
- Custom dashboards and monitoring tools
|
||||
- Container orchestration systems that need to sync app definitions
|
||||
|
||||
## Permissions
|
||||
|
||||
API keys act as the user that created them, so every request is subject to that user's group
|
||||
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
|
||||
return only the records the user may see rather than failing.
|
||||
|
||||
### App endpoints require app management access
|
||||
|
||||
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
|
||||
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
|
||||
returned the full app catalogue — including internal URLs — to any authenticated user.
|
||||
|
||||
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
|
||||
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
|
||||
board pickers.
|
||||
|
||||
### Integration endpoints are filtered per key
|
||||
|
||||
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
|
||||
`integration.search` filter server side. Without a global integration permission, a key only
|
||||
receives the integrations its owner has been granted access to, so responses may contain fewer
|
||||
records than before rather than returning an error. `integration.search` applies its `limit` after
|
||||
filtering, so a scoped key still receives a full page of results.
|
||||
|
||||
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
|
||||
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
|
||||
that integration, not that the call failed.
|
||||
|
||||
## Board Automation
|
||||
|
||||
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
|
||||
|
||||
@@ -51,6 +51,17 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
|
||||
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
|
||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
|
||||
|
||||
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
|
||||
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
|
||||
URLs of existing apps are never disclosed. The create action is still available.
|
||||
- **Neither**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
|
||||
board permissions are not required.
|
||||
@@ -17,6 +17,24 @@ To create an integration, choose **New integration** and select the supported ap
|
||||
|
||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||
|
||||
### Accessing the page
|
||||
|
||||
The `/manage/integrations` page is reachable when at least one of the following applies:
|
||||
|
||||
- you can manage all integrations (global `integration-full-all`),
|
||||
- you can create integrations (`integration-create`), or
|
||||
- you have been granted full access to at least one specific integration.
|
||||
|
||||
What the page shows depends on the case:
|
||||
|
||||
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
|
||||
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
|
||||
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
|
||||
- **None**: the page is not reachable and returns a not-found page.
|
||||
|
||||
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
|
||||
server side, so a user never receives data about integrations they lack access to.
|
||||
|
||||
Next, you'll need to fill out the required fields to configure the integration:
|
||||
|
||||

|
||||
|
||||
@@ -163,19 +163,77 @@ Members can be removed from the group using the remove button at the end:
|
||||
#### Permissions tab
|
||||
|
||||
In this tab, you manage what permissions the members of this group will have.
|
||||
Currently, the following permissions are available:
|
||||
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
|
||||
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
|
||||
has.
|
||||
|
||||
- Boards
|
||||
- **Create boards**: Allow members to create boards
|
||||
- **View all boards**: Allow members to view all boards
|
||||
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
|
||||
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
|
||||
- Integrations
|
||||
- **Create integrations**: Allow members to create integrations
|
||||
- **Use all integrations**: Allows members to add any integrations to their boards
|
||||
- **Interact with any integration**: Allow members to interact with any integration
|
||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
||||
- **Administrator**: Members with this permission have full access to all features and settings
|
||||
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
|
||||
category. Choosing **No access** grants no access level for that category.
|
||||
|
||||
##### Capabilities are separate from the access level
|
||||
|
||||
A capability is something you can grant on its own, independently of how much of everyone else's data the members
|
||||
can see. Creating is the usual example: you can let members create their own boards without letting them view or
|
||||
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
|
||||
without being able to edit apps.
|
||||
|
||||
Some access levels already include a capability through permission inheritance (marked in the table below); the
|
||||
checkbox is then ticked, disabled, and explains which level includes it.
|
||||
|
||||
##### Administration
|
||||
|
||||
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
|
||||
is disabled and explains that administrators already have that access. Setting the administrator row back to
|
||||
**No access** re-enables the other categories with the selections they had before.
|
||||
|
||||
##### Access levels
|
||||
|
||||
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
|
||||
|
||||
| Category | Access levels (ascending) | Capabilities | Already included at |
|
||||
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
|
||||
| Boards | view all boards → modify all boards → full board access | create boards | full board access |
|
||||
| Apps | modify all apps → full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
|
||||
| Integrations | use all integrations → interact with any integration → full integration access | create integrations | full integration access |
|
||||
| Search engines | modify all search engines → full search engine access | create search engines | modify all search engines, full search engine access |
|
||||
| Medias | view all medias → full media access | upload medias | full media access |
|
||||
| Other | view logs | — | — |
|
||||
| Administrator | administrator | — | — |
|
||||
|
||||
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
|
||||
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
|
||||
and picking **Modify** never silently grants use of every app.
|
||||
|
||||
The levels grant the following:
|
||||
|
||||
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
|
||||
- **Apps**: modify all apps; full app access (manage, use and delete any app).
|
||||
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
|
||||
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
|
||||
- **Medias**: view all medias; full media access (manage and delete any media).
|
||||
- **Other**: view logs.
|
||||
|
||||
##### Presets
|
||||
|
||||
Above the rows you can apply a preset to fill in a common configuration in one click:
|
||||
|
||||
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
|
||||
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
|
||||
- **Admin**: grants the administrator permission (full access to everything).
|
||||
|
||||
When the current selection does not match any preset, a **Custom** badge is shown.
|
||||
|
||||
##### Also grants
|
||||
|
||||
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
|
||||
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
|
||||
listing the implied permissions.
|
||||
|
||||
##### Effective permissions
|
||||
|
||||
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
|
||||
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
|
||||
levels - grouped by category, with a count for each.
|
||||
|
||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
|
||||
* navigation and the pages resolve access exactly the same way the API does.
|
||||
*/
|
||||
|
||||
export const getAppsSectionAccess = (session: Session | null) =>
|
||||
getAppManagementAccess(session?.user.permissions ?? []);
|
||||
|
||||
/**
|
||||
* Pure variant for callers that already fetched the integrations the user can see, so the list is
|
||||
* only queried once per request.
|
||||
*/
|
||||
export const getIntegrationsSectionAccess = (
|
||||
session: Session | null,
|
||||
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
|
||||
) =>
|
||||
getIntegrationManagementAccess(
|
||||
session?.user.permissions ?? [],
|
||||
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
|
||||
);
|
||||
|
||||
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
|
||||
// Only pay for the lookup when the answer is not already known from the global permissions.
|
||||
const needsDelegatedLookup =
|
||||
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
|
||||
|
||||
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -16,6 +16,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getAppsSectionAccess } from "../_access";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -41,16 +42,27 @@ interface AppsPageProps {
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
|
||||
if (!canAccess) notFound();
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
|
||||
// so the list stays empty instead of leaking every app name and URL.
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { items: [], totalCount: 0 };
|
||||
const t = await getScopedI18n("app");
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
|
||||
|
||||
const emptyState = hasSearch ? (
|
||||
const emptyState = !canManageAll ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -82,12 +94,14 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
canManageAll ? (
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
@@ -100,7 +114,6 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
@@ -113,12 +126,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
@@ -157,23 +169,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
@@ -41,75 +43,92 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
return (
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
<>
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
actionsAlignment="center"
|
||||
/>
|
||||
{widget.valid && (
|
||||
<CustomWidgetSourceSetupDialog
|
||||
definitionId={widget.id}
|
||||
opened={sourceSetupOpened}
|
||||
onClose={sourceSetupControls.close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
|
||||
function CustomWidgetStatusBadges({
|
||||
widget,
|
||||
onConfigureSources,
|
||||
}: {
|
||||
widget: WidgetDef;
|
||||
onConfigureSources(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
@@ -157,9 +176,17 @@ function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getIntegrationsSectionAccess } from "../_access";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
@@ -29,11 +30,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const integrations = await api.integration.all();
|
||||
const {
|
||||
canManageAll: hasGlobalFullAccess,
|
||||
canCreate,
|
||||
canAccess,
|
||||
} = getIntegrationsSectionAccess(session, integrations);
|
||||
if (!canAccess) notFound();
|
||||
|
||||
const t = await getScopedI18n("integration");
|
||||
const canCreate = session.user.permissions.includes("integration-create");
|
||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||
// Without integration-full-all only the integrations that were explicitly delegated to the user
|
||||
// are manageable, so the list never shows an integration they cannot open.
|
||||
const manageableIntegrations = integrations.filter(
|
||||
(integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess,
|
||||
);
|
||||
|
||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||
const filteredIntegrations = integrations
|
||||
const filteredIntegrations = manageableIntegrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
@@ -53,7 +65,14 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/integrations/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -93,24 +112,33 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
<IntegrationItem key={integration.id} integration={integration} />
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
const pageContent =
|
||||
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
|
||||
if (hasGlobalFullAccess) {
|
||||
return pageContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("page.list.scopedNote")}
|
||||
</Text>
|
||||
{pageContent}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: RouterOutputs["integration"]["all"][number];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
||||
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
@@ -143,21 +171,19 @@ const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps)
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
import { getAppsSectionAccess, getIntegrationsSectionAccessAsync } from "./_access";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
@@ -65,6 +66,8 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const appsAccess = getAppsSectionAccess(session);
|
||||
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -82,7 +85,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -92,7 +95,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
@@ -9,6 +10,9 @@ export default async function NotFound() {
|
||||
<Stack align="center">
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
<Anchor component={Link} href="/manage">
|
||||
{t("backToHome")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,12 @@ export default async function ManagementPage() {
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||
<Card
|
||||
component={Link}
|
||||
href={statistic.path}
|
||||
key={statistic.path}
|
||||
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -42,7 +47,7 @@ export default async function ManagementPage() {
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
+357
-25
@@ -1,17 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
|
||||
import { useCallback, useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Transition,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconShieldExclamation } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissionKeys } from "@homarr/definitions";
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "@homarr/definitions";
|
||||
import { createFormContext } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
|
||||
|
||||
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
|
||||
|
||||
interface PermissionFormProps {
|
||||
@@ -19,27 +45,17 @@ interface PermissionFormProps {
|
||||
}
|
||||
|
||||
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
|
||||
// Mantine compares each field against the values snapshot, so toggling a control back to its
|
||||
// saved value clears the dirty state on its own - no manual bookkeeping needed here.
|
||||
const form = useForm({
|
||||
initialValues: groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = initialPermissions.includes(key);
|
||||
return acc;
|
||||
}, {} as FormType),
|
||||
onValuesChange(values) {
|
||||
const currentKeys = objectEntries(values)
|
||||
.filter(([_key, value]) => Boolean(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
if (
|
||||
currentKeys.every((key) => initialPermissions.includes(key)) &&
|
||||
initialPermissions.every((key) => currentKeys.includes(key))
|
||||
) {
|
||||
form.resetDirty(); // Reset dirty state if all keys are the same as initial
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form>
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<FormProvider form={form}>{children}</FormProvider>
|
||||
</form>
|
||||
);
|
||||
@@ -47,15 +63,329 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
||||
|
||||
type FormType = Record<GroupPermissionKey, boolean>;
|
||||
|
||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
||||
const form = useFormContext();
|
||||
type MatrixForm = ReturnType<typeof useFormContext>;
|
||||
|
||||
const props = form.getInputProps(name, {
|
||||
withError: false,
|
||||
type: "checkbox",
|
||||
const emptyMatrixState = (): PermissionMatrixState =>
|
||||
objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
|
||||
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
|
||||
|
||||
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
|
||||
matrixStateOf(values)[category].level;
|
||||
|
||||
export const hasMatrixCapability = (
|
||||
values: FormType,
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
): boolean => matrixStateOf(values)[category].capabilities.includes(capability);
|
||||
|
||||
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
|
||||
const targetKeys = new Set(
|
||||
matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }),
|
||||
);
|
||||
permissionMatrix[category].levels.forEach((key) => {
|
||||
form.setFieldValue(key, targetKeys.has(key));
|
||||
});
|
||||
// Capability keys are deliberately left untouched: a level that implies one already covers it,
|
||||
// and an explicitly granted capability must survive lowering the level again.
|
||||
};
|
||||
|
||||
return <Switch {...props} />;
|
||||
export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => {
|
||||
form.setFieldValue(capability, granted);
|
||||
};
|
||||
|
||||
const presets = {
|
||||
viewer: {
|
||||
board: { level: 1, capabilities: [] },
|
||||
app: { level: 0, capabilities: ["app-use-all"] },
|
||||
integration: { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: [] },
|
||||
},
|
||||
editor: {
|
||||
board: { level: 2, capabilities: ["board-create"] },
|
||||
app: { level: 1, capabilities: ["app-use-all"] },
|
||||
integration: { level: 2, capabilities: ["integration-create"] },
|
||||
"search-engine": { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: ["media-upload"] },
|
||||
},
|
||||
admin: { admin: { level: 1, capabilities: [] } },
|
||||
} satisfies Record<string, Partial<PermissionMatrixState>>;
|
||||
|
||||
export type PermissionPreset = keyof typeof presets;
|
||||
|
||||
const presetToFormState = (preset: PermissionPreset): FormType => {
|
||||
const presetState: Partial<PermissionMatrixState> = presets[preset];
|
||||
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = presetState[category] ?? { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
const enabledKeys = new Set(matrixStateToPermissions(state));
|
||||
|
||||
return groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = enabledKeys.has(key);
|
||||
return acc;
|
||||
}, {} as FormType);
|
||||
};
|
||||
|
||||
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
|
||||
form.setValues(presetToFormState(preset));
|
||||
};
|
||||
|
||||
export const getActivePreset = (values: FormType): PermissionPreset | null => {
|
||||
return (
|
||||
objectKeys(presets).find((preset) => {
|
||||
const presetValues = presetToFormState(preset);
|
||||
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Short, scannable label for a level. Categories with a single level use their permission label
|
||||
* instead, so the control reads "No access | View logs" rather than "No access | Enabled".
|
||||
*/
|
||||
const levelLabelKey = (key: GroupPermissionKey) => {
|
||||
if (key.endsWith("-view-all")) return "view" as const;
|
||||
if (key.endsWith("-use-all")) return "use" as const;
|
||||
if (key.endsWith("-interact-all")) return "interact" as const;
|
||||
if (key.endsWith("-modify-all")) return "modify" as const;
|
||||
if (key.endsWith("-full-all")) return "full" as const;
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MatrixImpliedHintProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const level = getMatrixLevel(form.getValues(), category);
|
||||
if (level === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
|
||||
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
|
||||
if (implied.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = implied.map((key) => permissionLabels[key] ?? key);
|
||||
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatrixRowProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const values = form.getValues();
|
||||
const level = getMatrixLevel(values, category);
|
||||
const isDanger = category === "admin";
|
||||
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
|
||||
const { levels, capabilities } = permissionMatrix[category];
|
||||
const hasSingleLevel = levels.length === 1;
|
||||
|
||||
const data = [
|
||||
{ value: "0", label: tPermissions("matrix.none") },
|
||||
...levels.map((key, index) => {
|
||||
const shortKey = hasSingleLevel ? null : levelLabelKey(key);
|
||||
return {
|
||||
value: String(index + 1),
|
||||
label: shortKey === null ? (permissionLabels[key] ?? key) : tPermissions(`matrix.level.${shortKey}`),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const capabilityStates = capabilities.map((capability) => ({
|
||||
capability,
|
||||
checked: hasMatrixCapability(values, category, capability),
|
||||
implied: isCapabilityImpliedByLevel(category, capability, level),
|
||||
}));
|
||||
|
||||
const selectedKey = level === 0 ? undefined : levels[level - 1];
|
||||
// With no access level but a capability granted, describing the row as "no access" would
|
||||
// contradict the checkbox next to it, so the granted capability describes the row instead.
|
||||
const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability;
|
||||
const description =
|
||||
describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? "");
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" wrap="wrap" align="center" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
|
||||
<Text fw={500} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md" wrap="wrap" align="center">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={data}
|
||||
value={String(level)}
|
||||
disabled={disabledByAdmin}
|
||||
aria-label={tPermissions(`${category}.title`)}
|
||||
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
|
||||
/>
|
||||
{/* Fixed-width column so every level control lines up, whatever capabilities a row has. */}
|
||||
<Box w={{ base: "auto", sm: 230 }}>
|
||||
<Stack gap={4}>
|
||||
{capabilityStates.map(({ capability, checked, implied }) => (
|
||||
<Tooltip
|
||||
key={capability}
|
||||
label={tPermissions("matrix.capabilityIncluded", { level: data[level]?.label ?? "" })}
|
||||
disabled={!implied}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={permissionLabels[capability] ?? capability}
|
||||
checked={checked}
|
||||
disabled={disabledByAdmin || implied}
|
||||
onChange={(event) => applyMatrixCapability(form, capability, event.currentTarget.checked)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
{disabledByAdmin && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.grantedViaAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionMatrixProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const PermissionMatrix = ({ permissionLabels, permissionDescriptions }: PermissionMatrixProps) => (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
{objectKeys(permissionMatrix).map((category, index) => (
|
||||
<Stack key={category} gap="md">
|
||||
{index > 0 && <Divider />}
|
||||
<MatrixRow
|
||||
category={category}
|
||||
permissionLabels={permissionLabels}
|
||||
permissionDescriptions={permissionDescriptions}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const PresetButtons = () => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const activePreset = getActivePreset(form.getValues());
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions("matrix.preset.label")}
|
||||
</Text>
|
||||
{(["viewer", "editor", "admin"] as const).map((preset) => (
|
||||
<Tooltip key={preset} label={tPermissions(`matrix.preset.${preset}Description`)}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={activePreset === preset ? "filled" : "default"}
|
||||
onClick={() => applyPreset(form, preset)}
|
||||
>
|
||||
{tPermissions(`matrix.preset.${preset}`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface EffectivePermissionPreviewProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const panelId = useId();
|
||||
|
||||
const values = form.getValues();
|
||||
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||
const effective = getPermissionsWithChildren(checkedKeys);
|
||||
|
||||
const grouped = objectKeys(permissionMatrix)
|
||||
.map((category) => ({
|
||||
category,
|
||||
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
|
||||
}))
|
||||
.filter(({ keys }) => keys.length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened} aria-controls={panelId}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.effective.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light">{effective.length}</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse id={panelId} expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{grouped.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{tPermissions("matrix.effective.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
grouped.map(({ category, keys }) => (
|
||||
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
{keys.length}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
@@ -80,8 +410,10 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Set new initial values for discard and reset dirty state
|
||||
// setInitialValues moves the snapshot so Discard returns here, but only resetDirty clears
|
||||
// the dirty flags that keep the unsaved-changes bar on screen.
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty(values);
|
||||
showSuccessNotification({
|
||||
title: tNotification("success.title"),
|
||||
message: tNotification("success.message"),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -9,7 +8,14 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissions } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||
import type { PermissionLabels } from "./_group-permission-form";
|
||||
import {
|
||||
EffectivePermissionPreview,
|
||||
PermissionForm,
|
||||
PermissionMatrix,
|
||||
PresetButtons,
|
||||
SaveAffix,
|
||||
} from "./_group-permission-form";
|
||||
|
||||
interface GroupPermissionsPageProps {
|
||||
params: Promise<{
|
||||
@@ -26,27 +32,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const tPermissions = await getScopedI18n("group.permission");
|
||||
const t = await getI18n();
|
||||
|
||||
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.permissions.title")}</Title>
|
||||
|
||||
<PermissionForm initialPermissions={group.permissions}>
|
||||
<Stack pos="relative">
|
||||
{objectKeys(groupPermissions).map((group) => {
|
||||
const isDanger = group === "admin";
|
||||
<PresetButtons />
|
||||
|
||||
return (
|
||||
<Stack key={group} gap="sm">
|
||||
<Title order={2} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${group}.title`)}
|
||||
</Title>
|
||||
<PermissionCard isDanger={isDanger} group={group} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
|
||||
|
||||
<EffectivePermissionPreview permissionLabels={permissionLabels} />
|
||||
|
||||
<SaveAffix groupId={group.id} />
|
||||
</Stack>
|
||||
@@ -55,66 +55,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionCardProps {
|
||||
group: keyof typeof groupPermissions;
|
||||
isDanger: boolean;
|
||||
}
|
||||
const buildPermissionTextsAsync = async () => {
|
||||
const permissionLabels: PermissionLabels = {};
|
||||
const permissionDescriptions: PermissionLabels = {};
|
||||
|
||||
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
||||
const t = await getScopedI18n(`group.permission.${group}.item`);
|
||||
const item = groupPermissions[group];
|
||||
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
|
||||
for (const category of objectKeys(groupPermissions)) {
|
||||
const tItem = await getScopedI18n(`group.permission.${category}.item`);
|
||||
const item = groupPermissions[category];
|
||||
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{permissions.map((permission, index) => (
|
||||
<React.Fragment key={permission}>
|
||||
<PermissionRow
|
||||
name={createGroupPermissionKey(group, permission)}
|
||||
label={t(`${permission}.label`)}
|
||||
description={t(`${permission}.description`)}
|
||||
/>
|
||||
|
||||
{index < permissions.length - 1 && (
|
||||
<CardSection>
|
||||
<Divider />
|
||||
</CardSection>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
|
||||
if (typeof groupPermissions[group] === "boolean") {
|
||||
return group as GroupPermissionKey;
|
||||
suffixes.forEach((suffix) => {
|
||||
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
|
||||
permissionLabels[key] = tItem(`${suffix}.label`);
|
||||
permissionDescriptions[key] = tItem(`${suffix}.description`);
|
||||
});
|
||||
}
|
||||
|
||||
return `${group}-${permission}` as GroupPermissionKey;
|
||||
};
|
||||
|
||||
interface PermissionRowProps {
|
||||
name: GroupPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{label}</Text>
|
||||
<Text c="gray.5">{description}</Text>
|
||||
</Stack>
|
||||
<PermissionSwitch name={name} />
|
||||
</Group>
|
||||
);
|
||||
return { permissionLabels, permissionDescriptions };
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
createCustomWidgetSourceSetupValues,
|
||||
CustomWidgetSourceSetupPanel,
|
||||
isCustomWidgetSourceSetupReady,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type {
|
||||
CustomWidgetSourceSetupMessages,
|
||||
CustomWidgetSourceSetupValue,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CustomWidgetSourceSetupDialogProps {
|
||||
definitionId: string;
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetSourceSetupDialog({
|
||||
definitionId,
|
||||
opened,
|
||||
onClose,
|
||||
}: CustomWidgetSourceSetupDialogProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
|
||||
const configureMutation = clientApi.customWidget.sourceConfigure.useMutation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
const setups = useMemo(
|
||||
() => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []),
|
||||
[definitionQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
}, [definitionQuery.data, opened, setups]);
|
||||
|
||||
const messages: CustomWidgetSourceSetupMessages = {
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("sourceSetupDialog.description"),
|
||||
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
|
||||
baseUrl: t("workbench.sources.baseUrl"),
|
||||
networkScope: t("workbench.sources.networkScope"),
|
||||
authentication: t("workbench.sources.authentication"),
|
||||
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
|
||||
ready: t("importReview.sourceSetup.ready"),
|
||||
needsUrl: t("importReview.sourceSetup.needsUrl"),
|
||||
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
|
||||
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const setup of setups) {
|
||||
const value = values[setup.sourceId];
|
||||
if (!value) continue;
|
||||
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
);
|
||||
await configureMutation.mutateAsync({
|
||||
definitionId,
|
||||
sourceId: setup.sourceId,
|
||||
baseUrl: value.baseUrl,
|
||||
networkScope: value.networkScope,
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
showSuccessNotification({
|
||||
title: t("action.configureSources"),
|
||||
message: t("notification.sourcesConfigured"),
|
||||
});
|
||||
await utils.customWidget.get.invalidate({ id: definitionId });
|
||||
await utils.customWidget.list.invalidate();
|
||||
await utils.customWidget.available.invalidate();
|
||||
await utils.widget.customApi.getData.invalidate();
|
||||
await revalidatePathActionAsync("/manage/custom-widgets");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.configureSources"),
|
||||
message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("sourceSetupDialog.title")} size="lg">
|
||||
{definitionQuery.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : definitionQuery.error ? (
|
||||
<Text c="red" size="sm">
|
||||
{definitionQuery.error.message}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={setups}
|
||||
values={values}
|
||||
messages={messages}
|
||||
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={saving}>
|
||||
{t("sourceSetupDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
loading={saving}
|
||||
disabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
>
|
||||
{t("sourceSetupDialog.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export interface ManageCollectionItemProps {
|
||||
metadata?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
actionsAlignment?: "flex-start" | "center";
|
||||
}
|
||||
|
||||
export const ManageCollectionItem = ({
|
||||
@@ -46,6 +47,7 @@ export const ManageCollectionItem = ({
|
||||
metadata,
|
||||
badges,
|
||||
actions,
|
||||
actionsAlignment = "flex-start",
|
||||
}: ManageCollectionItemProps) => (
|
||||
<Paper component="li" p="sm" withBorder radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||
@@ -59,7 +61,7 @@ export const ManageCollectionItem = ({
|
||||
{metadata}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0, alignSelf: actionsAlignment }}>
|
||||
{actions}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -50,7 +51,8 @@ export const appRouter = createTRPCRouter({
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
all: protectedProcedure
|
||||
all: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({
|
||||
@@ -88,6 +90,16 @@ export const appRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(({ ctx, input }) => {
|
||||
if (
|
||||
!ctx.session.user.permissions.includes("app-modify-all") &&
|
||||
!ctx.session.user.permissions.includes("board-modify-all")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db.query.apps.findMany({
|
||||
where: like(apps.name, `%${input.query}%`),
|
||||
orderBy: asc(apps.name),
|
||||
|
||||
@@ -302,16 +302,43 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
|
||||
// Stored exactly as selected - implied permissions are resolved at read time through
|
||||
// getPermissionsWithChildren, so nothing is silently escalated here.
|
||||
const permissions = [...new Set(input.permissions)];
|
||||
|
||||
if (input.permissions.length > 0) {
|
||||
await ctx.db.insert(groupPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
}
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.delete(schema.groupPermissions).where(eq(schema.groupPermissions.groupId, input.groupId));
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
await transaction.insert(schema.groupPermissions).values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)).run();
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
transaction
|
||||
.insert(groupPermissions)
|
||||
.values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
transferOwnership: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { db, eq, inArray, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -6,16 +8,46 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
invites,
|
||||
medias,
|
||||
searchEngines,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
|
||||
const accessibleIntegrations = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return accessibleIntegrations
|
||||
.filter((integration) => constructIntegrationPermissions(integration, null).hasFullAccess)
|
||||
.map((integration) => integration.id);
|
||||
};
|
||||
|
||||
interface HomeStatistic {
|
||||
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
|
||||
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
|
||||
@@ -92,20 +124,30 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||
// A card is only shown when the page behind it is reachable, and it counts only what the user is
|
||||
// allowed to see. The rules come from @homarr/definitions so this cannot drift from the pages.
|
||||
const permissions = ctx.session?.user.permissions ?? [];
|
||||
const hasGlobalFullIntegrationAccess = permissions.includes("integration-full-all");
|
||||
const fullAccessIntegrationIds =
|
||||
hasGlobalFullIntegrationAccess || !ctx.session?.user
|
||||
? []
|
||||
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session.user.id);
|
||||
const integrationAccess = getIntegrationManagementAccess(permissions, fullAccessIntegrationIds.length > 0);
|
||||
if (integrationAccess.canAccess) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(integrations),
|
||||
count: integrationAccess.canManageAll ? await db.$count(integrations) : fullAccessIntegrationIds.length,
|
||||
path: "/manage/integrations",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
const appAccess = getAppManagementAccess(permissions);
|
||||
if (appAccess.canAccess) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(apps),
|
||||
count: appAccess.canManageAll ? await db.$count(apps) : 0,
|
||||
path: "/manage/apps",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
@@ -71,80 +72,18 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrations = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
return integrations
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
return await getAccessibleIntegrationsAsync(ctx);
|
||||
}),
|
||||
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(
|
||||
integrations.kind,
|
||||
objectEntries(integrationDefs)
|
||||
.filter(([_, integration]) => [...integration.category].includes("search"))
|
||||
.map(([kind, _]) => kind),
|
||||
),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
);
|
||||
}),
|
||||
allOfGivenCategory: protectedProcedure
|
||||
.input(
|
||||
@@ -153,42 +92,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const intergrationKinds = getIntegrationKindsByCategory(input.category);
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(integrations.kind, intergrationKinds),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(integrations.kind, getIntegrationKindsByCategory(input.category)),
|
||||
);
|
||||
}),
|
||||
search: protectedProcedure
|
||||
.meta({
|
||||
@@ -205,11 +112,29 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where: like(integrations.name, `%${input.query}%`),
|
||||
orderBy: asc(integrations.name),
|
||||
limit: input.limit,
|
||||
with: getIntegrationAccessRelationsAsync(
|
||||
ctx.session.user.id,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationA.name.localeCompare(integrationB.name) || integrationA.id.localeCompare(integrationB.id),
|
||||
)
|
||||
.slice(0, input.limit)
|
||||
.map(
|
||||
({ userPermissions: _userPermissions, groupPermissions: _groupPermissions, ...integration }) => integration,
|
||||
);
|
||||
}),
|
||||
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
|
||||
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||
@@ -774,6 +699,66 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const globalIntegrationPermissions = [
|
||||
"integration-use-all",
|
||||
"integration-interact-all",
|
||||
"integration-full-all",
|
||||
] satisfies GroupPermissionKey[];
|
||||
|
||||
const hasGlobalIntegrationAccess = (session: Session | null) =>
|
||||
globalIntegrationPermissions.some((permission) => session?.user.permissions.includes(permission) ?? false);
|
||||
|
||||
const hasIntegrationQueryAccess = (
|
||||
session: Session | null,
|
||||
integration: {
|
||||
userPermissions: { permission: IntegrationPermission }[];
|
||||
groupPermissions: { permission: IntegrationPermission }[];
|
||||
},
|
||||
) => hasGlobalIntegrationAccess(session) || constructIntegrationPermissions(integration, session).hasUseAccess;
|
||||
|
||||
const getIntegrationAccessRelationsAsync = (userId: string, groupIds: string[]) => ({
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(integrationGroupPermissions.groupId, groupIds),
|
||||
},
|
||||
});
|
||||
|
||||
const getAccessibleIntegrationsAsync = async (ctx: { db: Database; session: Session }, where?: SQL) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where,
|
||||
with: getIntegrationAccessRelationsAsync(
|
||||
ctx.session.user.id,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind) ||
|
||||
integrationA.name.localeCompare(integrationB.name) ||
|
||||
integrationA.id.localeCompare(integrationB.id),
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
await db.insert(apps).values([
|
||||
@@ -74,6 +74,20 @@ describe("all should return all apps", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.all();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
@@ -87,12 +101,12 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps without board permissions", async () => {
|
||||
test("should return apps with app-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
@@ -101,6 +115,43 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should require app-modify-all or board-modify-all permission", () => {
|
||||
test("should throw FORBIDDEN without app-modify-all or board-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all permission", 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" });
|
||||
|
||||
const result = await caller.search({ query: "Homarr" });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an app by id", () => {
|
||||
|
||||
@@ -449,6 +449,165 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||
});
|
||||
|
||||
test("with a standalone create permission it should not escalate to its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-create"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["board-create"]);
|
||||
});
|
||||
|
||||
test.each([["app-create"], ["integration-create"], ["search-engine-create"], ["media-upload"]] as const)(
|
||||
"with %s alone it should persist exactly that permission",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: [permission] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission: key }) => key)).toEqual([permission]);
|
||||
},
|
||||
);
|
||||
|
||||
test("with a create permission and its parent it should persist exactly two rows", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create", "app-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(2);
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with duplicate permissions it should persist each permission only once", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-view-all", "board-view-all", "board-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-modify-all", "board-view-all"]);
|
||||
});
|
||||
|
||||
test("saving the same permissions twice should be idempotent", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-create", "media-upload"]);
|
||||
});
|
||||
|
||||
test("with an empty permission list it should clear all existing permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values([
|
||||
{ groupId, permission: "admin" },
|
||||
{ groupId, permission: "board-full-all" },
|
||||
]);
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -58,6 +58,171 @@ describe("all should return all integrations", () => {
|
||||
expect(result[1]!.kind).toBe("homeAssistant");
|
||||
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
|
||||
});
|
||||
|
||||
test("with integration full access and no per-integration rows should return all integrations with full access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(2);
|
||||
for (const integration of result) {
|
||||
expect(integration.permissions.hasFullAccess).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("with integration use access should return all integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test("without global integration access should only return integrations with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("with a per-integration interact grant should return the integration with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "interact",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should return accessible integrations", () => {
|
||||
test("without global integration access should only return integrations with use access as plain rows", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.search({ query: "Home" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]).not.toHaveProperty("userPermissions");
|
||||
expect(result[0]).not.toHaveProperty("groupPermissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
|
||||
@@ -124,7 +124,7 @@ export const integrationDefs = {
|
||||
readarr: {
|
||||
name: "Readarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
|
||||
defaultPort: 8787,
|
||||
|
||||
@@ -117,3 +117,139 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
|
||||
}
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
|
||||
/**
|
||||
* Describes how each permission category is presented in the group permission matrix UI.
|
||||
*
|
||||
* `levels` is an ordered escalation of access: every level implies the one below it through
|
||||
* {@link groupPermissionParents}, so picking level N grants everything up to N.
|
||||
*
|
||||
* `capabilities` are the keys that are NOT part of that ladder - "may add new resources", "may
|
||||
* upload", "may put any app on a board". They can be granted on their own (e.g. "members may create
|
||||
* their own boards but not see anyone else's") and some levels already imply them; use
|
||||
* {@link isCapabilityImpliedByLevel} to detect that instead of hard-coding it.
|
||||
*
|
||||
* Only keys that genuinely imply each other may share a ladder. `app-modify-all` implies
|
||||
* `app-create` but NOT `app-use-all` (only `app-full-all` does), so `app-use-all` is a capability -
|
||||
* putting it in the ladder would make picking "Modify" silently grant use of every app.
|
||||
*
|
||||
* Together, `levels` and `capabilities` cover every key in {@link groupPermissionKeys}, so the
|
||||
* matrix is a lossless representation of a group's permissions.
|
||||
*/
|
||||
export const permissionMatrix = {
|
||||
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"], capabilities: ["board-create"] },
|
||||
app: { levels: ["app-modify-all", "app-full-all"], capabilities: ["app-use-all", "app-create"] },
|
||||
integration: {
|
||||
levels: ["integration-use-all", "integration-interact-all", "integration-full-all"],
|
||||
capabilities: ["integration-create"],
|
||||
},
|
||||
"search-engine": {
|
||||
levels: ["search-engine-modify-all", "search-engine-full-all"],
|
||||
capabilities: ["search-engine-create"],
|
||||
},
|
||||
media: { levels: ["media-view-all", "media-full-all"], capabilities: ["media-upload"] },
|
||||
other: { levels: ["other-view-logs"], capabilities: [] },
|
||||
admin: { levels: ["admin"], capabilities: [] },
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{ levels: readonly GroupPermissionKey[]; capabilities: readonly GroupPermissionKey[] }
|
||||
>;
|
||||
|
||||
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
|
||||
|
||||
export interface PermissionMatrixCategoryState {
|
||||
/** 0 = no access, N = every key up to `permissionMatrix[category].levels[N - 1]` */
|
||||
level: number;
|
||||
/** The category's granted capability keys, including the ones the level already implies */
|
||||
capabilities: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
export type PermissionMatrixState = Record<PermissionMatrixCategory, PermissionMatrixCategoryState>;
|
||||
|
||||
/**
|
||||
* Whether the given level already grants a capability through permission inheritance.
|
||||
* For example board level 3 (`board-full-all`) implies `board-create`.
|
||||
*/
|
||||
export const isCapabilityImpliedByLevel = (
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
level: number,
|
||||
) => {
|
||||
if (level <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(capability);
|
||||
};
|
||||
|
||||
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
|
||||
const permissionSet = new Set(permissions);
|
||||
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
let level = 0;
|
||||
permissionMatrix[category].levels.forEach((key, index) => {
|
||||
if (permissionSet.has(key)) {
|
||||
level = index + 1;
|
||||
}
|
||||
});
|
||||
|
||||
acc[category] = {
|
||||
level,
|
||||
capabilities: permissionMatrix[category].capabilities.filter(
|
||||
(capability) => permissionSet.has(capability) || isCapabilityImpliedByLevel(category, capability, level),
|
||||
),
|
||||
};
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
};
|
||||
|
||||
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
const { level, capabilities } = state[category];
|
||||
acc.push(...permissionMatrix[category].levels.slice(0, level));
|
||||
// Skip capabilities the chosen level already implies, so the stored permissions stay minimal
|
||||
// and a state -> permissions -> state round-trip is exact.
|
||||
acc.push(...capabilities.filter((capability) => !isCapabilityImpliedByLevel(category, capability, level)));
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
};
|
||||
|
||||
export interface ManagementSectionAccess {
|
||||
/** May see and manage every resource of the section */
|
||||
canManageAll: boolean;
|
||||
/** May add new resources to the section */
|
||||
canCreate: boolean;
|
||||
/** May open the section at all */
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A management section is reachable when the user can do at least one thing in it. Both rules live
|
||||
* here so the navigation, the pages themselves and the management home statistics cannot drift
|
||||
* apart - each of them consumes the same function.
|
||||
*/
|
||||
export const getAppManagementAccess = (permissions: readonly GroupPermissionKey[]): ManagementSectionAccess => {
|
||||
const canManageAll = permissions.includes("app-modify-all");
|
||||
const canCreate = permissions.includes("app-create");
|
||||
|
||||
return { canManageAll, canCreate, canAccess: canManageAll || canCreate };
|
||||
};
|
||||
|
||||
/**
|
||||
* `hasDelegatedFullAccess` is whether the user was granted full access to at least one specific
|
||||
* integration; it has to be looked up per request, so the caller resolves it.
|
||||
*/
|
||||
export const getIntegrationManagementAccess = (
|
||||
permissions: readonly GroupPermissionKey[],
|
||||
hasDelegatedFullAccess: boolean,
|
||||
): ManagementSectionAccess & { hasDelegatedFullAccess: boolean } => {
|
||||
const canManageAll = permissions.includes("integration-full-all");
|
||||
const canCreate = permissions.includes("integration-create");
|
||||
|
||||
return {
|
||||
canManageAll,
|
||||
canCreate,
|
||||
hasDelegatedFullAccess,
|
||||
canAccess: canManageAll || canCreate || hasDelegatedFullAccess,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GroupPermissionKey } from "../permissions";
|
||||
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions";
|
||||
import type { GroupPermissionKey, PermissionMatrixState } from "../permissions";
|
||||
import {
|
||||
getAppManagementAccess,
|
||||
getIntegrationManagementAccess,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "../permissions";
|
||||
|
||||
const emptyState = (overrides: Partial<PermissionMatrixState> = {}): PermissionMatrixState => ({
|
||||
board: { level: 0, capabilities: [] },
|
||||
app: { level: 0, capabilities: [] },
|
||||
integration: { level: 0, capabilities: [] },
|
||||
"search-engine": { level: 0, capabilities: [] },
|
||||
media: { level: 0, capabilities: [] },
|
||||
other: { level: 0, capabilities: [] },
|
||||
admin: { level: 0, capabilities: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getPermissionsWithParents should return the correct permissions", () => {
|
||||
test.each([
|
||||
@@ -45,3 +66,204 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
||||
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matrix must be able to represent every permission", () => {
|
||||
test("every group permission key is covered by exactly one category", () => {
|
||||
const covered = Object.values(permissionMatrix).flatMap(({ levels, capabilities }) => [...levels, ...capabilities]);
|
||||
expect(covered.toSorted()).toEqual(groupPermissionKeys.toSorted());
|
||||
});
|
||||
|
||||
test.each(groupPermissionKeys.map((key) => [key]))("expect %s to survive a round-trip on its own", (key) => {
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState([key]))).toContain(key);
|
||||
});
|
||||
|
||||
test("every ladder is a real chain, so no level can fabricate a sibling permission", () => {
|
||||
Object.values(permissionMatrix).forEach(({ levels }) => {
|
||||
levels.forEach((key, index) => {
|
||||
// Selecting a level must never grant more than that level's key already implies.
|
||||
const implied = getPermissionsWithChildren([key]);
|
||||
expect(levels.slice(0, index + 1).every((lower) => implied.includes(lower))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionsToMatrixState should resolve the highest present level per category", () => {
|
||||
test("resolves the highest present level key", () => {
|
||||
expect(permissionsToMatrixState(["board-view-all", "board-modify-all"]).board.level).toBe(2);
|
||||
});
|
||||
|
||||
test("resolves the admin category", () => {
|
||||
expect(permissionsToMatrixState(["admin"]).admin.level).toBe(1);
|
||||
});
|
||||
|
||||
test("resolves an empty selection to all zero", () => {
|
||||
const state = permissionsToMatrixState([]);
|
||||
expect(Object.values(state).every(({ level, capabilities }) => level === 0 && capabilities.length === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a standalone create key without granting any level", () => {
|
||||
expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, capabilities: ["board-create"] });
|
||||
});
|
||||
|
||||
test("marks a capability as granted when the level implies it", () => {
|
||||
expect(permissionsToMatrixState(["board-full-all"]).board.capabilities).toEqual(["board-create"]);
|
||||
expect(permissionsToMatrixState(["app-modify-all"]).app.capabilities).toEqual(["app-create"]);
|
||||
expect(permissionsToMatrixState(["media-full-all"]).media.capabilities).toEqual(["media-upload"]);
|
||||
});
|
||||
|
||||
test("does not mark a capability as granted for a level that does not imply it", () => {
|
||||
expect(permissionsToMatrixState(["board-modify-all"]).board.capabilities).toEqual([]);
|
||||
expect(permissionsToMatrixState(["integration-interact-all"]).integration.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
test("app-use-all is a capability, not a level, because app-modify-all does not imply it", () => {
|
||||
// Regression: modelling use as the level below modify made picking Modify silently grant the
|
||||
// use of every app, which the permission graph does not do.
|
||||
const state = permissionsToMatrixState(["app-modify-all"]);
|
||||
expect(state.app).toEqual({ level: 1, capabilities: ["app-create"] });
|
||||
expect(matrixStateToPermissions(state)).toEqual(["app-modify-all"]);
|
||||
});
|
||||
|
||||
test("app-full-all implies both app capabilities", () => {
|
||||
expect(permissionsToMatrixState(["app-full-all"]).app).toEqual({
|
||||
level: 2,
|
||||
capabilities: ["app-use-all", "app-create"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCapabilityImpliedByLevel", () => {
|
||||
test.each([
|
||||
["board", "board-create", 0, false],
|
||||
["board", "board-create", 1, false],
|
||||
["board", "board-create", 2, false],
|
||||
["board", "board-create", 3, true],
|
||||
["app", "app-create", 1, true],
|
||||
["app", "app-use-all", 1, false],
|
||||
["app", "app-use-all", 2, true],
|
||||
["integration", "integration-create", 2, false],
|
||||
["integration", "integration-create", 3, true],
|
||||
["search-engine", "search-engine-create", 1, true],
|
||||
["media", "media-upload", 1, false],
|
||||
["media", "media-upload", 2, true],
|
||||
] satisfies [keyof typeof permissionMatrix, GroupPermissionKey, number, boolean][])(
|
||||
"expect %s at level %i to imply %s: %s",
|
||||
(category, capability, level, expected) => {
|
||||
expect(isCapabilityImpliedByLevel(category, capability, level)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("matrixStateToPermissions should expand levels into permission keys", () => {
|
||||
test("expands a level into the ordered level keys", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 2, capabilities: [] } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expands the admin category into the admin key", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ admin: { level: 1, capabilities: [] } }))).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
test("emits a standalone capability without any level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 0, capabilities: ["board-create"] } }))).toEqual([
|
||||
"board-create",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits media-upload as the capability of the media category", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ media: { level: 0, capabilities: ["media-upload"] } }))).toEqual([
|
||||
"media-upload",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits app-use-all without granting any app level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ app: { level: 0, capabilities: ["app-use-all"] } }))).toEqual([
|
||||
"app-use-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits a capability that the chosen level already implies", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 3, capabilities: ["board-create"] } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix helpers should round-trip stably", () => {
|
||||
test.each([
|
||||
[["board-view-all", "board-modify-all"]],
|
||||
[["integration-use-all", "integration-interact-all", "integration-full-all"]],
|
||||
[["admin"]],
|
||||
[[]],
|
||||
[["search-engine-modify-all", "search-engine-full-all", "media-view-all", "other-view-logs"]],
|
||||
[["board-create"]],
|
||||
[["media-upload"]],
|
||||
[["board-create", "app-create", "integration-create", "search-engine-create", "media-upload"]],
|
||||
[["board-create", "board-view-all"]],
|
||||
[["app-modify-all"]],
|
||||
[["app-use-all"]],
|
||||
[["app-use-all", "app-modify-all"]],
|
||||
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
|
||||
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
|
||||
expect(roundTripped).toHaveLength(permissions.length);
|
||||
});
|
||||
|
||||
test("expect a redundant capability key to be normalised away by the level that implies it", () => {
|
||||
// app-modify-all already grants app-create, so the matrix stores the minimal set. The permission
|
||||
// is not lost: getPermissionsWithChildren re-derives it whenever permissions are read.
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(["app-create", "app-modify-all"]));
|
||||
expect(roundTripped).toEqual(["app-modify-all"]);
|
||||
expect(getPermissionsWithChildren(roundTripped)).toContain("app-create");
|
||||
});
|
||||
|
||||
test("expect a single higher-level key to expand to its full set on round-trip", () => {
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("management section access", () => {
|
||||
test.each([
|
||||
[[], false, false],
|
||||
[["app-create"], true, false],
|
||||
[["app-modify-all"], true, true],
|
||||
[["app-full-all", "app-modify-all"], true, true],
|
||||
[["app-use-all"], false, false],
|
||||
[["board-modify-all"], false, false],
|
||||
] satisfies [GroupPermissionKey[], boolean, boolean][])(
|
||||
"expect apps with %s to be reachable: %s, manage-all: %s",
|
||||
(permissions, canAccess, canManageAll) => {
|
||||
const access = getAppManagementAccess(permissions);
|
||||
expect(access.canAccess).toBe(canAccess);
|
||||
expect(access.canManageAll).toBe(canManageAll);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[[], false, false, false],
|
||||
[[], true, true, false],
|
||||
[["integration-create"], false, true, false],
|
||||
[["integration-full-all"], false, true, true],
|
||||
[["integration-use-all"], false, false, false],
|
||||
[["integration-interact-all"], false, false, false],
|
||||
[["integration-interact-all"], true, true, false],
|
||||
] satisfies [GroupPermissionKey[], boolean, boolean, boolean][])(
|
||||
"expect integrations with %s and delegated access %s to be reachable: %s, manage-all: %s",
|
||||
(permissions, hasDelegatedFullAccess, canAccess, canManageAll) => {
|
||||
const access = getIntegrationManagementAccess(permissions, hasDelegatedFullAccess);
|
||||
expect(access.canAccess).toBe(canAccess);
|
||||
expect(access.canManageAll).toBe(canManageAll);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,9 @@ const sessionSchema = z.object({
|
||||
SeriesName: z.string().nullish(),
|
||||
Name: z.string().nullish(),
|
||||
SeasonName: z.string().nullish(),
|
||||
ParentIndexNumber: z.number().nullish(),
|
||||
EpisodeTitle: z.string().nullish(),
|
||||
IndexNumber: z.number().nullish(),
|
||||
Album: z.string().nullish(),
|
||||
EpisodeCount: z.number().nullish(),
|
||||
})
|
||||
@@ -114,7 +116,9 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface StreamSession {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
name: string;
|
||||
seasonName: string | undefined;
|
||||
seasonNumber?: number | null;
|
||||
episodeName?: string | null;
|
||||
episodeNumber?: number | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
playback: {
|
||||
|
||||
@@ -64,7 +64,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null,
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
playback: {
|
||||
|
||||
@@ -26,7 +26,9 @@ const sessions: StreamSession[] = [
|
||||
type: "video",
|
||||
name: "Breaking Bad",
|
||||
seasonName: "Season 5",
|
||||
seasonNumber: 5,
|
||||
episodeName: "Ozymandias",
|
||||
episodeNumber: 14,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
@@ -58,7 +60,9 @@ const sessions: StreamSession[] = [
|
||||
type: "video",
|
||||
name: "Severance",
|
||||
seasonName: "Season 2",
|
||||
seasonNumber: 2,
|
||||
episodeName: "The We We Are",
|
||||
episodeNumber: 10,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
playback: { state: null, positionMs: null, durationMs: null },
|
||||
|
||||
@@ -80,8 +80,9 @@ interface Session {
|
||||
$: {
|
||||
grandparentTitle?: string;
|
||||
parentTitle?: string;
|
||||
parentIndex?: string;
|
||||
title?: string;
|
||||
index?: number;
|
||||
index?: string;
|
||||
type: string;
|
||||
live?: string;
|
||||
viewOffset?: string;
|
||||
|
||||
@@ -115,9 +115,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
|
||||
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
|
||||
seasonName: mediaElement.$.parentTitle,
|
||||
seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex),
|
||||
episodeName: mediaElement.$.title ?? null,
|
||||
episodeNumber: parseOptionalNumber(mediaElement.$.index),
|
||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||
episodeCount: mediaElement.$.index ?? null,
|
||||
episodeCount: parseOptionalNumber(mediaElement.$.index),
|
||||
playback: {
|
||||
state: playbackState,
|
||||
positionMs,
|
||||
|
||||
@@ -17,10 +17,11 @@ export const appIntegrationBoardMode = {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.concat(
|
||||
session.user.permissions.includes("board-modify-all")
|
||||
? [appsSearchGroup, integrationsSearchGroup]
|
||||
: [integrationsSearchGroup],
|
||||
);
|
||||
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
|
||||
// administrator without board access still gets the group instead of a FORBIDDEN response.
|
||||
const canSearchApps =
|
||||
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
|
||||
|
||||
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]);
|
||||
},
|
||||
} satisfies SearchMode;
|
||||
@@ -653,6 +653,35 @@
|
||||
"description": "Allow members to manage and delete any search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"matrix": {
|
||||
"none": "No access",
|
||||
"impliedHint": "Also grants: {permissions}",
|
||||
"grantedViaAdmin": "Administrators already have this. Set Admin to “No access” to configure it separately.",
|
||||
"preset": {
|
||||
"label": "Presets",
|
||||
"viewer": "Viewer",
|
||||
"viewerDescription": "Read-only: view boards and medias, use apps and integrations that already exist.",
|
||||
"editor": "Editor",
|
||||
"editorDescription": "Day-to-day management: create and edit boards, apps, integrations and search engines.",
|
||||
"admin": "Admin",
|
||||
"adminDescription": "Unrestricted access to every feature and setting, including user management.",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"effective": {
|
||||
"title": "Effective permissions",
|
||||
"description": "The full, de-duplicated set of actions members of this group can perform, including permissions granted indirectly.",
|
||||
"empty": "No permissions selected"
|
||||
},
|
||||
"noneDescription": "Members get no access to this area.",
|
||||
"level": {
|
||||
"view": "View",
|
||||
"use": "Use",
|
||||
"interact": "Interact",
|
||||
"modify": "Modify",
|
||||
"full": "Full"
|
||||
},
|
||||
"capabilityIncluded": "Already included in “{level}”"
|
||||
}
|
||||
},
|
||||
"memberNotice": {
|
||||
@@ -779,7 +808,9 @@
|
||||
"action": "Create your first app",
|
||||
"filteredTitle": "No apps match your search",
|
||||
"filteredDescription": "No apps match \"{search}\". Try another name or URL.",
|
||||
"clearSearch": "Clear search"
|
||||
"clearSearch": "Clear search",
|
||||
"createOnlyTitle": "You can only add apps",
|
||||
"createOnlyDescription": "Your permissions let you create apps but not browse existing ones. Ask an administrator for app management access to see the full list."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -901,7 +932,7 @@
|
||||
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
|
||||
"migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.",
|
||||
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
|
||||
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
|
||||
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Select this badge to configure the URL and encrypted values."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Custom Widget",
|
||||
@@ -934,6 +965,7 @@
|
||||
"downloadSkill": "View Custom Widget Skill",
|
||||
"publishWorkshop": "Publish to Workshop",
|
||||
"migrate": "Migrate Custom Widget",
|
||||
"configureSources": "Configure sources",
|
||||
"copyLegacyDefinition": "Copy original widget content",
|
||||
"exportLegacyDefinition": "Export original widget content",
|
||||
"copyMigrationPrompt": "Copy LLM migration prompt",
|
||||
@@ -947,6 +979,13 @@
|
||||
"removeItem": "Remove item",
|
||||
"addBar": "Add Bar"
|
||||
},
|
||||
"sourceSetupDialog": {
|
||||
"title": "Configure widget sources",
|
||||
"description": "Review where this widget connects and add or replace its encrypted credentials.",
|
||||
"credentialsRequired": "Requests will fail until the required credentials are added.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save configuration"
|
||||
},
|
||||
"field": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
@@ -1298,6 +1337,8 @@
|
||||
"legacyDefinitionCopyError": "Failed to copy the original widget content.",
|
||||
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
|
||||
"migrationPromptCopyError": "Failed to copy the migration prompt.",
|
||||
"sourcesConfigured": "Widget sources and credentials saved.",
|
||||
"sourceConfigurationError": "Failed to configure widget sources.",
|
||||
"aiPromptCopied": "AI prompt copied to clipboard",
|
||||
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
|
||||
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
|
||||
@@ -1571,6 +1612,7 @@
|
||||
"title": "Integrations",
|
||||
"ariaLabel": "Configured integrations",
|
||||
"search": "Search integrations",
|
||||
"scopedNote": "Showing only the integrations you are allowed to manage.",
|
||||
"action": {
|
||||
"edit": "Edit {name}",
|
||||
"delete": "Delete {name}"
|
||||
@@ -1581,7 +1623,9 @@
|
||||
"action": "Create your first integration",
|
||||
"filteredTitle": "No integrations match your search",
|
||||
"filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.",
|
||||
"clearSearch": "Clear search"
|
||||
"clearSearch": "Clear search",
|
||||
"createOnlyTitle": "You can only add integrations",
|
||||
"createOnlyDescription": "Your permissions let you create integrations but not manage existing ones. Ask an administrator for access to the ones you need."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -4120,6 +4164,7 @@
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
"seasonEpisode": "S{season} E{episode}",
|
||||
"metadata": {
|
||||
"title": "Stats for nerds",
|
||||
"video": {
|
||||
@@ -6123,7 +6168,8 @@
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Not Found",
|
||||
"text": "Could not find requested resource"
|
||||
"text": "Could not find requested resource",
|
||||
"backToHome": "Back to management home"
|
||||
},
|
||||
"error": {
|
||||
"title": "This page couldn't load",
|
||||
|
||||
@@ -314,7 +314,7 @@ export function JsonPathTreePicker({
|
||||
{hasData && (
|
||||
<Combobox.Header>
|
||||
<Group gap="xs" px="xs" pt="xs" pb={4}>
|
||||
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
|
||||
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />}>
|
||||
{loadedHint}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -353,7 +353,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{node.hasChildren && (
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
@@ -362,7 +362,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<TypeIcon size="var(--mantine-font-size-sm)" style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={selected ? 600 : 400}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{issues[0] && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="var(--mantine-font-size-md)" />}>
|
||||
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
|
||||
<IconApi size={18} />
|
||||
<IconApi size="var(--mantine-font-size-lg)" />
|
||||
</Avatar>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
@@ -152,7 +152,7 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
icon={<IconAlertTriangle size="var(--mantine-font-size-lg)" />}
|
||||
title={labels("migrationRequired")}
|
||||
>
|
||||
<Text size="xs">{labels("migrationDescription")}</Text>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const WidgetDynamicSelectInput = ({
|
||||
style={{ marginInlineStart: "auto" }}
|
||||
color="currentColor"
|
||||
opacity={0.6}
|
||||
size={18}
|
||||
size="var(--mantine-font-size-lg)"
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp
|
||||
disabled={!selectionEnabled}
|
||||
onClick={onSearch}
|
||||
variant="light"
|
||||
leftSection={<IconListSearch size={16} />}
|
||||
leftSection={<IconListSearch size="var(--mantine-font-size-md)" />}
|
||||
>
|
||||
{tLocation("search")}
|
||||
</Button>
|
||||
@@ -257,7 +257,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
})}
|
||||
>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
|
||||
<IconClick size={16} />
|
||||
<IconClick size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
|
||||
@@ -229,19 +229,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
})
|
||||
}
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={15} />}
|
||||
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />}
|
||||
size="xs"
|
||||
>
|
||||
{tRepository("edit.label")}
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
|
||||
<IconTrash size={15} />
|
||||
<IconTrash size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
|
||||
<Group align="center" justify="center" gap="xs" bg="red.1">
|
||||
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
|
||||
<IconTriangleFilled size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-filled)" />
|
||||
<Text size="sm" c="red">
|
||||
{tRepository("invalid")}
|
||||
</Text>
|
||||
@@ -371,7 +371,7 @@ const ProviderTokenInput = ({
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
leftSection={<IconKey size={14} />}
|
||||
leftSection={<IconKey size="var(--mantine-font-size-sm)" />}
|
||||
/>
|
||||
{editing && value.trim() && (
|
||||
<Button size="xs" onClick={handleSave} loading={saving}>
|
||||
@@ -380,7 +380,7 @@ const ProviderTokenInput = ({
|
||||
)}
|
||||
{hasToken && !editing && (
|
||||
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
|
||||
<IconTrash size={14} />
|
||||
<IconTrash size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -51,20 +51,20 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
|
||||
label: translateIfNecessary(t, option.label) ?? option.value,
|
||||
},
|
||||
)}
|
||||
leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
|
||||
leftSection={CurrentIcon && <CurrentIcon size="var(--mantine-font-size-md)" stroke={1.5} />}
|
||||
renderOption={({ option, checked }) => {
|
||||
const Icon = getIconFor(options.options, String(option.value));
|
||||
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
{Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
|
||||
{Icon && <Icon color="currentColor" opacity={0.6} size="var(--mantine-font-size-lg)" stroke={1.5} />}
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck
|
||||
style={{ marginInlineStart: "auto" }}
|
||||
color="currentColor"
|
||||
opacity={0.6}
|
||||
size={18}
|
||||
size="var(--mantine-font-size-lg)"
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -263,7 +263,7 @@ const AnchorNoteWidgetContent = ({
|
||||
onClick={handleEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
<IconEdit size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap="xs" ta="center">
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
|
||||
<IconRobot size={20} />
|
||||
<IconRobot size="var(--mantine-font-size-xl)" />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title")}
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size={14} />}
|
||||
leftSection={<IconServer size="var(--mantine-font-size-sm)" />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
disabled={isSelectionSavePending}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
</Group>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
||||
<IconX size={20} />
|
||||
<IconX size="var(--mantine-font-size-xl)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -303,7 +303,7 @@ const CalendarAgenda = ({
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, -1))}
|
||||
>
|
||||
<IconChevronLeft size={18} aria-hidden />
|
||||
<IconChevronLeft size="var(--mantine-font-size-lg)" aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Stack gap={0} miw={0}>
|
||||
@@ -323,7 +323,7 @@ const CalendarAgenda = ({
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, 1))}
|
||||
>
|
||||
<IconChevronRight size={18} aria-hidden />
|
||||
<IconChevronRight size="var(--mantine-font-size-lg)" aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicator
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}>
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndi
|
||||
tabIndex={0}
|
||||
style={{ cursor: "help" }}
|
||||
>
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati
|
||||
|
||||
return (
|
||||
<Accordion.Item value="applications">
|
||||
<Accordion.Control icon={isTiny ? null : <IconCloud size={16} />}>
|
||||
<Accordion.Control icon={isTiny ? null : <IconCloud size="var(--mantine-font-size-md)" />}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("applications")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(runningApps, applications.length)} size="xs">
|
||||
|
||||
@@ -106,7 +106,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconLink size={12} />
|
||||
<IconLink size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isTiny && logsUrl && (
|
||||
@@ -121,7 +121,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconFileText size={12} />
|
||||
<IconFileText size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isTiny && (
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ServersSection({
|
||||
return (
|
||||
<Accordion.Item value="servers">
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Accordion.Control icon={isTiny ? null : <IconServer size="var(--mantine-font-size-md)" />} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("servers")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
|
||||
@@ -61,7 +61,7 @@ export function ServersSection({
|
||||
mr="xs"
|
||||
onClick={onToggleIp}
|
||||
>
|
||||
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
|
||||
{showIp ? <IconEye size="var(--mantine-font-size-xs)" /> : <IconEyeOff size="var(--mantine-font-size-xs)" />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Accordion.Panel p={4}>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
|
||||
|
||||
return (
|
||||
<Accordion.Item value="services">
|
||||
<Accordion.Control icon={isTiny ? null : <IconStack2 size={16} />}>
|
||||
<Accordion.Control icon={isTiny ? null : <IconStack2 size="var(--mantine-font-size-md)" />}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("services")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(runningServices, services.length)} size="xs">
|
||||
|
||||
@@ -90,7 +90,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconClockPause size={20} />}
|
||||
leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />}
|
||||
h="2rem"
|
||||
w="12rem"
|
||||
onClick={handleSetTimer}
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function DnsHoleControlsWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size={16} />
|
||||
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size="var(--mantine-font-size-md)" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function DnsHoleControlsWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<IconClockPause className="dns-hole-controls-timer-all-icon" size={16} />
|
||||
<IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function DnsHoleControlsWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size={16} />
|
||||
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size="var(--mantine-font-size-md)" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
@@ -378,7 +378,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
color="green"
|
||||
variant="light"
|
||||
>
|
||||
<IconPlayerPlay size={12} />
|
||||
<IconPlayerPlay size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
@@ -390,7 +390,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<IconPlayerStop size={12} />
|
||||
<IconPlayerStop size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
@@ -406,7 +406,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
>
|
||||
<IconClockPause size={12} />
|
||||
<IconClockPause size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
@@ -436,7 +436,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
<IconCircleFilled
|
||||
className="dns-hole-controls-item-status-icon"
|
||||
color={dnsLightStatus(isEnabled)}
|
||||
size={16}
|
||||
size="var(--mantine-font-size-md)"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -474,7 +474,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconClockPause className="dns-hole-controls-item-timer-icon" size={20} />
|
||||
<IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -430,7 +430,7 @@ export default function DockerWidget({
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
|
||||
<IconBrandDocker size="var(--mantine-font-size-xl)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" truncate>
|
||||
{t("table.footer", { count: containers.length.toString() })}
|
||||
</Text>
|
||||
@@ -457,7 +457,7 @@ export default function DockerWidget({
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
|
||||
>
|
||||
<IconRefresh size={16} />
|
||||
<IconRefresh size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -491,7 +491,7 @@ function ContainerMenuButton({
|
||||
aria-label={t("title")}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
<IconDots size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
|
||||
@@ -525,7 +525,7 @@ function ContainerActionItems({
|
||||
{container.name}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size={14} />}
|
||||
leftSection={<IconFileText size="var(--mantine-font-size-sm)" />}
|
||||
disabled={!handlers.canUse(container, "logs")}
|
||||
onClick={() => {
|
||||
handlers.onOpenLogs(container);
|
||||
@@ -537,7 +537,7 @@ function ContainerActionItems({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={stateAction === "start" ? "green" : "red"}
|
||||
leftSection={<StateIcon size={14} />}
|
||||
leftSection={<StateIcon size="var(--mantine-font-size-sm)" />}
|
||||
disabled={!handlers.canUse(container, "lifecycle")}
|
||||
onClick={() => invokeAction(stateAction)}
|
||||
>
|
||||
@@ -545,7 +545,7 @@ function ContainerActionItems({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="orange"
|
||||
leftSection={<IconRotateClockwise size={14} />}
|
||||
leftSection={<IconRotateClockwise size="var(--mantine-font-size-sm)" />}
|
||||
disabled={!handlers.canUse(container, "lifecycle")}
|
||||
onClick={() => invokeAction("restart")}
|
||||
>
|
||||
@@ -553,7 +553,7 @@ function ContainerActionItems({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />}
|
||||
disabled={!handlers.canUse(container, "remove")}
|
||||
onClick={() => invokeAction("remove")}
|
||||
>
|
||||
@@ -561,7 +561,7 @@ function ContainerActionItems({
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconCategoryPlus size={14} />}
|
||||
leftSection={<IconCategoryPlus size="var(--mantine-font-size-sm)" />}
|
||||
onClick={() => {
|
||||
handlers.onAddToHomarr(container);
|
||||
onClose();
|
||||
|
||||
@@ -279,7 +279,7 @@ function SpeedCell({
|
||||
const { Icon, color } = speedColumnConfig[direction];
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Icon size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Text size={fontSize} c={color}>
|
||||
{formatByteRate(speed)}
|
||||
</Text>
|
||||
@@ -914,7 +914,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<DetailPair
|
||||
label={t("items.downSpeed.detailsTitle")}
|
||||
value={formatByteRate(item.downSpeed)}
|
||||
icon={<IconDownload size={10} />}
|
||||
icon={<IconDownload size="var(--mantine-font-size-xs)" />}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
@@ -922,7 +922,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<DetailPair
|
||||
label={t("items.upSpeed.detailsTitle")}
|
||||
value={formatByteRate(item.upSpeed)}
|
||||
icon={<IconUpload size={10} />}
|
||||
icon={<IconUpload size="var(--mantine-font-size-xs)" />}
|
||||
color="green"
|
||||
/>
|
||||
)}
|
||||
@@ -1005,7 +1005,7 @@ function GlobalStatsBar({
|
||||
|
||||
<Tooltip label={t("stats.totalSize")} withArrow>
|
||||
<Group gap={4}>
|
||||
<IconDatabase size={12} style={{ opacity: 0.6 }} />
|
||||
<IconDatabase size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} />
|
||||
<Text size="xs">{`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
@@ -1013,14 +1013,14 @@ function GlobalStatsBar({
|
||||
|
||||
<Group gap="md">
|
||||
<Group gap={4}>
|
||||
<IconDownload size={12} style={{ opacity: 0.6 }} />
|
||||
<IconDownload size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} />
|
||||
<Text size="xs" fw={600} c="blue">
|
||||
{formatByteRate(totalSpeed)}
|
||||
</Text>
|
||||
</Group>
|
||||
{totalUpSpeed > 0 && (
|
||||
<Group gap={4}>
|
||||
<IconUpload size={12} style={{ opacity: 0.6 }} />
|
||||
<IconUpload size="var(--mantine-font-size-xs)" style={{ opacity: 0.6 }} />
|
||||
<Text size="xs" fw={600} c="green">
|
||||
{formatByteRate(totalUpSpeed)}
|
||||
</Text>
|
||||
@@ -1093,7 +1093,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
<Menu.Label>{truncateText(item.name, 40)}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconInfoCircle size={14} />}
|
||||
leftSection={<IconInfoCircle size="var(--mantine-font-size-sm)" />}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(item.name).catch(() => {});
|
||||
onClose();
|
||||
@@ -1104,7 +1104,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
|
||||
{item.id && (
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={14} />}
|
||||
leftSection={<IconCopy size="var(--mantine-font-size-sm)" />}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(item.id).catch(() => {});
|
||||
onClose();
|
||||
@@ -1118,7 +1118,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<PauseResumeIcon size={14} />}
|
||||
leftSection={<PauseResumeIcon size="var(--mantine-font-size-sm)" />}
|
||||
onClick={() => {
|
||||
pauseResumeInvoke?.();
|
||||
onClose();
|
||||
@@ -1130,7 +1130,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
{!showDeleteConfirm && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDelete();
|
||||
@@ -1144,7 +1144,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
<>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
leftSection={<IconTrash size="var(--mantine-font-size-sm)" />}
|
||||
onClick={() => {
|
||||
item.actions?.delete({ fromDisk: false });
|
||||
onClose();
|
||||
@@ -1154,7 +1154,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrashX size={14} />}
|
||||
leftSection={<IconTrashX size="var(--mantine-font-size-sm)" />}
|
||||
onClick={() => {
|
||||
item.actions?.delete({ fromDisk: true });
|
||||
onClose();
|
||||
@@ -1321,14 +1321,14 @@ function WidgetFooter({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FilterIcon size={14} />
|
||||
<FilterIcon size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{toggleStats ? (
|
||||
<Tooltip label={statsTooltip}>
|
||||
<ActionIcon size="xs" variant={statsIconVariant} aria-label={statsTooltip} onClick={toggleStats}>
|
||||
<StatsIcon size={14} />
|
||||
<StatsIcon size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -1356,7 +1356,7 @@ function WidgetFooter({
|
||||
disabled={integrationsStatuses.paused.length === 0}
|
||||
onClick={() => resumeQueue({ integrationIds: integrationsStatuses.paused })}
|
||||
>
|
||||
<IconPlayerPlay size={14} />
|
||||
<IconPlayerPlay size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -1364,12 +1364,12 @@ function WidgetFooter({
|
||||
{!showStats && (
|
||||
<Group gap={2}>
|
||||
<Text size="xs" fw={600} c="blue">
|
||||
<IconDownload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
<IconDownload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
{formatByteRate(totalSpeed)}
|
||||
</Text>
|
||||
{totalUpSpeed > 0 && (
|
||||
<Text size="xs" fw={600} c="green">
|
||||
<IconUpload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
<IconUpload size="var(--mantine-font-size-xs)" style={{ verticalAlign: "middle", marginRight: 2 }} />
|
||||
{formatByteRate(totalUpSpeed)}
|
||||
</Text>
|
||||
)}
|
||||
@@ -1386,7 +1386,7 @@ function WidgetFooter({
|
||||
disabled={integrationsStatuses.active.length === 0}
|
||||
onClick={() => pauseQueue({ integrationIds: integrationsStatuses.active })}
|
||||
>
|
||||
<IconPlayerPause size={14} />
|
||||
<IconPlayerPause size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -386,7 +386,7 @@ const InterfacesPanel = ({
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={<IconTopologyBus size={16} />}>
|
||||
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size="xs">{label}</Text>
|
||||
{hasError && (
|
||||
@@ -410,7 +410,7 @@ const InterfacesPanel = ({
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarUp
|
||||
size={12}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
|
||||
@@ -419,7 +419,7 @@ const InterfacesPanel = ({
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarDown
|
||||
size={12}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ResourceAccordionItem = ({
|
||||
}: PropsWithChildren<ResourceAccordionItemProps>) => {
|
||||
return (
|
||||
<Accordion.Item value={value}>
|
||||
<Accordion.Control icon={isTiny ? null : <Icon size={16} />}>
|
||||
<Accordion.Control icon={isTiny ? null : <Icon size="var(--mantine-font-size-md)" />}>
|
||||
<Group style={{ rowGap: "0" }} gap="xs">
|
||||
<Text size="xs">{title}</Text>
|
||||
<Badge variant="dot" color={badge.color} size="xs">
|
||||
|
||||
@@ -97,20 +97,20 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||
const storage = formatBytesPair(item.storage.used, item.storage.total);
|
||||
return (
|
||||
<List>
|
||||
<List.Item icon={<IconCpu size={16} />}>
|
||||
<List.Item icon={<IconCpu size="var(--mantine-font-size-md)" />}>
|
||||
{t("cpu")} - {item.cpu.cores}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconBrain size={16} />}>
|
||||
<List.Item icon={<IconBrain size="var(--mantine-font-size-md)" />}>
|
||||
{t("memory")} - {memory.used} / {memory.available}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
<List.Item icon={<IconDatabase size="var(--mantine-font-size-md)" />}>
|
||||
{t("storage")} - {storage.used} / {storage.available}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconClockHour3 size={16} />}>
|
||||
<List.Item icon={<IconClockHour3 size="var(--mantine-font-size-md)" />}>
|
||||
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
||||
</List.Item>
|
||||
{item.haState && (
|
||||
<List.Item icon={<IconHeartBolt size={16} />}>
|
||||
<List.Item icon={<IconHeartBolt size="var(--mantine-font-size-md)" />}>
|
||||
{t("haState")} - {capitalize(item.haState)}
|
||||
</List.Item>
|
||||
)}
|
||||
@@ -152,15 +152,15 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
<List.Item icon={<IconDatabase size="var(--mantine-font-size-md)" />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.storage.write)}</Text>
|
||||
<IconArrowNarrowDown size={14} />
|
||||
<IconArrowNarrowDown size="var(--mantine-font-size-sm)" />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.storage.read)}</Text>
|
||||
<IconArrowNarrowUp size={14} />
|
||||
<IconArrowNarrowUp size="var(--mantine-font-size-sm)" />
|
||||
</Group>
|
||||
</Group>
|
||||
</List.Item>
|
||||
@@ -172,15 +172,15 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List.Item icon={<IconNetwork size={16} />}>
|
||||
<List.Item icon={<IconNetwork size="var(--mantine-font-size-md)" />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.network.in)}</Text>
|
||||
<IconArrowNarrowDown size={14} />
|
||||
<IconArrowNarrowDown size="var(--mantine-font-size-sm)" />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{formatBytes(item.network.out)}</Text>
|
||||
<IconArrowNarrowUp size={14} />
|
||||
<IconArrowNarrowUp size="var(--mantine-font-size-sm)" />
|
||||
</Group>
|
||||
</Group>
|
||||
</List.Item>
|
||||
|
||||
@@ -178,7 +178,7 @@ function Carousel({
|
||||
size={40}
|
||||
onClick={() => move(-1)}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
<IconChevronLeft size="var(--mantine-font-size-lg)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
@@ -189,7 +189,7 @@ function Carousel({
|
||||
size={advanced ? 40 : 32}
|
||||
onClick={() => setPaused((value) => !value)}
|
||||
>
|
||||
{paused ? <IconPlayerPlay size={18} /> : <IconPlayerPause size={18} />}
|
||||
{paused ? <IconPlayerPlay size="var(--mantine-font-size-lg)" /> : <IconPlayerPause size="var(--mantine-font-size-lg)" />}
|
||||
</ActionIcon>
|
||||
{advanced && (
|
||||
<ActionIcon
|
||||
@@ -200,7 +200,7 @@ function Carousel({
|
||||
size={40}
|
||||
onClick={() => move(1)}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
<IconChevronRight size="var(--mantine-font-size-lg)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
@@ -214,7 +214,7 @@ function Carousel({
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
<IconCalendar size="var(--mantine-font-size-md)" />
|
||||
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString(locale)}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ImmichServerStatsWidget({
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{statVisibility.showUsers && (
|
||||
<StatItem
|
||||
icon={<IconUsers size={20} />}
|
||||
icon={<IconUsers size="var(--mantine-font-size-xl)" />}
|
||||
label={t("widget.immich-serverStats.users")}
|
||||
value={stats.userCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
@@ -67,7 +67,7 @@ export default function ImmichServerStatsWidget({
|
||||
)}
|
||||
{statVisibility.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size={20} />}
|
||||
icon={<IconPhoto size="var(--mantine-font-size-xl)" />}
|
||||
label={t("widget.immich-serverStats.photos")}
|
||||
value={stats.photoCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
@@ -75,7 +75,7 @@ export default function ImmichServerStatsWidget({
|
||||
)}
|
||||
{statVisibility.showVideos && (
|
||||
<StatItem
|
||||
icon={<IconVideo size={20} />}
|
||||
icon={<IconVideo size="var(--mantine-font-size-xl)" />}
|
||||
label={t("widget.immich-serverStats.videos")}
|
||||
value={stats.videoCount.toLocaleString(locale)}
|
||||
dense={statsLayout.dense}
|
||||
@@ -83,7 +83,7 @@ export default function ImmichServerStatsWidget({
|
||||
)}
|
||||
{statVisibility.showStorage && (
|
||||
<StatItem
|
||||
icon={<IconDatabase size={20} />}
|
||||
icon={<IconDatabase size="var(--mantine-font-size-xl)" />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||
dense={statsLayout.dense}
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function IndexerManagerWidget({
|
||||
}}
|
||||
aria-label={t("widget.indexerManager.testAll")}
|
||||
>
|
||||
<IconTestPipe size={14} />
|
||||
<IconTestPipe size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function MediaMissingWidget({
|
||||
{showMissing && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconQuestionMark size={16} />
|
||||
<IconQuestionMark size="var(--mantine-font-size-md)" />
|
||||
<Text size="sm" fw={600}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Text>
|
||||
@@ -152,7 +152,7 @@ export default function MediaMissingWidget({
|
||||
{showQueued && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconDownload size={16} />
|
||||
<IconDownload size="var(--mantine-font-size-md)" />
|
||||
<Text size="sm" fw={600}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Text>
|
||||
@@ -181,12 +181,12 @@ export default function MediaMissingWidget({
|
||||
</Group>
|
||||
<Tabs.List grow>
|
||||
{showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{showQueued && (
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
|
||||
@@ -273,7 +273,7 @@ const InfoDivider = () => (
|
||||
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
|
||||
{Icon && <Icon size="var(--mantine-font-size-xs)" color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -94,7 +94,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
<IconSearch size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -281,7 +281,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
handleDecision("approve");
|
||||
}}
|
||||
>
|
||||
<IconThumbUp size={16} />
|
||||
<IconThumbUp size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("pending.decline")}>
|
||||
@@ -296,7 +296,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
handleDecision("decline");
|
||||
}}
|
||||
>
|
||||
<IconThumbDown size={16} />
|
||||
<IconThumbDown size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function MediaServerWidget({
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size="var(--mantine-font-size-md)" />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
@@ -226,7 +226,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
<IconSearch size="var(--mantine-font-size-md)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
|
||||
.tableViewport :global(th),
|
||||
.tableViewport :global(td) {
|
||||
padding-block: 4px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 8px;
|
||||
overflow: hidden;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableViewport :global(th) {
|
||||
@@ -41,7 +43,20 @@
|
||||
|
||||
.cellContent {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userIdentity {
|
||||
display: grid;
|
||||
flex: 1 1 0;
|
||||
grid-template-rows: auto auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
row-gap: 1px;
|
||||
}
|
||||
|
||||
.userIdentityLine {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sessionRow {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
|
||||
import { getMediaServerColumnVisibility, SessionDetailsPopover } from "./component";
|
||||
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
@@ -108,3 +108,19 @@ describe("getMediaServerColumnVisibility", () => {
|
||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSeasonEpisodeParams", () => {
|
||||
test("zero-pads season and episode numbers", () => {
|
||||
expect(getSeasonEpisodeParams(4, 12)).toEqual({ season: "04", episode: "12" });
|
||||
});
|
||||
|
||||
test("does not pad numbers already two digits or longer", () => {
|
||||
expect(getSeasonEpisodeParams(12, 104)).toEqual({ season: "12", episode: "104" });
|
||||
});
|
||||
|
||||
test("returns null when either number is missing", () => {
|
||||
expect(getSeasonEpisodeParams(undefined, 12)).toBeNull();
|
||||
expect(getSeasonEpisodeParams(4, null)).toBeNull();
|
||||
expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,16 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
||||
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
||||
}
|
||||
|
||||
export function getSeasonEpisodeParams(
|
||||
seasonNumber: number | null | undefined,
|
||||
episodeNumber: number | null | undefined,
|
||||
): { season: string; episode: string } | null {
|
||||
if (seasonNumber === null || seasonNumber === undefined || episodeNumber === null || episodeNumber === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { season: String(seasonNumber).padStart(2, "0"), episode: String(episodeNumber).padStart(2, "0") };
|
||||
}
|
||||
|
||||
const getSessionSortValue = (session: StreamSession, column: SortColumn) => {
|
||||
if (column === "user") return session.user?.username ?? session.sessionName;
|
||||
if (column === "currentlyPlaying") return session.currentlyPlaying?.name ?? session.sessionName;
|
||||
@@ -124,7 +134,7 @@ function StreamTableHeader({
|
||||
<Text component="span" size="xs" fw={600} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<SortIcon size={13} aria-hidden />
|
||||
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden />
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Text size="xs" fw={600} truncate>
|
||||
@@ -225,7 +235,7 @@ export default function MediaServerWidget({
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={tGlobal("search.placeholder")}
|
||||
aria-label={tGlobal("search.placeholder")}
|
||||
leftSection={<IconSearch size={14} aria-hidden />}
|
||||
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
|
||||
/>
|
||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
@@ -304,13 +314,13 @@ export default function MediaServerWidget({
|
||||
>
|
||||
{columnVisibility.user && (
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" w="100%">
|
||||
<Avatar size={28} src={session.user?.profilePictureUrl} />
|
||||
<Stack gap={0} className={classes.cellContent}>
|
||||
<Stack gap={2} className={classes.cellContent}>
|
||||
<Text size="xs" truncate>
|
||||
{session.user?.username ?? t("items.unknownUser")}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" truncate>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{session.sessionName}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -329,22 +339,27 @@ export default function MediaServerWidget({
|
||||
{columnVisibility.status && (
|
||||
<Table.Td>
|
||||
{currentlyPlaying && (
|
||||
<Stack gap={4} align="flex-start" className={classes.cellContent}>
|
||||
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{(location ?? bitrateLabel) && (
|
||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
|
||||
{location &&
|
||||
(location === "lan" ? (
|
||||
<IconWifi size="var(--mantine-font-size-xs)" />
|
||||
) : (
|
||||
<IconWorld size="var(--mantine-font-size-xs)" />
|
||||
))}
|
||||
{location && (
|
||||
<Text size="10px" c="dimmed" tt="uppercase">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="10px" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{bitrateLabel}
|
||||
</Text>
|
||||
)}
|
||||
@@ -372,7 +387,7 @@ export default function MediaServerWidget({
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconVideo size={16} style={{ flexShrink: 0 }} />
|
||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
@@ -424,14 +439,25 @@ function CurrentlyPlaying({ item }: { item: StreamSession }) {
|
||||
: null;
|
||||
const remainingMinutes =
|
||||
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
|
||||
const seasonEpisodeParams = getSeasonEpisodeParams(currentlyPlaying.seasonNumber, currentlyPlaying.episodeNumber);
|
||||
const seasonEpisodeLabel = seasonEpisodeParams ? t("items.seasonEpisode", seasonEpisodeParams) : null;
|
||||
|
||||
return (
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
|
||||
<Icon
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{currentlyPlaying.name}
|
||||
</Text>
|
||||
{seasonEpisodeLabel && (
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{seasonEpisodeLabel}
|
||||
</Text>
|
||||
)}
|
||||
{isPaused ? (
|
||||
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
|
||||
{t("items.paused")}
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function MediaTranscodingWidget({
|
||||
return {
|
||||
label: (
|
||||
<Center style={{ gap: 4 }}>
|
||||
<Icon size={12} />
|
||||
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
{footerLayout.showTabLabels ? (
|
||||
<Text span size="xs">
|
||||
{t(`tab.${value}`)}
|
||||
@@ -259,7 +259,7 @@ export const resolveQueuePagination = (
|
||||
const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
|
||||
<Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<Icon size={16} />
|
||||
<Icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
<Popover.Target>
|
||||
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={t("title")}>
|
||||
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
|
||||
<IconHeartbeat size={16} />
|
||||
<IconHeartbeat size="var(--mantine-font-size-md)" />
|
||||
</Indicator>
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
@@ -38,7 +38,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
>
|
||||
<Stack gap="sm" align="center">
|
||||
<Group gap="xs">
|
||||
<IconHeartbeat size={18} />
|
||||
<IconHeartbeat size="var(--mantine-font-size-lg)" />
|
||||
<Text size="sm">{t("title")}</Text>
|
||||
</Group>
|
||||
<Divider
|
||||
|
||||
@@ -59,11 +59,11 @@ export function QueuePanel(props: QueuePanelProps) {
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{item.type === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={12} />
|
||||
<IconTransform size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.healthCheck")}>
|
||||
<IconHeartbeat size={12} />
|
||||
<IconHeartbeat size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text lineClamp={1} size="xs">
|
||||
|
||||
@@ -94,7 +94,7 @@ function StatisticItem(props: StatisticItemProps) {
|
||||
<Tooltip label={props.label}>
|
||||
<Card p={0} radius={board.itemRadius} miw={48} flex={1}>
|
||||
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
|
||||
<props.icon size={16} style={{ minWidth: 16 }} />
|
||||
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
|
||||
<Text size="md">{props.value}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
@@ -66,11 +66,11 @@ export function WorkersPanel(props: WorkersPanelProps) {
|
||||
<div>
|
||||
{worker.jobType === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={14} />
|
||||
<IconTransform size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.healthCheck")}>
|
||||
<IconHeartbeat size={14} />
|
||||
<IconHeartbeat size="var(--mantine-font-size-sm)" style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
<Alert icon={<IconInfoCircle size="var(--mantine-font-size-md)" />} color="blue" variant="light">
|
||||
{t("item.edit.app.propagationNotice")}
|
||||
</Alert>
|
||||
<AppForm
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={section.status}
|
||||
label={statusLabels[getBinaryStatusKey(section.status)]}
|
||||
size={16}
|
||||
size="var(--mantine-font-size-md)"
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{getMatrixSectionLabel(section.key, t)}
|
||||
@@ -210,7 +210,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
size={20}
|
||||
size="var(--mantine-font-size-xl)"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -256,7 +256,15 @@ const getMatrixSectionLabel = (key: NetworkControllerMatrixSectionKey, t: Transl
|
||||
return t(`widget.networkControllerSummary.card.${key}`);
|
||||
};
|
||||
|
||||
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
|
||||
const StatusIcon = ({
|
||||
status,
|
||||
label,
|
||||
size,
|
||||
}: {
|
||||
status?: "enabled" | "disabled";
|
||||
label: string;
|
||||
size: number | string;
|
||||
}) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
|
||||
@@ -445,7 +445,7 @@ function CompactStatGrid({
|
||||
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon className={classes.statIcon} size={18} stroke={1.5} color={severityToIconColor(severity)} />
|
||||
<Icon className={classes.statIcon} size="var(--mantine-font-size-lg)" stroke={1.5} color={severityToIconColor(severity)} />
|
||||
)}
|
||||
<span className={classes.compactStatValue}>
|
||||
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
|
||||
|
||||
@@ -303,20 +303,20 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
{hasError ? (
|
||||
<IconTriangleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
|
||||
size={10}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="var(--mantine-color-red-filled)"
|
||||
/>
|
||||
) : repository.viewed ? (
|
||||
<IconCheck
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
|
||||
size={10}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="green"
|
||||
/>
|
||||
) : (
|
||||
(repository.isNewRelease || repository.isStaleRelease) && (
|
||||
<IconCircleFilled
|
||||
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
|
||||
size={10}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color={
|
||||
repository.isNewRelease
|
||||
? "var(--mantine-color-primaryColor-filled)"
|
||||
@@ -390,7 +390,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-preRelease",
|
||||
)}
|
||||
size={13}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
color={
|
||||
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
@@ -411,7 +411,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-archived",
|
||||
)}
|
||||
size={13}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
color={
|
||||
repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
|
||||
}
|
||||
@@ -432,7 +432,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-icon",
|
||||
"releases-repository-details-icon-forked",
|
||||
)}
|
||||
size={13}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -459,7 +459,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-stars-icon",
|
||||
)}
|
||||
size={12}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
@@ -501,7 +501,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-forks-icon",
|
||||
)}
|
||||
size={12}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
@@ -543,7 +543,7 @@ const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: Detai
|
||||
"releases-repository-details-stats-icon",
|
||||
"releases-repository-details-stats-issues-icon",
|
||||
)}
|
||||
size={12}
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
|
||||
/>
|
||||
<Text
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
return (
|
||||
<Flex direction="column" h="100%" p="xs">
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs">
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -103,7 +103,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs" mb="xs">
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -78,14 +78,19 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
)}
|
||||
{compactStaleWarning && displayMode === "compact" && (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-light-color)" />
|
||||
<IconAlertTriangle
|
||||
aria-hidden
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="var(--mantine-color-orange-light-color)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text component="output" size="xs" c="var(--mantine-color-text)" style={{ whiteSpace: "nowrap" }}>
|
||||
{compactStaleWarning}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{staleWarning && displayMode === "advanced" && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size="var(--mantine-font-size-md)" />} p="xs">
|
||||
<output>{staleWarning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -18,14 +18,14 @@ export function StatsBar({
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={cols} spacing="xs">
|
||||
<StatCard icon={<IconVideo size={16} />} label={t("stats.activeStreams")} value={stats.activeStreams} />
|
||||
<StatCard icon={<IconUsers size={16} />} label={t("stats.totalUsers")} value={stats.totalUsers} />
|
||||
<StatCard icon={<IconVideo size="var(--mantine-font-size-md)" />} label={t("stats.activeStreams")} value={stats.activeStreams} />
|
||||
<StatCard icon={<IconUsers size="var(--mantine-font-size-md)" />} label={t("stats.totalUsers")} value={stats.totalUsers} />
|
||||
<StatCard
|
||||
icon={<IconDevices size={16} />}
|
||||
icon={<IconDevices size="var(--mantine-font-size-md)" />}
|
||||
label={t("stats.transcodes")}
|
||||
value={`${summary.transcodes}/${summary.total}`}
|
||||
/>
|
||||
<StatCard icon={<IconNetwork size={16} />} label={t("stats.bandwidth")} value={summary.totalBitrate} />
|
||||
<StatCard icon={<IconNetwork size="var(--mantine-font-size-md)" />} label={t("stats.bandwidth")} value={summary.totalBitrate} />
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ function StreamCard({
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{stream.state === "playing" ? (
|
||||
<IconPlayerPlay size={14} color="var(--mantine-color-green-6)" />
|
||||
<IconPlayerPlay size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<IconPlayerPause size={14} color="var(--mantine-color-yellow-6)" />
|
||||
<IconPlayerPause size="var(--mantine-font-size-sm)" color="var(--mantine-color-yellow-6)" />
|
||||
)}
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{stream.username}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ViolationsList({
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<IconAlertTriangle
|
||||
size={14}
|
||||
size="var(--mantine-font-size-sm)"
|
||||
color={
|
||||
violation.severity === "high"
|
||||
? "var(--mantine-color-red-6)"
|
||||
|
||||
@@ -308,7 +308,7 @@ function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: Traefik
|
||||
{rows.map(({ key, icon: Icon, summary }) => (
|
||||
<div key={key} className={classes.protocolRow}>
|
||||
<Group gap={4} wrap="nowrap" className={classes.protocolLabel}>
|
||||
<Icon size={14} />
|
||||
<Icon size="var(--mantine-font-size-sm)" />
|
||||
<Text>{t(`resource.${key}`)}</Text>
|
||||
</Group>
|
||||
<StatusCount summary={summary} />
|
||||
@@ -354,7 +354,7 @@ function HealthBadge({ errors, warnings }: { errors: number; warnings: number })
|
||||
|
||||
if (errors > 0) {
|
||||
return (
|
||||
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
|
||||
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size="var(--mantine-font-size-xs)" />} radius="sm">
|
||||
{t("status.errors", { count: errors })}
|
||||
</Badge>
|
||||
);
|
||||
@@ -362,14 +362,14 @@ function HealthBadge({ errors, warnings }: { errors: number; warnings: number })
|
||||
|
||||
if (warnings > 0) {
|
||||
return (
|
||||
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
|
||||
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size="var(--mantine-font-size-xs)" />} radius="sm">
|
||||
{t("status.warnings", { count: warnings })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="green" variant="light" leftSection={<IconCircleCheck size={12} />} radius="sm">
|
||||
<Badge color="green" variant="light" leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />} radius="sm">
|
||||
{t("status.healthy")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -159,7 +159,7 @@ function VpnInfoColumn({
|
||||
</Text>
|
||||
{!dense && (city || country) && (
|
||||
<Group gap={4} justify="flex-start" wrap="nowrap">
|
||||
<IconMapPin aria-hidden size={10} />
|
||||
<IconMapPin aria-hidden size="var(--mantine-font-size-xs)" />
|
||||
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
|
||||
{[city, country].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
@@ -176,7 +176,7 @@ function VpnProviderDetails({ provider, protocol }: { provider: string; protocol
|
||||
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
|
||||
{provider}
|
||||
</Text>
|
||||
<IconArrowsExchange stroke={1} size={12} style={{ flexShrink: 0 }} />
|
||||
<IconArrowsExchange stroke={1} size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
|
||||
{protocol}
|
||||
</Text>
|
||||
@@ -196,7 +196,7 @@ function DnsStatusBadge({ status }: { status: string }) {
|
||||
color={getStatusColor(status)}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<StatusIcon aria-hidden size={9} />}
|
||||
leftSection={<StatusIcon aria-hidden size="var(--mantine-font-size-xs)" />}
|
||||
>
|
||||
DNS
|
||||
<VisuallyHidden> {statusLabel}</VisuallyHidden>
|
||||
|
||||
@@ -116,11 +116,11 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconMapPin size={16} aria-hidden />
|
||||
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
|
||||
<Text size="sm">{options.location.name}</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconWind size={16} aria-hidden />
|
||||
<IconWind size="var(--mantine-font-size-md)" aria-hidden />
|
||||
<Text size="sm">
|
||||
{t("currentWindSpeed", {
|
||||
currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed),
|
||||
@@ -196,7 +196,7 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowUpRight size={15} aria-hidden />
|
||||
<IconArrowUpRight size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<Text size="sm">
|
||||
{getPreferredUnit(
|
||||
dayWeather.maxTemp,
|
||||
@@ -207,7 +207,7 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<IconArrowDownRight size={15} aria-hidden />
|
||||
<IconArrowDownRight size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<Text size="sm">
|
||||
{getPreferredUnit(
|
||||
dayWeather.minTemp,
|
||||
@@ -219,13 +219,13 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconDroplets size={15} aria-hidden />
|
||||
<IconDroplets size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<Text size="sm">{dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<IconWind size={15} aria-hidden />
|
||||
<IconWind size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<Text size="sm">
|
||||
{getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "}
|
||||
{getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)}
|
||||
@@ -235,14 +235,14 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconSunrise size={15} aria-hidden />
|
||||
<IconSunrise size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<Text size="sm">{getPreferredTime(dayWeather.sunrise, locale)}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconSunset size={15} aria-hidden />
|
||||
<IconSunset size="var(--mantine-font-size-sm)" aria-hidden />
|
||||
<Text size="sm">{getPreferredTime(dayWeather.sunset, locale)}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -285,7 +285,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
<Stack gap="xs" align="center">
|
||||
{options.showCurrentWindSpeed && (
|
||||
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs">
|
||||
<IconWind size={16} />
|
||||
<IconWind size="var(--mantine-font-size-md)" />
|
||||
<Text fz={16}>
|
||||
{t("currentWindSpeed", {
|
||||
currentWindSpeed: (options.useImperialSpeed
|
||||
@@ -301,13 +301,13 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
)}
|
||||
{weather.daily[0]?.humidity !== undefined && (
|
||||
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
|
||||
<IconDroplets size={16} />
|
||||
<IconDroplets size="var(--mantine-font-size-md)" />
|
||||
<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 gap="xs" wrap="nowrap">
|
||||
<IconArrowUpRight size={16} />
|
||||
<IconArrowUpRight size="var(--mantine-font-size-md)" />
|
||||
<Text fz={16}>
|
||||
{getPreferredUnit(
|
||||
weather.daily[0]?.maxTemp,
|
||||
@@ -317,7 +317,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconArrowDownRight size={16} />
|
||||
<IconArrowDownRight size="var(--mantine-font-size-md)" />
|
||||
<Text fz={16}>
|
||||
{getPreferredUnit(
|
||||
weather.daily[0]?.minTemp,
|
||||
@@ -331,7 +331,7 @@ const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) =>
|
||||
{options.showCity && (
|
||||
<>
|
||||
<Group className="weather-city-group" wrap="nowrap" gap="xs">
|
||||
<IconMapPin size={16} />
|
||||
<IconMapPin size="var(--mantine-font-size-md)" />
|
||||
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
|
||||
{options.location.name}
|
||||
</Text>
|
||||
@@ -350,7 +350,7 @@ const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeathe
|
||||
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
|
||||
{options.showCity && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconMapPin size={16} />
|
||||
<IconMapPin size="var(--mantine-font-size-md)" />
|
||||
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
|
||||
{options.location.name}
|
||||
</Text>
|
||||
|
||||
@@ -95,15 +95,15 @@ export const WeatherDescription = ({
|
||||
<Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text>
|
||||
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
||||
<List>
|
||||
<List.Item icon={<IconTemperaturePlus size={15} />}>{`${tCommon("information.max")}: ${maxTemp}`}</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={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
||||
<List.Item icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
|
||||
<List.Item icon={<IconTemperatureMinus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
||||
<List.Item icon={<IconSun size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
||||
<List.Item icon={<IconMoon size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
||||
{humidity !== undefined && (
|
||||
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
|
||||
<List.Item icon={<IconDroplets size="var(--mantine-font-size-sm)" />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
|
||||
)}
|
||||
{maxWindSpeed !== undefined && (
|
||||
<List.Item icon={<IconWind size={15} />}>
|
||||
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
|
||||
{t("dailyForecast.maxWindSpeed", {
|
||||
maxWindSpeed: (useImperialSpeed ? metricToImperial(maxWindSpeed) : maxWindSpeed).toFixed(1),
|
||||
unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"),
|
||||
@@ -111,7 +111,7 @@ export const WeatherDescription = ({
|
||||
</List.Item>
|
||||
)}
|
||||
{maxWindGusts !== undefined && (
|
||||
<List.Item icon={<IconWind size={15} />}>
|
||||
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
|
||||
{t("dailyForecast.maxWindGusts", {
|
||||
maxWindGusts: (useImperialSpeed ? metricToImperial(maxWindGusts) : maxWindGusts).toFixed(1),
|
||||
unit: useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"),
|
||||
|
||||
@@ -88,7 +88,7 @@ export const WidgetIntegrationSelect = ({
|
||||
return (
|
||||
<Combobox.Option value={item.id} key={item.id} active={multiSelectValues.includes(item.id)}>
|
||||
<Group gap="sm" align="center">
|
||||
{multiSelectValues.includes(item.id) ? <CheckIcon size={12} /> : null}
|
||||
{multiSelectValues.includes(item.id) ? <CheckIcon size="var(--mantine-font-size-xs)" /> : null}
|
||||
<Group gap={7} align="center">
|
||||
<Avatar src={getIconUrl(item.kind)} size="sm" />
|
||||
<Stack gap={0}>
|
||||
|
||||
Reference in new issue
Block a user