feat: add zustand for state management & implement for auth
This commit is contained in:
parent
e62e678fba
commit
77c4b0cb1a
|
|
@ -0,0 +1,2 @@
|
|||
VITE_API_URL="http://localhost:8000"
|
||||
VITE_APP_KEY="123456"
|
||||
|
|
@ -22,3 +22,5 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -8,10 +8,18 @@ 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>
|
||||
|
||||
{/* Auth routes */}
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path="login" element={<Login />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<RequiredAuth />}>
|
||||
<Route path="/" element={<App />} />
|
||||
|
||||
<Route element={<DashboardLayout />}>
|
||||
|
|
@ -21,9 +29,7 @@ export default function Routes() {
|
|||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/towers" element={<Towers />} />
|
||||
</Route>
|
||||
{/* Auth routes */}
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path="login" element={<Login />} />
|
||||
|
||||
</Route>
|
||||
</RRRoutes>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue