feat: add zustand for state management & implement for auth

This commit is contained in:
HasanMu 2025-02-12 18:23:50 +07:00
parent e62e678fba
commit 77c4b0cb1a
14 changed files with 352 additions and 13 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL="http://localhost:8000"
VITE_APP_KEY="123456"

2
.gitignore vendored
View File

@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

128
package-lock.json generated
View File

@ -14,12 +14,15 @@
"@mui/icons-material": "^6.4.3",
"@mui/material": "^6.4.3",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"iconsax-react": "^0.0.8",
"lodash": "^4.17.21",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.5",
"react-svg": "^16.3.0"
"react-svg": "^16.3.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
@ -2041,6 +2044,21 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
@ -2185,6 +2203,17 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2250,6 +2279,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -2277,6 +2311,14 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@ -2646,6 +2688,38 @@
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2997,6 +3071,25 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -3227,6 +3320,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3788,6 +3886,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -16,12 +16,15 @@
"@mui/icons-material": "^6.4.3",
"@mui/material": "^6.4.3",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"iconsax-react": "^0.0.8",
"lodash": "^4.17.21",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.5",
"react-svg": "^16.3.0"
"react-svg": "^16.3.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.19.0",

View File

@ -8,14 +8,24 @@ import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link";
import Grid from "@mui/material/Grid2";
import { useNavigate } from "react-router";
import { useAuthStore } from "../../stores/authStore";
export default function Login() {
let navigate = useNavigate();
const onSubmit = (e: any) => {
const { login, token, isLoading, error } = useAuthStore();
const onSubmit = async (e: any) => {
e.preventDefault();
navigate("/dashboard");
await login({
username: e.target.username.value,
password: e.target.password.value,
});
if (token) {
navigate("/dashboard");
}
};
return (
@ -34,6 +44,8 @@ export default function Login() {
name="username"
autoComplete="username"
autoFocus
error={!!error}
helperText={error?.toString()}
/>
<TextField
margin="normal"
@ -58,7 +70,7 @@ export default function Login() {
</Link>
</Grid> */}
</Grid>
<Button type="submit" fullWidth variant="contained" sx={{ my: 2 }}>
<Button type="submit" fullWidth variant="contained" sx={{ my: 2 }} disabled={isLoading}>
Sign In
</Button>
</Box>

View File

@ -0,0 +1,15 @@
import { createAPI } from '../services/api';
import { LoginPayload, LoginResponse } from '../types/auth.types';
const api = createAPI();
export const authRepository = {
async login(payload: LoginPayload): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/auth/login', payload);
return response.data;
},
async logout(): Promise<void> {
await api.post('/auth/logout');
},
};

View File

@ -0,0 +1,10 @@
import { Navigate, Outlet } from "react-router";
import { useAuthStore } from "../stores/authStore";
const RequiredAuth = () => {
const { token } = useAuthStore();
return token ? <Outlet /> : <Navigate to="/login" replace />;
};
export default RequiredAuth;

View File

@ -8,23 +8,29 @@ import Backbone from "../pages/Backbone";
import Devices from "../pages/Devices";
import Fishbone from "../pages/Fishbone";
import Towers from "../pages/Towers";
import RequiredAuth from "./RequiredAuth";
export default function Routes() {
return (
<RRRoutes>
<Route path="/" element={<App />} />
<Route element={<DashboardLayout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/backbone" element={<Backbone />} />
<Route path="/fishbone" element={<Fishbone />} />
<Route path="/devices" element={<Devices />} />
<Route path="/towers" element={<Towers />} />
</Route>
{/* Auth routes */}
<Route element={<AuthLayout />}>
<Route path="login" element={<Login />} />
</Route>
<Route element={<RequiredAuth />}>
<Route path="/" element={<App />} />
<Route element={<DashboardLayout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/backbone" element={<Backbone />} />
<Route path="/fishbone" element={<Fishbone />} />
<Route path="/devices" element={<Devices />} />
<Route path="/towers" element={<Towers />} />
</Route>
</Route>
</RRRoutes>
)
}

24
src/services/api.ts Normal file
View File

@ -0,0 +1,24 @@
import axios, { AxiosInstance } from 'axios';
import { useAuthStore } from '../stores/authStore';
export const createAPI = (): AxiosInstance => {
const { VITE_API_URL } = import.meta.env;
const api = axios.create({
baseURL: `${VITE_API_URL}/api/v1`,
headers: {
'Accept': 'application/json',
},
timeout: 30000,
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
return api;
};

66
src/stores/authStore.ts Normal file
View File

@ -0,0 +1,66 @@
import { create } from 'zustand';
import { authRepository } from '../repositories/authRepository';
import { AuthState } from './types';
import { persist } from 'zustand/middleware';
import { createJSONStorage } from 'zustand/middleware';
import { secureStorage } from './storage';
type PersistedAuthState = Pick<AuthState, 'token' | 'user'>;
console.log(import.meta.env);
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
token: null,
user: null,
isLoading: false,
error: null,
login: async (payload) => {
set({ isLoading: true, error: null });
try {
const response = await authRepository.login(payload);
if (response.data?.token && response.data) {
set({ token: response.data.token, user: response.data.user });
return true;
} else {
set({ error: response.status.description || 'Login failed' });
return false;
}
} catch (error: any) {
if (error.status === 401) {
set({ error: error.response.status.description });
} else {
set({ error: error.message });
}
return false;
} finally {
set({ isLoading: false });
}
},
logout: async () => {
set({ isLoading: true, error: null });
try {
await authRepository.logout();
// Clear persisted state
} catch (error: any) {
} finally {
set({ token: null, user: null, error: null, isLoading: false });
}
}
}),
{
name: 'session',
storage: createJSONStorage(() => secureStorage),
partialize: (state) => ({
token: state.token,
user: state.user,
} as PersistedAuthState),
}
)
);

28
src/stores/storage.ts Normal file
View File

@ -0,0 +1,28 @@
import CryptoJS from "crypto-js";
import { StateStorage } from "zustand/middleware";
export const secureStorage: StateStorage = {
getItem: (name: string): string | null => {
const data = localStorage.getItem(name);
if (!data) return null;
try {
const decrypted = CryptoJS.AES.decrypt(
data,
import.meta.env.VITE_APP_KEY
).toString(CryptoJS.enc.Utf8);
return decrypted || null;
} catch {
return null;
}
},
setItem: (name: string, value: string): void => {
const encrypted = CryptoJS.AES.encrypt(
value,
import.meta.env.VITE_APP_KEY
).toString();
localStorage.setItem(name, encrypted);
},
removeItem: (name: string): void => {
localStorage.removeItem(name);
},
};

15
src/stores/types.ts Normal file
View File

@ -0,0 +1,15 @@
import { LoginPayload, UserProfile } from "../types/auth.types";
export interface AuthState {
token: string | null;
user: UserProfile | null;
isLoading: boolean;
error: string | null | AuthErrorInputState;
login: (payload: LoginPayload) => Promise<boolean>;
logout: () => Promise<void>;
}
export interface AuthErrorInputState {
username?: string | null;
password?: string | null;
}

19
src/types/auth.types.ts Normal file
View File

@ -0,0 +1,19 @@
export interface LoginPayload {
username: string;
password: string;
}
export interface LoginResponse {
status: {
code: number;
description: string;
},
data?: {
token: string;
user: UserProfile;
}
}
export interface UserProfile {
name: string;
}

11
vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_KEY: string
readonly VITE_API_URL: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}