feat: restrict non credential provider interactions (#871)

* wip: add provider field to sqlite user table

* feat: disable invites when credentials provider is not used

* wip: add migration for provider field in user table with sqlite

* wip: remove fields that can not be modified by non credential users

* wip: make username, mail and avatar disabled instead of hidden

* wip: external users membership of group cannot be managed manually

* feat: add alerts to inform about disabled fields and managing group members

* wip: add mysql migration for provider on user table

* chore: fix format issues

* chore: address pull request feedback

* fix: build issue

* fix: deepsource issues

* fix: tests not working

* feat: restrict login to specific auth providers

* chore: address pull request feedback

* fix: deepsource issue
This commit is contained in:
Meier Lukas
2024-07-27 11:38:51 +02:00
committed by GitHub
parent eba4052522
commit 6f7327b774
36 changed files with 2989 additions and 116 deletions

View File

@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server";
@@ -19,6 +20,8 @@ interface InviteUsagePageProps {
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();
if (session) notFound();

View File

@@ -22,6 +22,7 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
@@ -65,6 +66,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.users.items.invites"),
icon: IconMailForward,
href: "/manage/users/invites",
hidden: !isProviderEnabled("credentials"),
},
{
label: t("items.users.items.groups"),

View File

@@ -3,6 +3,7 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -14,6 +15,7 @@ interface LinkProps {
subtitle: string;
count: number;
href: string;
hidden?: boolean;
}
export async function generateMetadata() {
@@ -42,6 +44,7 @@ export default async function ManagementPage() {
title: t("statistic.createUser"),
},
{
hidden: !isProviderEnabled("credentials"),
count: statistics.countInvites,
href: "/manage/users/invites",
subtitle: t("statisticLabel.authentication"),
@@ -72,24 +75,27 @@ export default async function ManagementPage() {
<HeroBanner />
<Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{links.map((link, index) => (
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="2.4rem" fw="bolder">
{link.count}
</Text>
<Stack gap={0}>
<Text c="red" size="xs">
{link.subtitle}
</Text>
<Text fw="bold">{link.title}</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
))}
{links.map(
(link) =>
!link.hidden && (
<Card component={Link} href={link.href} key={link.href} withBorder>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="2.4rem" fw="bolder">
{link.count}
</Text>
<Stack gap={0}>
<Text c="red" size="xs">
{link.subtitle}
</Text>
<Text fw="bold">{link.title}</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
),
)}
</SimpleGrid>
</>
);

View File

@@ -93,24 +93,38 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
const isCredentialsUser = user.provider === "credentials";
return (
<Box pos="relative">
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
<Menu
opened={opened}
keepMounted
onChange={isCredentialsUser ? toggle : undefined}
position="bottom-start"
withArrow
>
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UnstyledButton
component={isCredentialsUser ? undefined : "div"}
style={{ cursor: !isCredentialsUser ? "default" : undefined }}
onClick={isCredentialsUser ? toggle : undefined}
>
<UserAvatar user={user} size={200} />
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
{isCredentialsUser && (
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
)}
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>

View File

@@ -51,8 +51,12 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
},
});
// Only credentials users can edit their profile
const isProviderCredentials = user.provider === "credentials";
const handleSubmit = useCallback(
(values: FormType) => {
if (!isProviderCredentials) return;
mutate({
...values,
id: user.id,
@@ -64,14 +68,25 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Group justify="end">
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
{isProviderCredentials && (
<Group justify="end">
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Stack>
</form>
);

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { Box, Group, Stack, Title } from "@mantine/core";
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -53,8 +54,14 @@ export default async function EditUserPage({ params }: Props) {
notFound();
}
const isCredentialsUser = user.provider === "credentials";
return (
<Stack>
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
@@ -67,13 +74,15 @@ export default async function EditUserPage({ params }: Props) {
<ProfileLanguageChange />
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
{isCredentialsUser && (
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
)}
</Stack>
);
}

View File

@@ -28,6 +28,8 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
notFound();
}
const isCredentialsUser = user.provider === "credentials";
return (
<ManageContainer size="xl">
<DynamicBreadcrumb
@@ -57,11 +59,13 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
label={tUser("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/${params.userId}/security`}
label={tUser("setting.security.title")}
icon={<IconShieldLock size="1rem" stroke={1.5} />}
/>
{isCredentialsUser && (
<NavigationLink
href={`/manage/users/${params.userId}/security`}
label={tUser("setting.security.title")}
icon={<IconShieldLock size="1rem" stroke={1.5} />}
/>
)}
</Stack>
</Stack>
</GridCol>

View File

@@ -28,6 +28,10 @@ export default async function UserSecurityPage({ params }: Props) {
notFound();
}
if (user.provider !== "credentials") {
notFound();
}
return (
<Stack>
<Title>{tSecurity("title")}</Title>

View File

@@ -1,8 +1,11 @@
import Link from "next/link";
import { Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env.mjs";
import { isProviderEnabled } from "@homarr/auth/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, UserAvatar } from "@homarr/ui";
@@ -28,9 +31,22 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
: group.members;
const providerTypes = isProviderEnabled("credentials")
? env.AUTH_PROVIDERS.length > 1
? "mixed"
: "credentials"
: "external";
return (
<Stack>
<Title>{tMembers("title")}</Title>
{providerTypes !== "credentials" && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${providerTypes}`)}
</Alert>
)}
<Group justify="space-between">
<SearchInput
placeholder={t("common.rtl", {
@@ -39,7 +55,9 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
})}
defaultValue={searchParams.search}
/>
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
{isProviderEnabled("credentials") && (
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
)}
</Group>
{filteredMembers.length === 0 && (
<Center py="sm">
@@ -60,7 +78,7 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
}
interface RowProps {
member: RouterOutputs["group"]["getPaginated"]["items"][number]["members"][number];
member: RouterOutputs["group"]["getById"]["members"][number];
groupId: string;
}
@@ -70,13 +88,13 @@ const Row = ({ member, groupId }: RowProps) => {
<TableTd>
<Group>
<UserAvatar size="sm" user={member} />
<Anchor component={Link} href={`/manage/users/${member.id}`}>
<Anchor component={Link} href={`/manage/users/${member.id}/general`}>
{member.name}
</Anchor>
</Group>
</TableTd>
<TableTd w={100}>
<RemoveGroupMember user={member} groupId={groupId} />
{member.provider === "credentials" && <RemoveGroupMember user={member} groupId={groupId} />}
</TableTd>
</TableTr>
);

View File

@@ -1,9 +1,16 @@
import { notFound } from "next/navigation";
import { api } from "@homarr/api/server";
import { isProviderEnabled } from "@homarr/auth/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() {
if (!isProviderEnabled("credentials")) {
notFound();
}
const initialInvites = await api.invite.getAll();
return (
<>

View File

@@ -22,18 +22,24 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
component={ScrollArea}
>
{links.map((link, index) => {
if (link.hidden) {
return null;
}
const { icon: TablerIcon, ...props } = link;
const Icon = <TablerIcon size={20} stroke={1.5} />;
let clientLink: ClientNavigationLink;
if ("items" in props) {
clientLink = {
...props,
items: props.items.map((item) => {
return {
...item,
icon: <item.icon size={20} stroke={1.5} />,
};
}),
items: props.items
.filter((item) => !item.hidden)
.map((item) => {
return {
...item,
icon: <item.icon size={20} stroke={1.5} />,
};
}),
} as ClientNavigationLink;
} else {
clientLink = props as ClientNavigationLink;
@@ -49,6 +55,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
interface CommonNavigationLinkProps {
label: string;
icon: TablerIcon;
hidden?: boolean;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {