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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,10 @@ export default async function UserSecurityPage({ params }: Props) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (user.provider !== "credentials") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{tSecurity("title")}</Title>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user