Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: user management view #9

Merged
merged 16 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ export interface ChangePasswordBody {
* @minLength 8
* @maxLength 64
*/
password: string;
newPassword: string;
/**
* @minLength 8
* @maxLength 64
*/
oldPassword: string;
}

export type ChangePasswordResponse = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { toast } from "sonner";
import { useAuthStore } from "~/modules/Auth/authStore";
import { ApiClient } from "../api-client";
import { ChangePasswordBody } from "../generated-api";

type ChangePasswordOptions = {
data: ChangePasswordBody;
};

export function useChangePassword() {
const { currentUser } = useAuthStore();
if (!currentUser) {
throw new Error("User is not logged in");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove this, if user is not logged in backend will return 401


return useMutation({
mutationFn: async (options: ChangePasswordOptions) => {
const response = await ApiClient.users.usersControllerChangePassword(
currentUser.id,
options.data
);

return response.data;
},
onSuccess: () => {
toast.success("Password updated successfully");
},
onError: (error) => {
if (error instanceof AxiosError) {
return toast.error(error.response?.data.message);
}
toast.error(error.message);
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ type LoginUserOptions = {
};

export function useLoginUser() {
const { setLoggedIn } = useAuthStore();
const { setLoggedIn, setCurrentUser } = useAuthStore();
return useMutation({
mutationFn: async (options: LoginUserOptions) => {
const response = await ApiClient.auth.authControllerLogin(options.data);

if (!response) throw new Error("Invalid username or password");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@k1eu any ideas on how to move to onError in this case?
the problem is in return response.data, where the response is undefined, even if the ‘real’ response has an error message.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should actually go to onError if you throw or the ApiCLient throws here 🤔


return response.data;
},
onSuccess: () => {
onSuccess: (data) => {
setCurrentUser(data.data);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a /current-user endpoint and treat useCurrentUser() - as react query hook for that with longer revalidate time, then in the

setLoggedIn(true);
},
onError: (error) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { toast } from "sonner";
import { useAuthStore } from "~/modules/Auth/authStore";
import { ApiClient } from "../api-client";
import { UpdateUserBody } from "../generated-api";

type UpdateUserOptions = {
data: UpdateUserBody;
};

export function useUpdateUser() {
const { setCurrentUser, currentUser } = useAuthStore();
if (!currentUser) {
throw new Error("User is not logged in");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove that, user shouldn't be able to get into that action ui wise, and if SOMEHOW he gets there - he should be blocked on BE roles


return useMutation({
mutationFn: async (options: UpdateUserOptions) => {
const response = await ApiClient.users.usersControllerUpdateUser(
currentUser.id,
options.data
);

return response.data;
},
onSuccess: (data) => {
setCurrentUser(data.data);
toast.success("User updated successfully");
},
onError: (error) => {
if (error instanceof AxiosError) {
return toast.error(error.response?.data.message);
}
toast.error(error.message);
},
});
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { UpdateUserResponse } from "~/api/generated-api";

type AuthStore = {
isLoggedIn: boolean;
setLoggedIn: (value: boolean) => void;
currentUser: UpdateUserResponse["data"] | null;
setCurrentUser: (user: UpdateUserResponse["data"]) => void;
};

export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
isLoggedIn: false,
setLoggedIn: (value) => set({ isLoggedIn: value }),
currentUser: null,
setCurrentUser: (user) => set({ currentUser: user }),
}),
{
name: "auth-storage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export default function DashboardLayout() {
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link to="#">Settings</Link>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link to="/dashboard/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Link, Outlet, useLocation } from "@remix-run/react";
import { replace } from "lodash-es";
import { cn } from "~/lib/utils";

export default function SettingsLayout() {
const { hash } = useLocation();
const currentTab = replace(hash, "#", "") || "user-info";

const navigationItems = [
{
id: "user-info",
title: "User Info",
},
{
id: "change-password",
title: "Change Password",
},
];

return (
<div className="mx-auto grid w-full max-w-6xl items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr] p-4">
<nav className="grid gap-4 text-sm text-muted-foreground">
<h1 className="text-3xl font-semibold text-primary">Settings</h1>
{navigationItems.map((item) => (
<Link
key={item.id}
to={`#${item.id}`}
className={cn({
"text-primary": currentTab === item.id,
})}
>
{item.title}
</Link>
))}
</nav>
<Outlet />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ChangePasswordForm from "./forms/ChangePasswordForm";
import UserForm from "./forms/UserForm";

export default function SettingsPage() {
return (
<div className="grid gap-6">
<UserForm />
<ChangePasswordForm />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ChangePasswordBody } from "~/api/generated-api";
import { Button } from "~/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { useChangePassword } from "~/api/mutations/useChangePassword";
import { cn } from "~/lib/utils";

const passwordSchema = z.object({
oldPassword: z.string().min(8),
newPassword: z.string().min(8),
});

export default function ChangePasswordForm() {
const { mutate: changePassword } = useChangePassword();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ChangePasswordBody>({ resolver: zodResolver(passwordSchema) });

const onSubmit = (data: ChangePasswordBody) => {
changePassword({ data });
};

return (
<Card id="user-info">
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Change password</CardTitle>
<CardDescription>Update your password here.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Label htmlFor="oldPassword">Old Password</Label>
<Input
id="oldPassword"
className={cn({ "border-red-500": errors.oldPassword })}
{...register("oldPassword", {
required: "Old password is required",
})}
/>
{errors.oldPassword && (
<p className="text-red-500 text-sm mt-1">
{errors.oldPassword.message}
</p>
)}
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
className={cn({ "border-red-500": errors.newPassword })}
{...register("newPassword", {
required: "New password is required",
})}
/>
{errors.newPassword && (
<p className="text-red-500 text-sm mt-1">
{errors.newPassword.message}
</p>
)}
</CardContent>
<CardFooter className="border-t px-6 py-4">
<Button type="submit">Save</Button>
</CardFooter>
</form>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { UpdateUserBody } from "~/api/generated-api";
import { useUpdateUser } from "~/api/mutations/useUpdateUser";
import { Button } from "~/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { cn } from "~/lib/utils";
import { useAuthStore } from "~/modules/Auth/authStore";

const updateUserSchema = z.object({
email: z.string().email(),
});

export default function UserForm() {
const { mutate: updateUser } = useUpdateUser();
const currentUser = useAuthStore.getState().currentUser;

const {
register,
handleSubmit,
formState: { errors },
} = useForm<UpdateUserBody>({ resolver: zodResolver(updateUserSchema) });

const onSubmit = (data: UpdateUserBody) => {
updateUser({ data });
};

return (
<Card id="change-password">
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Change user information</CardTitle>
<CardDescription>Update your user information here.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
placeholder="user@email.com"
defaultValue={currentUser?.email}
className={cn({ "border-red-500": errors.email })}
{...register("email", { required: "Email is required" })}
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</CardContent>
<CardFooter className="border-t px-6 py-4">
<Button>Save</Button>
</CardFooter>
</form>
</Card>
);
}
2 changes: 1 addition & 1 deletion examples/common_nestjs_remix/apps/web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Toaster } from "./components/ui/sonner";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" className="scroll-smooth">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
Expand Down
2 changes: 2 additions & 0 deletions examples/common_nestjs_remix/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"compression": "^1.7.4",
"express": "^4.18.2",
"isbot": "^4.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.408.0",
"morgan": "^1.10.0",
"react": "^18.3.1",
Expand All @@ -46,6 +47,7 @@
"@remix-run/dev": "^2.9.2",
"@types/compression": "^1.7.5",
"@types/express": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/morgan": "^1.9.9",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
Expand Down
9 changes: 9 additions & 0 deletions examples/common_nestjs_remix/apps/web/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export const routes: (
route("", "modules/Dashboard/Dashboard.page.tsx", {
index: true,
});
route(
"settings",
"modules/Dashboard/Settings/Settings.layout.tsx",
() => {
route("", "modules/Dashboard/Settings/Settings.page.tsx", {
index: true,
});
}
);
});
route("auth", "modules/Auth/Auth.layout.tsx", () => {
route("login", "modules/Auth/Login.page.tsx");
Expand Down
Loading