feat: add edit user page (#173)
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { Button, Divider, Group, Stack, Text } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
|
interface DangerZoneAccordionProps {
|
||||||
|
user: NonNullable<RouterOutputs["user"]["getById"]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DangerZoneAccordion = ({ user }: DangerZoneAccordionProps) => {
|
||||||
|
const t = useScopedI18n("management.page.user.edit.section.dangerZone");
|
||||||
|
const router = useRouter();
|
||||||
|
const { mutateAsync: mutateUserDeletionAsync } =
|
||||||
|
clientApi.user.delete.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
await router.push("/manage/users");
|
||||||
|
await revalidatePathAction("/manage/users");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(
|
||||||
|
async () => await mutateUserDeletionAsync(user.id),
|
||||||
|
[user, mutateUserDeletionAsync],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" px="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold" size="sm">
|
||||||
|
{t("action.delete.label")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{t("action.delete.description")}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button onClick={handleDelete} variant="subtle" color="red">
|
||||||
|
{t("action.delete.button")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useForm, zodResolver } from "@homarr/form";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { Button, Stack, TextInput } from "@homarr/ui";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||||
|
|
||||||
|
interface ProfileAccordionProps {
|
||||||
|
user: NonNullable<RouterOutputs["user"]["getById"]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
|
||||||
|
const t = useScopedI18n("management.page.user.edit.section.profile");
|
||||||
|
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
await revalidatePathAction("/manage/users");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
},
|
||||||
|
validate: zodResolver(validation.user.editProfile),
|
||||||
|
validateInputOnBlur: true,
|
||||||
|
validateInputOnChange: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
mutate({
|
||||||
|
userId: user.id,
|
||||||
|
form: form.values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label={t("form.username.label")}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("form.email.label")}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Title } from "@homarr/ui";
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionControl,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionPanel,
|
||||||
|
Avatar,
|
||||||
|
Group,
|
||||||
|
IconAlertTriangleFilled,
|
||||||
|
IconSettingsFilled,
|
||||||
|
IconShieldLockFilled,
|
||||||
|
IconUserFilled,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
import { api } from "~/trpc/server";
|
||||||
|
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
|
||||||
|
import { ProfileAccordion } from "./_components/profile.accordion";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
@@ -24,6 +40,7 @@ export async function generateMetadata({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditUserPage({ params }: Props) {
|
export default async function EditUserPage({ params }: Props) {
|
||||||
|
const t = await getScopedI18n("management.page.user.edit");
|
||||||
const user = await api.user.getById({
|
const user = await api.user.getById({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
});
|
});
|
||||||
@@ -32,5 +49,60 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Title>Edit User {user.name}!</Title>;
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group mb="md">
|
||||||
|
<Avatar>{user.name?.substring(0, 2)}</Avatar>
|
||||||
|
<Title>{user.name}</Title>
|
||||||
|
</Group>
|
||||||
|
<Accordion variant="separated" defaultValue="general">
|
||||||
|
<AccordionItem value="general">
|
||||||
|
<AccordionControl icon={<IconUserFilled />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.profile.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel>
|
||||||
|
<ProfileAccordion user={user} />
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="preferences">
|
||||||
|
<AccordionControl icon={<IconSettingsFilled />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.preferences.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel></AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="security">
|
||||||
|
<AccordionControl icon={<IconShieldLockFilled />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.security.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel></AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem
|
||||||
|
styles={{
|
||||||
|
item: {
|
||||||
|
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||||
|
borderWidth: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value="dangerZone"
|
||||||
|
>
|
||||||
|
<AccordionControl icon={<IconAlertTriangleFilled />}>
|
||||||
|
<Text fw="bold" size="lg">
|
||||||
|
{t("section.dangerZone.title")}
|
||||||
|
</Text>
|
||||||
|
</AccordionControl>
|
||||||
|
<AccordionPanel
|
||||||
|
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
|
||||||
|
>
|
||||||
|
<DangerZoneAccordion user={user} />
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { schema } from "@homarr/db";
|
import { createId, eq, schema } from "@homarr/db";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
import { userRouter } from "../user";
|
import { userRouter } from "../user";
|
||||||
@@ -91,4 +91,140 @@ describe("initUser should initialize the first user", () => {
|
|||||||
|
|
||||||
await expect(act()).rejects.toThrow("too_small");
|
await expect(act()).rejects.toThrow("too_small");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
|
||||||
|
// arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = userRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = createId();
|
||||||
|
const emailVerified = new Date(2024, 0, 5);
|
||||||
|
|
||||||
|
await db.insert(schema.users).values({
|
||||||
|
id,
|
||||||
|
name: "TEST 1",
|
||||||
|
email: "abc@gmail.com",
|
||||||
|
emailVerified,
|
||||||
|
});
|
||||||
|
|
||||||
|
// act
|
||||||
|
await caller.editProfile({
|
||||||
|
userId: id,
|
||||||
|
form: {
|
||||||
|
name: "ABC",
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, id));
|
||||||
|
|
||||||
|
expect(user).toHaveLength(1);
|
||||||
|
expect(user[0]).toStrictEqual({
|
||||||
|
id,
|
||||||
|
name: "ABC",
|
||||||
|
email: "abc@gmail.com",
|
||||||
|
emailVerified,
|
||||||
|
salt: null,
|
||||||
|
password: null,
|
||||||
|
image: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editProfile should update users and update emailVerified when email dirty", async () => {
|
||||||
|
// arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = userRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = createId();
|
||||||
|
|
||||||
|
await db.insert(schema.users).values({
|
||||||
|
id,
|
||||||
|
name: "TEST 1",
|
||||||
|
email: "abc@gmail.com",
|
||||||
|
emailVerified: new Date(2024, 0, 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
// act
|
||||||
|
await caller.editProfile({
|
||||||
|
userId: id,
|
||||||
|
form: {
|
||||||
|
name: "ABC",
|
||||||
|
email: "myNewEmail@gmail.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, id));
|
||||||
|
|
||||||
|
expect(user).toHaveLength(1);
|
||||||
|
expect(user[0]).toStrictEqual({
|
||||||
|
id,
|
||||||
|
name: "ABC",
|
||||||
|
email: "myNewEmail@gmail.com",
|
||||||
|
emailVerified: null,
|
||||||
|
salt: null,
|
||||||
|
password: null,
|
||||||
|
image: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete should delete user", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = userRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userToDelete = createId();
|
||||||
|
|
||||||
|
const initialUsers = [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
name: "User 1",
|
||||||
|
email: null,
|
||||||
|
emailVerified: null,
|
||||||
|
image: null,
|
||||||
|
password: null,
|
||||||
|
salt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: userToDelete,
|
||||||
|
name: "User 2",
|
||||||
|
email: null,
|
||||||
|
emailVerified: null,
|
||||||
|
image: null,
|
||||||
|
password: null,
|
||||||
|
salt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
name: "User 3",
|
||||||
|
email: null,
|
||||||
|
emailVerified: null,
|
||||||
|
image: null,
|
||||||
|
password: null,
|
||||||
|
salt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(schema.users).values(initialUsers);
|
||||||
|
|
||||||
|
await caller.delete(userToDelete);
|
||||||
|
|
||||||
|
const usersInDb = await db.select().from(schema.users);
|
||||||
|
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { createSalt, hashPassword } from "@homarr/auth";
|
import { createSalt, hashPassword } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { createId, db, eq, schema } from "@homarr/db";
|
import { createId, eq, schema } from "@homarr/db";
|
||||||
import { users } from "@homarr/db/schema/sqlite";
|
import { users } from "@homarr/db/schema/sqlite";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await createUser(ctx.db, input);
|
await createUser(ctx.db, input);
|
||||||
}),
|
}),
|
||||||
getAll: publicProcedure.query(async () => {
|
getAll: publicProcedure.query(async ({ ctx }) => {
|
||||||
return db.query.users.findMany({
|
return ctx.db.query.users.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -47,8 +47,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
getById: publicProcedure
|
getById: publicProcedure
|
||||||
.input(z.object({ userId: z.string() }))
|
.input(z.object({ userId: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return db.query.users.findFirst({
|
return ctx.db.query.users.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -59,6 +59,34 @@ export const userRouter = createTRPCRouter({
|
|||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
editProfile: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
form: validation.user.editProfile,
|
||||||
|
userId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const user = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, input.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const emailDirty =
|
||||||
|
input.form.email && user[0]?.email !== input.form.email;
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
name: input.form.name,
|
||||||
|
email: emailDirty === true ? input.form.email : undefined,
|
||||||
|
emailVerified: emailDirty === true ? null : undefined,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
}),
|
||||||
|
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||||
|
await ctx.db.delete(users).where(eq(users.id, input));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUser = async (
|
const createUser = async (
|
||||||
|
|||||||
@@ -568,6 +568,36 @@ export default {
|
|||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
metaTitle: "Edit user {username}",
|
metaTitle: "Edit user {username}",
|
||||||
|
section: {
|
||||||
|
profile: {
|
||||||
|
title: "Profile",
|
||||||
|
form: {
|
||||||
|
username: {
|
||||||
|
label: "Username",
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
label: "E-Mail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
title: "Preferences",
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
title: "Security",
|
||||||
|
},
|
||||||
|
dangerZone: {
|
||||||
|
title: "Danger zone",
|
||||||
|
action: {
|
||||||
|
delete: {
|
||||||
|
label: "Delete user permanently",
|
||||||
|
description:
|
||||||
|
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
|
||||||
|
button: "Delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
metaTitle: "Create user",
|
metaTitle: "Create user",
|
||||||
|
|||||||
@@ -22,9 +22,21 @@ const signInSchema = z.object({
|
|||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editProfileSchema = z.object({
|
||||||
|
name: usernameSchema,
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.or(z.literal(""))
|
||||||
|
.transform((value) => (value === "" ? null : value))
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
export const userSchemas = {
|
export const userSchemas = {
|
||||||
signIn: signInSchema,
|
signIn: signInSchema,
|
||||||
init: initUserSchema,
|
init: initUserSchema,
|
||||||
create: createUserSchema,
|
create: createUserSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
|
editProfile: editProfileSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user