diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f419ab --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL="http://localhost:8000" +VITE_APP_KEY="123456" \ No newline at end of file diff --git a/.gitignore b/.gitignore index a547bf3..3b0b403 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7ccd5ca..c233d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index af66e12..d0f9be6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx index b5517e5..5dbc6ea 100644 --- a/src/pages/auth/Login.tsx +++ b/src/pages/auth/Login.tsx @@ -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()} /> */} - diff --git a/src/repositories/authRepository.ts b/src/repositories/authRepository.ts new file mode 100644 index 0000000..334901c --- /dev/null +++ b/src/repositories/authRepository.ts @@ -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 { + const response = await api.post('/auth/login', payload); + return response.data; + }, + + async logout(): Promise { + await api.post('/auth/logout'); + }, +}; \ No newline at end of file diff --git a/src/routes/RequiredAuth.tsx b/src/routes/RequiredAuth.tsx new file mode 100644 index 0000000..bd3b33a --- /dev/null +++ b/src/routes/RequiredAuth.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from "react-router"; +import { useAuthStore } from "../stores/authStore"; + +const RequiredAuth = () => { + const { token } = useAuthStore(); + + return token ? : ; +}; + +export default RequiredAuth; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 31fbf13..80fea02 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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 ( - } /> - }> - } /> - } /> - } /> - } /> - } /> - {/* Auth routes */} }> } /> + + }> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + + + ) } diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..8b0c3f1 --- /dev/null +++ b/src/services/api.ts @@ -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; +}; \ No newline at end of file diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts new file mode 100644 index 0000000..24c5e9c --- /dev/null +++ b/src/stores/authStore.ts @@ -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; +console.log(import.meta.env); + +export const useAuthStore = create()( + 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), + } + ) +); diff --git a/src/stores/storage.ts b/src/stores/storage.ts new file mode 100644 index 0000000..acd2bce --- /dev/null +++ b/src/stores/storage.ts @@ -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); + }, +}; diff --git a/src/stores/types.ts b/src/stores/types.ts new file mode 100644 index 0000000..1586263 --- /dev/null +++ b/src/stores/types.ts @@ -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; + logout: () => Promise; +} + +export interface AuthErrorInputState { + username?: string | null; + password?: string | null; +} diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts new file mode 100644 index 0000000..a286e24 --- /dev/null +++ b/src/types/auth.types.ts @@ -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; +} diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 0000000..c3e1cd7 --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_KEY: string + readonly VITE_API_URL: string + // more env variables... + } + + interface ImportMeta { + readonly env: ImportMetaEnv + } \ No newline at end of file