feat(auth): admin user updates added
if a password change happens then an email will be sent to the user.
This commit is contained in:
43
frontend/src/components/admin/user/UserPage.tsx
Normal file
43
frontend/src/components/admin/user/UserPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { getUsers } from "@/utils/querys/admin/users";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import UserCard from "./components/UserCard";
|
||||
|
||||
export default function UserPage() {
|
||||
const { data, isError, error, isLoading } = useQuery(getUsers());
|
||||
|
||||
if (isLoading) return <div className="m-auto">Loading users...</div>;
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<div className="m-auto">
|
||||
There was an error getting the users.... {JSON.stringify(error)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="m-2 w-dvw">
|
||||
<Accordion type="single" collapsible>
|
||||
{data.map((u: any) => {
|
||||
return (
|
||||
<AccordionItem key={u.user_id} value={u.user_id}>
|
||||
<AccordionTrigger>
|
||||
<span>{u.username}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div>
|
||||
<UserCard user={u} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/admin/user/components/UserCard.tsx
Normal file
183
frontend/src/components/admin/user/components/UserCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { userFormOptions } from "@/utils/formStuff/options/userformOptions";
|
||||
import { generatePassword } from "@/utils/passwordGen";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function UserCard(data: any) {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const form = useForm({
|
||||
...userFormOptions(data.user),
|
||||
onSubmit: async ({ value }) => {
|
||||
// Do something with form data
|
||||
|
||||
const userData = { ...value, user_id: data.user.user_id };
|
||||
|
||||
try {
|
||||
const res = await axios.patch(
|
||||
"/api/auth/updateuser",
|
||||
userData,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.data.success) {
|
||||
toast.success(res.data.message);
|
||||
form.reset();
|
||||
} else {
|
||||
res.data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<form.Field
|
||||
name="username"
|
||||
validators={{
|
||||
// We can choose between form-wide and field-specific validators
|
||||
onChange: ({ value }) =>
|
||||
value.length > 3
|
||||
? undefined
|
||||
: "Username must be longer than 3 letters",
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<div className="m-2 min-w-48 max-w-96 p-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
//type="number"
|
||||
onChange={(e) =>
|
||||
field.handleChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{field.state.meta.errors.length ? (
|
||||
<em>{field.state.meta.errors.join(",")}</em>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form.Field
|
||||
name="email"
|
||||
validators={{
|
||||
// We can choose between form-wide and field-specific validators
|
||||
onChange: ({ value }) =>
|
||||
value.length > 3
|
||||
? undefined
|
||||
: "You must enter a correct ",
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<div className="m-2 min-w-48 max-w-96 p-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
//type="number"
|
||||
onChange={(e) =>
|
||||
field.handleChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{field.state.meta.errors.length ? (
|
||||
<em>{field.state.meta.errors.join(",")}</em>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form.Field
|
||||
name="password"
|
||||
validators={{
|
||||
onChangeAsyncDebounceMs: 500,
|
||||
onChangeAsync: ({ value }) => {
|
||||
if (
|
||||
window.location.pathname.includes("/users") &&
|
||||
value.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (value.length < 4) {
|
||||
return "Password must be at least 4 characters long.";
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(value)) {
|
||||
return "Password must contain at least one uppercase letter.";
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(value)) {
|
||||
return "Password must contain at least one lower case letter.";
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(value)) {
|
||||
return "Password must contain at least one number.";
|
||||
}
|
||||
|
||||
if (
|
||||
!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
return "Password must contain at least one special character.";
|
||||
}
|
||||
},
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<div className="m-2 min-w-48 max-w-96 p-2">
|
||||
<Label htmlFor="password">
|
||||
Change Password
|
||||
</Label>
|
||||
<div className="mt-2 flex flex-row">
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
//type="number"
|
||||
onChange={(e) =>
|
||||
field.handleChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className="ml-2"
|
||||
onClick={() =>
|
||||
field.handleChange(
|
||||
generatePassword(8)
|
||||
)
|
||||
}
|
||||
>
|
||||
Random password
|
||||
</Button>
|
||||
</div>
|
||||
{field.state.meta.errors.length ? (
|
||||
<em>{field.state.meta.errors.join(",")}</em>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<div>
|
||||
<Button onClick={form.handleSubmit}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
import {Atom, Logs, Minus, Plus, Server, Settings, ShieldCheck, Users, Webhook} from "lucide-react";
|
||||
import {
|
||||
Atom,
|
||||
Logs,
|
||||
Minus,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -10,7 +20,11 @@ import {
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "../../ui/sidebar";
|
||||
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from "../../ui/collapsible";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../../ui/collapsible";
|
||||
|
||||
const items = [
|
||||
{
|
||||
@@ -53,9 +67,9 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "#",
|
||||
url: "/users",
|
||||
icon: Users,
|
||||
isActive: false,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: "UCD",
|
||||
@@ -82,7 +96,11 @@ export function AdminSideBar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{data.navMain.map((item, index) => (
|
||||
<Collapsible key={item.title} defaultOpen={index === 1} className="group/collapsible">
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
defaultOpen={index === 1}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
@@ -96,15 +114,25 @@ export function AdminSideBar() {
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuSubItem
|
||||
key={item.title}
|
||||
>
|
||||
{item.isActive && (
|
||||
<SidebarMenuSubButton asChild>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={item.url}
|
||||
target={item.newWindow ? "_blank" : "_self"}
|
||||
target={
|
||||
item.newWindow
|
||||
? "_blank"
|
||||
: "_self"
|
||||
}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
<span>
|
||||
{item.title}
|
||||
</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
)}
|
||||
|
||||
64
frontend/src/components/ui/accordion.tsx
Normal file
64
frontend/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
10
frontend/src/routes/_admin/users.tsx
Normal file
10
frontend/src/routes/_admin/users.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import UserPage from "@/components/admin/user/UserPage";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_admin/users")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <UserPage />;
|
||||
}
|
||||
13
frontend/src/utils/formStuff/options/userformOptions.tsx
Normal file
13
frontend/src/utils/formStuff/options/userformOptions.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { formOptions } from "@tanstack/react-form";
|
||||
|
||||
export const userFormOptions = (user: any) => {
|
||||
return formOptions({
|
||||
defaultValues: {
|
||||
username: user.username,
|
||||
password: "",
|
||||
email: user.email,
|
||||
//hobbies: [],
|
||||
},
|
||||
// } as Person,
|
||||
});
|
||||
};
|
||||
27
frontend/src/utils/passwordGen.ts
Normal file
27
frontend/src/utils/passwordGen.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const generatePassword = (length: number) => {
|
||||
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const lowercase = "abcdefghijklmnopqrstuvwxyz";
|
||||
const numbers = "0123456789";
|
||||
const symbols = "!@#$%&()_+-={}:,.<>?/"; // Safe symbol list
|
||||
|
||||
// Ensure the password contains at least one of each required type
|
||||
let password: any = [
|
||||
uppercase[Math.floor(Math.random() * uppercase.length)],
|
||||
lowercase[Math.floor(Math.random() * lowercase.length)],
|
||||
numbers[Math.floor(Math.random() * numbers.length)],
|
||||
symbols[Math.floor(Math.random() * symbols.length)],
|
||||
];
|
||||
|
||||
// Fill the rest of the password with random characters from all sets
|
||||
const allCharacters = uppercase + lowercase;
|
||||
for (let i = password.length; i < length; i++) {
|
||||
password.push(
|
||||
allCharacters[Math.floor(Math.random() * allCharacters.length)]
|
||||
);
|
||||
}
|
||||
|
||||
// Shuffle the password to avoid predictable patterns
|
||||
password = password.sort(() => Math.random() - 0.5).join("");
|
||||
|
||||
return password;
|
||||
};
|
||||
26
frontend/src/utils/querys/admin/users.tsx
Normal file
26
frontend/src/utils/querys/admin/users.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export function getUsers() {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
return queryOptions({
|
||||
queryKey: ["getUsers"],
|
||||
queryFn: () => fetchUsers(token),
|
||||
enabled: !!token, // Prevents query if token is null
|
||||
staleTime: 1000,
|
||||
//refetchInterval: 2 * 2000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
const fetchUsers = async (token: string | null) => {
|
||||
const { data } = await axios.get(`/api/auth/allusers`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
// if we are not localhost ignore the devDir setting.
|
||||
//const url: string = window.location.host.split(":")[0];
|
||||
return data.data ?? [];
|
||||
};
|
||||
Reference in New Issue
Block a user