Skip to content

Commit

Permalink
feat: restrict non credential provider interactions (#871)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Meierschlumpf authored Jul 27, 2024
1 parent eba4052 commit 6f7327b
Show file tree
Hide file tree
Showing 36 changed files with 2,989 additions and 116 deletions.
3 changes: 3 additions & 0 deletions apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions apps/nextjs/src/app/[locale]/manage/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
42 changes: 24 additions & 18 deletions apps/nextjs/src/app/[locale]/manage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,6 +15,7 @@ interface LinkProps {
subtitle: string;
count: number;
href: string;
hidden?: boolean;
}

export async function generateMetadata() {
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}>
Expand All @@ -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>
);
}
14 changes: 9 additions & 5 deletions apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
notFound();
}

const isCredentialsUser = user.provider === "credentials";

return (
<ManageContainer size="xl">
<DynamicBreadcrumb
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export default async function UserSecurityPage({ params }: Props) {
notFound();
}

if (user.provider !== "credentials") {
notFound();
}

return (
<Stack>
<Title>{tSecurity("title")}</Title>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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", {
Expand All @@ -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">
Expand All @@ -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;
}

Expand All @@ -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>
);
Expand Down
7 changes: 7 additions & 0 deletions apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand Down
Loading

0 comments on commit 6f7327b

Please sign in to comment.