feat(frontend): finished login form with validation
This commit is contained in:
@@ -11,28 +11,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@antfu/ni": "^23.3.1",
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
"@tanstack/react-router": "^1.106.0",
|
||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-grid-layout": "^1.5.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"shadcn": "^2.4.0-canary.6",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
18
frontend/src/components/extendedUI/LstCard.tsx
Normal file
18
frontend/src/components/extendedUI/LstCard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import {ReactNode} from "react";
|
||||
import {Card} from "../ui/card";
|
||||
|
||||
interface LstCardProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function LstCard({children, className = "", style = {}}: LstCardProps) {
|
||||
return (
|
||||
<div className="m-auto">
|
||||
<Card className={`border-solid border-2 border-[#00659c] ${className}`} style={style}>
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,91 +6,17 @@ import {QualitySideBar} from "./side-components/quality";
|
||||
import {ForkliftSideBar} from "./side-components/forklift";
|
||||
import {EomSideBar} from "./side-components/eom";
|
||||
import {AdminSideBar} from "./side-components/admin";
|
||||
|
||||
type Feature = string;
|
||||
|
||||
interface Module {
|
||||
id: number;
|
||||
name: string;
|
||||
features: Feature[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface RolePermissions {
|
||||
[moduleName: string]: Feature[];
|
||||
}
|
||||
|
||||
interface Roles {
|
||||
[roleName: string]: RolePermissions;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: keyof Roles;
|
||||
}
|
||||
const modules: Module[] = [
|
||||
{id: 1, name: "production", active: true, features: ["view", "edit", "approve"]},
|
||||
{id: 2, name: "logistics", active: true, features: ["view", "assign", "track"]},
|
||||
{id: 3, name: "quality", active: false, features: ["view", "audit", "approve"]},
|
||||
{id: 4, name: "forklift", active: true, features: ["view", "request", "operate"]},
|
||||
{id: 5, name: "admin", active: true, features: ["view", "manage_users", "view_logs", "settings"]},
|
||||
];
|
||||
|
||||
const rolePermissions: Roles = {
|
||||
admin: {
|
||||
production: ["view", "edit", "approve"],
|
||||
logistics: ["view", "assign", "track"],
|
||||
quality: ["view", "audit", "approve"],
|
||||
forklift: ["view", "request", "operate"],
|
||||
admin: ["view", "manage_users", "view_logs", "settings"],
|
||||
},
|
||||
manager: {
|
||||
production: ["view", "edit"],
|
||||
logistics: ["view", "assign"],
|
||||
quality: ["view", "audit"],
|
||||
forklift: ["view", "request"],
|
||||
admin: ["view_logs"],
|
||||
},
|
||||
supervisor: {
|
||||
production: ["view"],
|
||||
logistics: ["view"],
|
||||
quality: ["view"],
|
||||
forklift: ["view", "request"],
|
||||
admin: [],
|
||||
},
|
||||
user: {
|
||||
production: ["view"],
|
||||
logistics: [],
|
||||
quality: [],
|
||||
forklift: [],
|
||||
admin: [],
|
||||
},
|
||||
};
|
||||
|
||||
// const users: User[] = [
|
||||
// {id: 1, username: "admin", role: "admin"},
|
||||
// {id: 2, username: "manager", role: "manager"},
|
||||
// {id: 3, username: "supervisor", role: "supervisor"},
|
||||
// {id: 4, username: "user", role: "user"},
|
||||
// ];
|
||||
|
||||
function hasAccess(user: User, moduleName: string, feature: Feature): boolean {
|
||||
return rolePermissions[user.role]?.[moduleName]?.includes(feature) || false;
|
||||
}
|
||||
|
||||
function moduleActive(moduleName: string): boolean {
|
||||
const module = modules.find((m) => m.name === moduleName);
|
||||
return module ? module.active : false;
|
||||
}
|
||||
import {useSessionStore} from "../../lib/store/sessionStore";
|
||||
import {hasAccess} from "../../utils/userAccess";
|
||||
import {moduleActive} from "../../utils/moduleActive";
|
||||
|
||||
export function AppSidebar() {
|
||||
const user = {id: 4, username: "admin", role: "admin"};
|
||||
const {user} = useSessionStore();
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarContent>
|
||||
<Header />
|
||||
{hasAccess(user, "production", "view") && moduleActive("production") && <ProductionSideBar />}
|
||||
{moduleActive("production") && <ProductionSideBar />}
|
||||
{hasAccess(user, "logistics", "view") && moduleActive("logistics") && <LogisticsSideBar />}
|
||||
{hasAccess(user, "forklift", "view") && moduleActive("forklift") && <ForkliftSideBar />}
|
||||
{hasAccess(user, "eom", "view") && moduleActive("admin") && <EomSideBar />}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Header() {
|
||||
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">Logistics Support Tool</span>
|
||||
<span className="">v1.0.0</span>
|
||||
<span className="">v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -1,51 +1,36 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {cn} from "../../lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
function Avatar({className, ...props}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
function AvatarImage({className, ...props}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
function AvatarFallback({className, ...props}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
className={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
export {Avatar, AvatarImage, AvatarFallback};
|
||||
|
||||
68
frontend/src/components/ui/card.tsx
Normal file
68
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 px-6 pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 pb-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
30
frontend/src/components/ui/checkbox.tsx
Normal file
30
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as React from "react"
|
||||
|
||||
import {cn} from "../../lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -8,12 +8,14 @@ function Input({className, type, ...props}: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4",
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {Input};
|
||||
export { Input }
|
||||
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
27
frontend/src/components/ui/sonner.tsx
Normal file
27
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -1,16 +1,14 @@
|
||||
import {useRouter} from "@tanstack/react-router";
|
||||
import {useSessionStore} from "../store/sessionStore";
|
||||
import {useQueryClient} from "@tanstack/react-query";
|
||||
|
||||
export const useLogout = () => {
|
||||
const {clearSession} = useSessionStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const logout = async () => {
|
||||
router.invalidate();
|
||||
router.clearCache();
|
||||
await clearSession();
|
||||
queryClient.invalidateQueries();
|
||||
clearSession();
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
@@ -9,28 +9,31 @@ type User = {
|
||||
export type SessionState = {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
setSession: (user: SessionState["user"], token: string) => void;
|
||||
setSession: (user: User | null, token: string | null) => void;
|
||||
clearSession: () => void;
|
||||
};
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => {
|
||||
// Initialize from localStorage
|
||||
const storedUser = localStorage.getItem("user");
|
||||
// Initialize token from localStorage, but user remains in memory only
|
||||
const storedToken = localStorage.getItem("auth_token");
|
||||
|
||||
return {
|
||||
user: storedUser ? JSON.parse(storedUser) : null,
|
||||
user: null, // User is NOT stored in localStorage
|
||||
token: storedToken || null,
|
||||
|
||||
setSession: (user, token) => {
|
||||
if (token) {
|
||||
localStorage.setItem("auth_token", token);
|
||||
//localStorage.setItem("user", JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem("auth_token");
|
||||
}
|
||||
|
||||
console.log("Setting session:", {user, token});
|
||||
set({user, token});
|
||||
},
|
||||
|
||||
clearSession: () => {
|
||||
localStorage.removeItem("auth_token");
|
||||
//localStorage.removeItem("user");
|
||||
set({user: null, token: null});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {StrictMode} from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./styles.css";
|
||||
|
||||
import {SessionProvider} from "./components/providers/Providers.tsx";
|
||||
import {RouterProvider, createRouter} from "@tanstack/react-router";
|
||||
|
||||
// Import the generated route tree
|
||||
import {routeTree} from "./routeTree.gen";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient();
|
||||
// Create a new router instance
|
||||
const router = createRouter({routeTree});
|
||||
|
||||
@@ -24,9 +25,9 @@ if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as LoginImport } from './routes/login'
|
||||
import { Route as AboutImport } from './routes/about'
|
||||
import { Route as AuthImport } from './routes/_auth'
|
||||
import { Route as AdminImport } from './routes/_admin'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as OcpIndexImport } from './routes/ocp/index'
|
||||
import { Route as OcpLotsImport } from './routes/ocp/lots'
|
||||
@@ -38,6 +39,11 @@ const AuthRoute = AuthImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const AdminRoute = AdminImport.update({
|
||||
id: '/_admin',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -73,6 +79,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_admin': {
|
||||
id: '/_admin'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof AdminImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
@@ -153,6 +166,7 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/_admin': typeof AdminRoute
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
@@ -169,6 +183,7 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_admin'
|
||||
| '/_auth'
|
||||
| '/about'
|
||||
| '/login'
|
||||
@@ -180,6 +195,7 @@ export interface FileRouteTypes {
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
AboutRoute: typeof AboutRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
@@ -189,6 +205,7 @@ export interface RootRouteChildren {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
AboutRoute: AboutRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
@@ -207,6 +224,7 @@ export const routeTree = rootRoute
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/_admin",
|
||||
"/_auth",
|
||||
"/about",
|
||||
"/login",
|
||||
@@ -217,6 +235,9 @@ export const routeTree = rootRoute
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/_admin": {
|
||||
"filePath": "_admin.tsx"
|
||||
},
|
||||
"/_auth": {
|
||||
"filePath": "_auth.tsx",
|
||||
"children": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {createRootRoute, Outlet} from "@tanstack/react-router";
|
||||
import {createRootRoute, Link, Outlet} from "@tanstack/react-router";
|
||||
import {TanStackRouterDevtools} from "@tanstack/router-devtools";
|
||||
import Cookies from "js-cookie";
|
||||
import {SidebarProvider} from "../components/ui/sidebar";
|
||||
@@ -14,14 +14,24 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../components/ui/dropdown-menu";
|
||||
import {SessionProvider} from "../components/providers/Providers";
|
||||
import {Toaster} from "sonner";
|
||||
import {Button} from "../components/ui/button";
|
||||
import {useLogout} from "../lib/hooks/useLogout";
|
||||
import {useSession} from "../lib/hooks/useSession";
|
||||
import {useSessionStore} from "../lib/store/sessionStore";
|
||||
|
||||
// same as the layout
|
||||
export const Route = createRootRoute({
|
||||
component: () => {
|
||||
const sidebarState = Cookies.get("sidebar_state") === "true";
|
||||
const {session} = useSession();
|
||||
const {user} = useSessionStore();
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionProvider>
|
||||
<ThemeProvider>
|
||||
<nav className="flex justify-end">
|
||||
<div className="m-2 flex flex-row">
|
||||
@@ -31,6 +41,7 @@ export const Route = createRootRoute({
|
||||
<div className="m-1">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
{session ? (
|
||||
<div className="m-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -40,22 +51,37 @@ export const Route = createRootRoute({
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>Hello {user?.username}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuItem>Subscription</DropdownMenuItem>
|
||||
<hr className="solid"></hr>
|
||||
<DropdownMenuItem>
|
||||
<div className="m-auto mt-3">
|
||||
<Button onClick={() => logout()} variant="ghost">
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login">Login</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<SidebarProvider defaultOpen={sidebarState}>
|
||||
<AppSidebar />
|
||||
<Toaster expand={true} richColors closeButton />
|
||||
<Outlet />
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</>
|
||||
|
||||
13
frontend/src/routes/_admin.tsx
Normal file
13
frontend/src/routes/_admin.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import {createFileRoute, redirect} from "@tanstack/react-router";
|
||||
|
||||
// src/routes/_authenticated.tsx
|
||||
export const Route = createFileRoute("/_admin")({
|
||||
beforeLoad: async () => {
|
||||
const auth = localStorage.getItem("auth_token");
|
||||
if (!auth) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export const Route = createFileRoute("/_auth")({
|
||||
const auth = localStorage.getItem("auth_token");
|
||||
if (!auth) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
to: "/login",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,38 +1,143 @@
|
||||
import {createFileRoute, useRouter} from "@tanstack/react-router";
|
||||
import {isAuthenticated, signIn, signOut} from "../utils/auth";
|
||||
import {createFileRoute, redirect, useRouter} from "@tanstack/react-router";
|
||||
import {useForm, Controller} from "react-hook-form";
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import {LstCard} from "../components/extendedUI/LstCard";
|
||||
import {Button} from "../components/ui/button";
|
||||
import {Input} from "../components/ui/input";
|
||||
import {CardHeader} from "../components/ui/card";
|
||||
import {Label} from "../components/ui/label";
|
||||
import {toast} from "sonner";
|
||||
import {useSessionStore} from "../lib/store/sessionStore";
|
||||
import {Checkbox} from "../components/ui/checkbox";
|
||||
import {z} from "zod";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: () => {
|
||||
const isLoggedIn = localStorage.getItem("auth_token");
|
||||
if (isLoggedIn) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const FormSchema = z.object({
|
||||
username: z.string().min(1, "You must enter a valid username"),
|
||||
password: z.string().min(4, "You must enter a valid password"),
|
||||
rememberMe: z.boolean(),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const {setSession} = useSessionStore();
|
||||
const rememeberMe = localStorage.getItem("rememberMe") === "true";
|
||||
const username = localStorage.getItem("username") || "";
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: {errors},
|
||||
} = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
username: username || "",
|
||||
password: "",
|
||||
rememberMe: rememeberMe,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmitLogin = async (value: z.infer<typeof FormSchema>) => {
|
||||
// Do something with form data
|
||||
|
||||
// first update the rememberMe incase it was selected
|
||||
if (value.rememberMe) {
|
||||
localStorage.setItem("rememberMe", value.rememberMe.toString());
|
||||
localStorage.setItem("username", value.username);
|
||||
} else {
|
||||
localStorage.removeItem("rememberMe");
|
||||
localStorage.removeItem("username");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({username: value.username, password: value.password}),
|
||||
});
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error("Invalid credentials");
|
||||
// }
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store token in localStorage
|
||||
// localStorage.setItem("auth_token", data.data.token);
|
||||
|
||||
setSession(data.data.user, data.data.token);
|
||||
toast.success(`You are logged in as ${data.data.user.username}`);
|
||||
router.navigate({to: "/"});
|
||||
} catch (err) {
|
||||
toast.error("Invalid credentials");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="ml-[25%]">
|
||||
<LstCard className="p-3 w-96">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<p className="text-2xl">Login to LST</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<hr className="rounded"></hr>
|
||||
<form onSubmit={handleSubmit(onSubmitLogin)}>
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
placeholder="smith001"
|
||||
{...register("username")}
|
||||
className={errors.username ? "border-red-500" : ""}
|
||||
aria-invalid={!!errors.username}
|
||||
/>
|
||||
{errors.username && <p className="text-red-500 text-sm mt-1">{errors.username.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Ligin</h2>
|
||||
{isAuthenticated() ? (
|
||||
<>
|
||||
<p>Hello User!</p>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
signOut();
|
||||
router.invalidate();
|
||||
}}
|
||||
>
|
||||
signOut
|
||||
</Button>
|
||||
<Label htmlFor={"password"}>Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password")}
|
||||
className={errors.password ? "border-red-500" : ""}
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
signIn();
|
||||
router.invalidate();
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<div className="flex">
|
||||
<Controller
|
||||
render={({field}) => (
|
||||
<Checkbox id="remember" checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
control={control}
|
||||
name="rememberMe"
|
||||
defaultValue={rememeberMe}
|
||||
/>
|
||||
<Label htmlFor="remember" className="pl-2">
|
||||
remember me
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">Submit</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</LstCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
20
frontend/src/utils/moduleActive.ts
Normal file
20
frontend/src/utils/moduleActive.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type Feature = string;
|
||||
interface Module {
|
||||
id: number;
|
||||
name: string;
|
||||
features: Feature[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const modules: Module[] = [
|
||||
{id: 1, name: "production", active: true, features: ["view", "edit", "approve"]},
|
||||
{id: 2, name: "logistics", active: true, features: ["view", "assign", "track"]},
|
||||
{id: 3, name: "quality", active: false, features: ["view", "audit", "approve"]},
|
||||
{id: 4, name: "forklift", active: true, features: ["view", "request", "operate"]},
|
||||
{id: 5, name: "admin", active: true, features: ["view", "manage_users", "view_logs", "settings"]},
|
||||
];
|
||||
|
||||
export function moduleActive(moduleName: string): boolean {
|
||||
const module = modules.find((m) => m.name === moduleName);
|
||||
return module ? module.active : false;
|
||||
}
|
||||
52
frontend/src/utils/userAccess.ts
Normal file
52
frontend/src/utils/userAccess.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: keyof Roles;
|
||||
}
|
||||
|
||||
interface Roles {
|
||||
[roleName: string]: RolePermissions;
|
||||
}
|
||||
|
||||
interface RolePermissions {
|
||||
[moduleName: string]: Feature[];
|
||||
}
|
||||
|
||||
type Feature = string;
|
||||
|
||||
const rolePermissions: Roles = {
|
||||
admin: {
|
||||
production: ["view", "manage", "update", "admin"],
|
||||
logistics: ["view", "manage", "update", "admin"],
|
||||
quality: ["view", "request", "manage", "update", "admin"],
|
||||
forklift: ["view", "manage", "update", "admin"],
|
||||
admin: ["view", "view_logs", "manage", "update", "admin"],
|
||||
},
|
||||
manager: {
|
||||
production: ["view", "manage"],
|
||||
logistics: ["view", "manage"],
|
||||
quality: ["view", "manage"],
|
||||
forklift: ["view", "manage"],
|
||||
admin: ["view_logs"],
|
||||
},
|
||||
supervisor: {
|
||||
production: ["view", "update"],
|
||||
logistics: ["view", "update"],
|
||||
quality: ["view", "update"],
|
||||
forklift: ["view"],
|
||||
admin: [],
|
||||
},
|
||||
user: {
|
||||
production: ["view"],
|
||||
logistics: ["view"],
|
||||
quality: ["view"],
|
||||
forklift: [],
|
||||
admin: [],
|
||||
},
|
||||
};
|
||||
|
||||
// user will need access to the module.
|
||||
// users role will determine there visual access
|
||||
export function hasAccess(user: User | null, moduleName: string, feature: Feature): boolean {
|
||||
return user?.role ? rolePermissions[user.role]?.[moduleName]?.includes(feature) || false : false;
|
||||
}
|
||||
@@ -4,18 +4,18 @@
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"dev": "dotenvx run -- bun run -r --parallel --aggregate-output dev",
|
||||
"dev:all": "concurrently -n 'server,frontend' -c '#007755,#2f6da3' 'cd server && bun run dev' 'cd frontend && bun run dev'",
|
||||
"dev": "concurrently -n 'server,frontend' -c '#007755,#2f6da3' 'cd server && bun run dev' 'cd frontend && bun run dev'",
|
||||
"dev:server": "bun --env-file .env --watch server/index.ts",
|
||||
"dev:ocme": "bun --env-file .env --watch ocme/index.ts",
|
||||
"dev:frontend": "cd frontend && bunx --bun vite",
|
||||
"build:server": "cd apps/server && bun build index.js --outdir ../../dist/server",
|
||||
"build:ocme": "rimraf dist/ocme && cd apps/ocme && bun build index.js --outdir ../../dist/ocme",
|
||||
"build:front": "cd frontend && rimraf frontend/dist && bun run build",
|
||||
"start": "cd server && bun run --env-file ../.env ./index.js",
|
||||
"start": "bun --env-file .env server/index.js",
|
||||
"commit": "cz",
|
||||
"clean": "rimraf dist/server",
|
||||
"deploy": "standard-version --conventional-commits"
|
||||
"deploy": "standard-version --conventional-commits",
|
||||
"ui:add": "cd frontend && bun shadcn add "
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -10,7 +10,6 @@ import scalar from "./route/scalar";
|
||||
// services
|
||||
import {ocmeService} from "./services/ocme/ocmeServer";
|
||||
|
||||
console.log(process.env.JWT_SECRET);
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
app.use("*", logger());
|
||||
@@ -52,8 +51,8 @@ routes.forEach((route) => {
|
||||
// return c.json({success: true, message: "is authenticated"});
|
||||
// });
|
||||
|
||||
app.get("*", serveStatic({root: "../frontend/dist"}));
|
||||
app.get("*", serveStatic({path: "../frontend/dist/index.html"}));
|
||||
app.get("*", serveStatic({root: "./frontend/dist"}));
|
||||
app.get("*", serveStatic({path: "./frontend/dist/index.html"}));
|
||||
|
||||
export default app;
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ const route = createRoute({
|
||||
|
||||
app.openapi(route, async (c) => {
|
||||
let body: {username: string; password: string};
|
||||
|
||||
console.log(`Trying to login`);
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "index.ts"]
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
"allowJs": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
}
|
||||
],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./server/src", "./server/index.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user