mirror of
				https://github.com/khairul169/home-lab.git
				synced 2025-10-31 03:39:33 +07:00 
			
		
		
		
	feat: add authentication
This commit is contained in:
		
							parent
							
								
									ed5c715429
								
							
						
					
					
						commit
						1e8252d9da
					
				
							
								
								
									
										5
									
								
								backend/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								backend/.env.example
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| # | ||||
| JWT_SECRET= | ||||
| 
 | ||||
| AUTH_USERNAME= | ||||
| AUTH_PASSWORD= | ||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										118
									
								
								backend/index.ts
									
									
									
									
									
								
							
							
						
						
									
										118
									
								
								backend/index.ts
									
									
									
									
									
								
							| @ -1,118 +1,18 @@ | ||||
| import { Hono } from "hono"; | ||||
| import { cors } from "hono/cors"; | ||||
| import { serveStatic } from "hono/bun"; | ||||
| import { zValidator } from "@hono/zod-validator"; | ||||
| import z from "zod"; | ||||
| import si from "systeminformation"; | ||||
| 
 | ||||
| const formatBytes = (bytes: number) => { | ||||
|   const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; | ||||
|   if (bytes === 0) return "n/a"; | ||||
|   const i = parseInt(String(Math.floor(Math.log(bytes) / Math.log(1024))), 10); | ||||
|   if (i === 0) return `${bytes} ${sizes[i]}`; | ||||
|   return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`; | ||||
| }; | ||||
| 
 | ||||
| const secondsToTime = (seconds: number) => { | ||||
|   const d = Math.floor(seconds / (3600 * 24)); | ||||
|   const h = Math.floor((seconds % (3600 * 24)) / 3600); | ||||
|   const m = Math.floor((seconds % 3600) / 60); | ||||
|   // const s = Math.floor(seconds % 60);
 | ||||
|   return `${d}d ${h}h ${m}m`; | ||||
| }; | ||||
| import { HTTPException } from "hono/http-exception"; | ||||
| import routes from "./routes/_routes"; | ||||
| 
 | ||||
| const app = new Hono() | ||||
|   .use(cors()) | ||||
| 
 | ||||
|   .get("/system", async (c) => { | ||||
|     const date = new Date().toISOString(); | ||||
|     const uptime = secondsToTime(si.time().uptime || 0); | ||||
|     const system = await si.system(); | ||||
| 
 | ||||
|     const cpuSpeed = await si.cpuCurrentSpeed(); | ||||
|     const cpuTemp = await si.cpuTemperature(); | ||||
|     const cpuLoad = await si.currentLoad(); | ||||
|     const mem = await si.mem(); | ||||
| 
 | ||||
|     const perf = { | ||||
|       cpu: { | ||||
|         load: cpuLoad.currentLoad, | ||||
|         speed: cpuSpeed.avg, | ||||
|         temp: cpuTemp.main, | ||||
|       }, | ||||
|       mem: { | ||||
|         total: formatBytes(mem.total), | ||||
|         percent: (mem.active / mem.total) * 100, | ||||
|         used: formatBytes(mem.active), | ||||
|         free: formatBytes(mem.total - mem.active), | ||||
|       }, | ||||
|     }; | ||||
| 
 | ||||
|     const fsMounts = await si.fsSize(); | ||||
|     const storage = fsMounts | ||||
|       .filter( | ||||
|         (i) => | ||||
|           i.size > 32 * 1024 * 1024 * 1024 && | ||||
|           !i.mount.startsWith("/var/lib/docker") | ||||
|       ) | ||||
|       .map((i) => ({ | ||||
|         type: i.type, | ||||
|         mount: i.mount, | ||||
|         used: formatBytes(i.used), | ||||
|         percent: (i.used / i.size) * 100, | ||||
|         total: formatBytes(i.size), | ||||
|         free: formatBytes(i.available), | ||||
|       })); | ||||
| 
 | ||||
|     return c.json({ uptime, date, system, perf, storage }); | ||||
|   }) | ||||
| 
 | ||||
|   .get( | ||||
|     "/process", | ||||
|     zValidator( | ||||
|       "query", | ||||
|       z | ||||
|         .object({ sort: z.enum(["cpu", "mem"]), limit: z.coerce.number() }) | ||||
|         .partial() | ||||
|         .optional() | ||||
|     ), | ||||
|     async (c) => { | ||||
|       const memTotal = (await si.mem()).total; | ||||
|       const sort = c.req.query("sort") || "mem"; | ||||
|       const limit = parseInt(c.req.query("limit") || "") || 10; | ||||
| 
 | ||||
|       let processList = (await si.processes()).list; | ||||
| 
 | ||||
|       if (sort) { | ||||
|         switch (sort) { | ||||
|           case "cpu": | ||||
|             processList = processList.sort((a, b) => b.cpu - a.cpu); | ||||
|             break; | ||||
|           case "mem": | ||||
|             processList = processList.sort((a, b) => b.mem - a.mem); | ||||
|             break; | ||||
|   .use("*", serveStatic({ root: "./public" })) | ||||
|   .route("/", routes) | ||||
|   .onError((err, c) => { | ||||
|     if (err instanceof HTTPException) { | ||||
|       return err.getResponse(); | ||||
|     } | ||||
|       } | ||||
| 
 | ||||
|       const list = processList | ||||
|         .map((p) => ({ | ||||
|           name: p.name, | ||||
|           cmd: [p.name, p.params].filter(Boolean).join(" "), | ||||
|           cpu: p.cpu, | ||||
|           cpuPercent: p.cpu.toFixed(1) + "%", | ||||
|           mem: p.mem, | ||||
|           memUsage: formatBytes((p.mem / 100) * memTotal), | ||||
|           path: p.path, | ||||
|           user: p.user, | ||||
|         })) | ||||
|         .slice(0, limit); | ||||
| 
 | ||||
|       return c.json({ list }); | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   .use("*", serveStatic({ root: "./public" })); | ||||
| 
 | ||||
| export type AppType = typeof app; | ||||
|     return c.json({ message: err.message }, 500); | ||||
|   }); | ||||
| 
 | ||||
| export default app; | ||||
|  | ||||
							
								
								
									
										14
									
								
								backend/lib/jwt.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								backend/lib/jwt.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| import * as jwt from "hono/jwt"; | ||||
| 
 | ||||
| const JWT_SECRET = | ||||
|   process.env.JWT_SECRET || "75396f4ba17e0012d4511b8d4a5bae11c51008a3"; | ||||
| 
 | ||||
| export const generateToken = async (data: any) => { | ||||
|   return jwt.sign(data, JWT_SECRET); | ||||
| }; | ||||
| 
 | ||||
| export const verifyToken = async (token: string) => { | ||||
|   return jwt.verify(token, JWT_SECRET); | ||||
| }; | ||||
| 
 | ||||
| export const authMiddleware = jwt.jwt({ secret: JWT_SECRET }); | ||||
							
								
								
									
										15
									
								
								backend/lib/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/lib/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| export const formatBytes = (bytes: number) => { | ||||
|   const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; | ||||
|   if (bytes === 0) return "n/a"; | ||||
|   const i = parseInt(String(Math.floor(Math.log(bytes) / Math.log(1024))), 10); | ||||
|   if (i === 0) return `${bytes} ${sizes[i]}`; | ||||
|   return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`; | ||||
| }; | ||||
| 
 | ||||
| export const secondsToTime = (seconds: number) => { | ||||
|   const d = Math.floor(seconds / (3600 * 24)); | ||||
|   const h = Math.floor((seconds % (3600 * 24)) / 3600); | ||||
|   const m = Math.floor((seconds % 3600) / 60); | ||||
|   // const s = Math.floor(seconds % 60);
 | ||||
|   return `${d}d ${h}h ${m}m`; | ||||
| }; | ||||
| @ -7,7 +7,8 @@ | ||||
|     "start": "bun run index.ts" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/bun": "latest" | ||||
|     "@types/bun": "latest", | ||||
|     "@types/jsonwebtoken": "^9.0.6" | ||||
|   }, | ||||
|   "peerDependencies": { | ||||
|     "typescript": "^5.0.0" | ||||
| @ -15,6 +16,7 @@ | ||||
|   "dependencies": { | ||||
|     "@hono/zod-validator": "^0.2.0", | ||||
|     "hono": "^4.1.0", | ||||
|     "nanoid": "^5.0.6", | ||||
|     "systeminformation": "^5.22.2", | ||||
|     "zod": "^3.22.4" | ||||
|   } | ||||
|  | ||||
							
								
								
									
										15
									
								
								backend/routes/_routes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/routes/_routes.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| import { Hono } from "hono"; | ||||
| import auth from "./auth"; | ||||
| import system from "./system"; | ||||
| import _process from "./process"; | ||||
| import { authMiddleware } from "../lib/jwt"; | ||||
| 
 | ||||
| const routes = new Hono() | ||||
|   .route("/auth", auth) | ||||
|   .use(authMiddleware) | ||||
|   .route("/system", system) | ||||
|   .route("/process", _process); | ||||
| 
 | ||||
| export type AppType = typeof routes; | ||||
| 
 | ||||
| export default routes; | ||||
							
								
								
									
										34
									
								
								backend/routes/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								backend/routes/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | ||||
| import { zValidator } from "@hono/zod-validator"; | ||||
| import { Hono } from "hono"; | ||||
| import { HTTPException } from "hono/http-exception"; | ||||
| import { z } from "zod"; | ||||
| import { generateToken } from "../lib/jwt"; | ||||
| import { nanoid } from "nanoid"; | ||||
| 
 | ||||
| const loginSchema = z.object({ | ||||
|   username: z.string().min(1), | ||||
|   password: z.string().min(1), | ||||
| }); | ||||
| type LoginSchema = z.infer<typeof loginSchema>; | ||||
| 
 | ||||
| const route = new Hono().post( | ||||
|   "/login", | ||||
|   zValidator("json", loginSchema), | ||||
|   async (c) => { | ||||
|     const input: LoginSchema = await c.req.json(); | ||||
|     const { AUTH_USERNAME, AUTH_PASSWORD } = process.env; | ||||
| 
 | ||||
|     if (input.username !== AUTH_USERNAME || input.password !== AUTH_PASSWORD) { | ||||
|       throw new HTTPException(400, { | ||||
|         message: "Username or password is invalid!", | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     const data = { sessionId: nanoid() }; | ||||
|     const token = await generateToken(data); | ||||
| 
 | ||||
|     return c.json({ token, ...data }); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| export default route; | ||||
							
								
								
									
										51
									
								
								backend/routes/process.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								backend/routes/process.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,51 @@ | ||||
| import { Hono } from "hono"; | ||||
| import si from "systeminformation"; | ||||
| import { formatBytes } from "../lib/utils"; | ||||
| import { zValidator } from "@hono/zod-validator"; | ||||
| import { z } from "zod"; | ||||
| 
 | ||||
| const route = new Hono().get( | ||||
|   "/", | ||||
|   zValidator( | ||||
|     "query", | ||||
|     z | ||||
|       .object({ sort: z.enum(["cpu", "mem"]), limit: z.coerce.number() }) | ||||
|       .partial() | ||||
|       .optional() | ||||
|   ), | ||||
|   async (c) => { | ||||
|     const memTotal = (await si.mem()).total; | ||||
|     const sort = c.req.query("sort") || "mem"; | ||||
|     const limit = parseInt(c.req.query("limit") || "") || 10; | ||||
| 
 | ||||
|     let processList = (await si.processes()).list; | ||||
| 
 | ||||
|     if (sort) { | ||||
|       switch (sort) { | ||||
|         case "cpu": | ||||
|           processList = processList.sort((a, b) => b.cpu - a.cpu); | ||||
|           break; | ||||
|         case "mem": | ||||
|           processList = processList.sort((a, b) => b.mem - a.mem); | ||||
|           break; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const list = processList | ||||
|       .map((p) => ({ | ||||
|         name: p.name, | ||||
|         cmd: [p.name, p.params].filter(Boolean).join(" "), | ||||
|         cpu: p.cpu, | ||||
|         cpuPercent: p.cpu.toFixed(1) + "%", | ||||
|         mem: p.mem, | ||||
|         memUsage: formatBytes((p.mem / 100) * memTotal), | ||||
|         path: p.path, | ||||
|         user: p.user, | ||||
|       })) | ||||
|       .slice(0, limit); | ||||
| 
 | ||||
|     return c.json({ list }); | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| export default route; | ||||
							
								
								
									
										48
									
								
								backend/routes/system.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								backend/routes/system.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| import { Hono } from "hono"; | ||||
| import si from "systeminformation"; | ||||
| import { formatBytes, secondsToTime } from "../lib/utils"; | ||||
| 
 | ||||
| const route = new Hono().get("/", async (c) => { | ||||
|   const date = new Date().toISOString(); | ||||
|   const uptime = secondsToTime(si.time().uptime || 0); | ||||
|   const system = await si.system(); | ||||
| 
 | ||||
|   const cpuSpeed = await si.cpuCurrentSpeed(); | ||||
|   const cpuTemp = await si.cpuTemperature(); | ||||
|   const cpuLoad = await si.currentLoad(); | ||||
|   const mem = await si.mem(); | ||||
| 
 | ||||
|   const perf = { | ||||
|     cpu: { | ||||
|       load: cpuLoad.currentLoad, | ||||
|       speed: cpuSpeed.avg, | ||||
|       temp: cpuTemp.main, | ||||
|     }, | ||||
|     mem: { | ||||
|       total: formatBytes(mem.total), | ||||
|       percent: (mem.active / mem.total) * 100, | ||||
|       used: formatBytes(mem.active), | ||||
|       free: formatBytes(mem.total - mem.active), | ||||
|     }, | ||||
|   }; | ||||
| 
 | ||||
|   const fsMounts = await si.fsSize(); | ||||
|   const storage = fsMounts | ||||
|     .filter( | ||||
|       (i) => | ||||
|         i.size > 32 * 1024 * 1024 * 1024 && | ||||
|         !i.mount.startsWith("/var/lib/docker") | ||||
|     ) | ||||
|     .map((i) => ({ | ||||
|       type: i.type, | ||||
|       mount: i.mount, | ||||
|       used: formatBytes(i.used), | ||||
|       percent: (i.used / i.size) * 100, | ||||
|       total: formatBytes(i.size), | ||||
|       free: formatBytes(i.available), | ||||
|     })); | ||||
| 
 | ||||
|   return c.json({ uptime, date, system, perf, storage }); | ||||
| }); | ||||
| 
 | ||||
| export default route; | ||||
							
								
								
									
										11
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								package.json
									
									
									
									
									
								
							| @ -7,10 +7,13 @@ | ||||
|     "android": "expo start --android", | ||||
|     "ios": "expo start --ios", | ||||
|     "web": "expo start --web", | ||||
|     "build:web": "expo export -p web" | ||||
|     "build:web": "expo export -p web", | ||||
|     "postinstall": "patch-package" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@expo/metro-runtime": "~3.1.3", | ||||
|     "@hookform/resolvers": "^3.3.4", | ||||
|     "@react-native-async-storage/async-storage": "1.21.0", | ||||
|     "@types/react": "~18.2.45", | ||||
|     "class-variance-authority": "^0.7.0", | ||||
|     "dayjs": "^1.11.10", | ||||
| @ -22,6 +25,7 @@ | ||||
|     "hono": "^4.1.0", | ||||
|     "react": "18.2.0", | ||||
|     "react-dom": "18.2.0", | ||||
|     "react-hook-form": "^7.51.0", | ||||
|     "react-native": "0.73.4", | ||||
|     "react-native-circular-progress": "^1.3.9", | ||||
|     "react-native-safe-area-context": "4.8.2", | ||||
| @ -30,11 +34,14 @@ | ||||
|     "react-native-web": "~0.19.6", | ||||
|     "react-query": "^3.39.3", | ||||
|     "twrnc": "^4.1.0", | ||||
|     "typescript": "^5.3.0" | ||||
|     "typescript": "^5.3.0", | ||||
|     "zod": "^3.22.4", | ||||
|     "zustand": "^4.5.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.20.0", | ||||
|     "babel-plugin-module-resolver": "^5.0.0", | ||||
|     "patch-package": "^8.0.0", | ||||
|     "react-native-svg-transformer": "^1.3.0" | ||||
|   }, | ||||
|   "private": true | ||||
|  | ||||
							
								
								
									
										13
									
								
								patches/react-native-circular-progress+1.3.9.patch
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								patches/react-native-circular-progress+1.3.9.patch
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| diff --git a/node_modules/react-native-circular-progress/src/CircularProgress.js b/node_modules/react-native-circular-progress/src/CircularProgress.js
 | ||||
| index 3de2d77..0265a0f 100644
 | ||||
| --- a/node_modules/react-native-circular-progress/src/CircularProgress.js
 | ||||
| +++ b/node_modules/react-native-circular-progress/src/CircularProgress.js
 | ||||
| @@ -132,7 +132,7 @@ export default class CircularProgress extends React.PureComponent {
 | ||||
|  } | ||||
|   | ||||
|  CircularProgress.propTypes = { | ||||
| -  style: PropTypes.object,
 | ||||
| +  style: PropTypes.any,
 | ||||
|    size: PropTypes.oneOfType([ | ||||
|      PropTypes.number, | ||||
|      PropTypes.instanceOf(Animated.Value), | ||||
| @ -1,5 +1,5 @@ | ||||
| import React from "react"; | ||||
| import { Slot } from "expo-router"; | ||||
| import React, { useEffect, useState } from "react"; | ||||
| import { Slot, router, usePathname } from "expo-router"; | ||||
| import { QueryClientProvider } from "react-query"; | ||||
| import queryClient from "@/lib/queryClient"; | ||||
| import { View } from "react-native"; | ||||
| @ -7,23 +7,38 @@ import { cn, tw } from "@/lib/utils"; | ||||
| import { useDeviceContext } from "twrnc"; | ||||
| import { StatusBar } from "expo-status-bar"; | ||||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||||
| import { useStore } from "zustand"; | ||||
| import authStore from "@/stores/authStore"; | ||||
| 
 | ||||
| const RootLayout = () => { | ||||
|   const insets = useSafeAreaInsets(); | ||||
|   const pathname = usePathname(); | ||||
|   const isLoggedIn = useStore(authStore, (i) => i.isLoggedIn); | ||||
|   const [isLoaded, setLoaded] = useState(false); | ||||
| 
 | ||||
|   useDeviceContext(tw); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!isLoaded) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (!isLoggedIn && !pathname.startsWith("/auth")) { | ||||
|       router.navigate("/auth/login"); | ||||
|       return; | ||||
|     } | ||||
|   }, [pathname, isLoggedIn, isLoaded]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setLoaded(true); | ||||
|   }, []); | ||||
| 
 | ||||
|   return ( | ||||
|     <QueryClientProvider client={queryClient}> | ||||
|       <StatusBar style="auto" /> | ||||
|       <View style={cn("flex-1 bg-[#f2f7fb]")}> | ||||
|         <View | ||||
|           style={cn("flex-1 mx-auto w-full max-w-xl", { | ||||
|             paddingTop: insets.top, | ||||
|           })} | ||||
|         > | ||||
|       <View style={cn("flex-1 bg-[#f2f7fb]", { paddingTop: insets.top })}> | ||||
|         <Slot /> | ||||
|       </View> | ||||
|       </View> | ||||
|     </QueryClientProvider> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
							
								
								
									
										77
									
								
								src/app/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/app/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,77 @@ | ||||
| import React from "react"; | ||||
| import Box from "@ui/Box"; | ||||
| import Container from "@ui/Container"; | ||||
| import Text from "@ui/Text"; | ||||
| import { ScrollView } from "react-native"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import Input from "@ui/Input"; | ||||
| import { z } from "zod"; | ||||
| import { useForm } from "react-hook-form"; | ||||
| import { zodResolver } from "@hookform/resolvers/zod"; | ||||
| import Button from "@ui/Button"; | ||||
| import { useMutation } from "react-query"; | ||||
| import api from "@/lib/api"; | ||||
| import Alert from "@ui/Alert"; | ||||
| import { setAuthToken } from "@/stores/authStore"; | ||||
| import { router } from "expo-router"; | ||||
| 
 | ||||
| const schema = z.object({ | ||||
|   username: z.string().min(1), | ||||
|   password: z.string().min(1), | ||||
| }); | ||||
| 
 | ||||
| type FormSchema = z.infer<typeof schema>; | ||||
| 
 | ||||
| const defaultValues: FormSchema = { | ||||
|   username: "", | ||||
|   password: "", | ||||
| }; | ||||
| 
 | ||||
| const LoginPage = () => { | ||||
|   const form = useForm({ resolver: zodResolver(schema), defaultValues }); | ||||
|   const login = useMutation({ | ||||
|     mutationFn: (json: FormSchema) => | ||||
|       api.auth.login.$post({ json }).then((res) => res.json()), | ||||
|     onSuccess: async (data) => { | ||||
|       setAuthToken(data.token); | ||||
|       router.navigate("/"); | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   return ( | ||||
|     <ScrollView | ||||
|       contentContainerStyle={cn("flex-1 flex items-center justify-center")} | ||||
|     > | ||||
|       <Container className="p-4"> | ||||
|         <Box className="p-8 bg-white rounded-lg"> | ||||
|           <Text className="text-2xl">Login</Text> | ||||
| 
 | ||||
|           <Input | ||||
|             label="Username" | ||||
|             form={form} | ||||
|             path="username" | ||||
|             className="mt-6" | ||||
|           /> | ||||
|           <Input | ||||
|             label="Password" | ||||
|             form={form} | ||||
|             path="password" | ||||
|             className="mt-4" | ||||
|             secureTextEntry | ||||
|           /> | ||||
| 
 | ||||
|           <Alert className="mt-4" error={login.error} /> | ||||
| 
 | ||||
|           <Button | ||||
|             className="mt-8" | ||||
|             onPress={form.handleSubmit((val) => login.mutate(val))} | ||||
|           > | ||||
|             Login | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Container> | ||||
|     </ScrollView> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default LoginPage; | ||||
| @ -6,7 +6,6 @@ import Text from "@ui/Text"; | ||||
| import { HStack, VStack } from "@ui/Stack"; | ||||
| import Box from "@ui/Box"; | ||||
| import ProcessList from "./ProcessList"; | ||||
| import Divider from "@ui/Divider"; | ||||
| import Button from "@ui/Button"; | ||||
| import { Ionicons } from "@ui/Icons"; | ||||
| 
 | ||||
| @ -5,28 +5,31 @@ import Text from "@ui/Text"; | ||||
| import Performance from "./_sections/Performance"; | ||||
| import Summary from "./_sections/Summary"; | ||||
| import Storage from "./_sections/Storage"; | ||||
| import { ScrollView } from "react-native"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import Container from "@ui/Container"; | ||||
| import { useAuth } from "@/stores/authStore"; | ||||
| 
 | ||||
| const App = () => { | ||||
| const HomePage = () => { | ||||
|   const { isLoggedIn } = useAuth(); | ||||
|   const { data: system } = useQuery({ | ||||
|     queryKey: ["system"], | ||||
|     queryFn: () => api.system.$get().then((r) => r.json()), | ||||
|     refetchInterval: 1000, | ||||
|     enabled: isLoggedIn, | ||||
|   }); | ||||
| 
 | ||||
|   if (!isLoggedIn) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <ScrollView | ||||
|       contentContainerStyle={cn("px-4 py-8 md:py-16")} | ||||
|       showsVerticalScrollIndicator={false} | ||||
|     > | ||||
|     <Container scrollable className="px-4 py-8 md:py-16"> | ||||
|       <Text className="text-2xl font-medium">Home Lab</Text> | ||||
| 
 | ||||
|       <Summary data={system} /> | ||||
|       <Performance data={system} /> | ||||
|       <Storage data={system} /> | ||||
|     </ScrollView> | ||||
|     </Container> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default App; | ||||
| export default HomePage; | ||||
							
								
								
									
										75
									
								
								src/components/ui/Alert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/components/ui/Alert.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { ComponentPropsWithClassName } from "@/types/components"; | ||||
| import { View } from "react-native"; | ||||
| import Text from "./Text"; | ||||
| import { VariantProps, cva } from "class-variance-authority"; | ||||
| 
 | ||||
| const alertVariants = cva( | ||||
|   "rounded-md bg-gray-100 border border-gray-300 px-3 py-2 w-full", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "bg-gray-100 border-gray-300", | ||||
|         success: "bg-green-100 border-green-300", | ||||
|         error: "bg-red-100 border-red-300", | ||||
|         warning: "bg-yellow-100 border-yellow-300", | ||||
|         info: "bg-blue-100 border-blue-300", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|     }, | ||||
|   } | ||||
| ); | ||||
| 
 | ||||
| const alertTextVariants = cva("text-sm", { | ||||
|   variants: { | ||||
|     variant: { | ||||
|       default: "text-gray-700", | ||||
|       success: "text-green-700", | ||||
|       error: "text-red-700", | ||||
|       warning: "text-yellow-700", | ||||
|       info: "text-blue-700", | ||||
|     }, | ||||
|   }, | ||||
|   defaultVariants: { | ||||
|     variant: "default", | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| type Props = Omit<ComponentPropsWithClassName<typeof View>, "children"> & | ||||
|   VariantProps<typeof alertVariants> & { | ||||
|     children?: string; | ||||
|     textClassName?: string; | ||||
|     error?: unknown; | ||||
|   }; | ||||
| 
 | ||||
| const Alert = ({ | ||||
|   className, | ||||
|   textClassName, | ||||
|   children, | ||||
|   variant: variantName, | ||||
|   error, | ||||
| }: Props) => { | ||||
|   let variant = variantName; | ||||
|   let message = children; | ||||
| 
 | ||||
|   if (error) { | ||||
|     variant = "error"; | ||||
|     message = (error as any)?.message || "An error occured!"; | ||||
|   } | ||||
| 
 | ||||
|   if (!message) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <View style={cn(alertVariants({ variant }), className)}> | ||||
|       <Text className={[alertTextVariants({ variant }), textClassName]}> | ||||
|         {message} | ||||
|       </Text> | ||||
|     </View> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Alert; | ||||
| @ -54,12 +54,13 @@ const buttonTextVariants = cva("text-center font-medium", { | ||||
| }); | ||||
| 
 | ||||
| interface ButtonProps | ||||
|   extends React.ComponentPropsWithoutRef<typeof Pressable>, | ||||
|   extends Omit<React.ComponentPropsWithoutRef<typeof Pressable>, "children">, | ||||
|     VariantProps<typeof buttonVariants> { | ||||
|   label?: string; | ||||
|   labelClasses?: string; | ||||
|   className?: string; | ||||
|   icon?: React.ReactNode; | ||||
|   children?: string; | ||||
| } | ||||
| function Button({ | ||||
|   label, | ||||
| @ -68,6 +69,7 @@ function Button({ | ||||
|   variant, | ||||
|   size, | ||||
|   icon, | ||||
|   children, | ||||
|   ...props | ||||
| }: ButtonProps) { | ||||
|   const textStyles = cn( | ||||
| @ -86,7 +88,9 @@ function Button({ | ||||
|     > | ||||
|       {icon ? <Slot.View style={textStyles}>{icon}</Slot.View> : null} | ||||
| 
 | ||||
|       {label ? <Text style={textStyles}>{label}</Text> : null} | ||||
|       {label || children ? ( | ||||
|         <Text style={textStyles}>{label || children}</Text> | ||||
|       ) : null} | ||||
|     </Pressable> | ||||
|   ); | ||||
| } | ||||
|  | ||||
							
								
								
									
										32
									
								
								src/components/ui/Container.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/components/ui/Container.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | ||||
| import { ScrollView, View } from "react-native"; | ||||
| import React from "react"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| 
 | ||||
| type ContainerProps = { | ||||
|   className?: string; | ||||
|   children?: React.ReactNode; | ||||
|   scrollable?: boolean; | ||||
| }; | ||||
| 
 | ||||
| const Container = ({ | ||||
|   className, | ||||
|   children, | ||||
|   scrollable = false, | ||||
| }: ContainerProps) => { | ||||
|   const style = cn("mx-auto w-full max-w-xl", className); | ||||
| 
 | ||||
|   if (scrollable) { | ||||
|     return ( | ||||
|       <ScrollView | ||||
|         contentContainerStyle={style} | ||||
|         showsVerticalScrollIndicator={false} | ||||
|       > | ||||
|         {children} | ||||
|       </ScrollView> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return <View style={style}>{children}</View>; | ||||
| }; | ||||
| 
 | ||||
| export default Container; | ||||
							
								
								
									
										65
									
								
								src/components/ui/Input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/components/ui/Input.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,65 @@ | ||||
| import React from "react"; | ||||
| import { TextInput } from "react-native"; | ||||
| import { ComponentPropsWithClassName } from "@/types/components"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import Box from "./Box"; | ||||
| import Text from "./Text"; | ||||
| import { Controller, FieldValues, Path, UseFormReturn } from "react-hook-form"; | ||||
| 
 | ||||
| type BaseInputProps = ComponentPropsWithClassName<typeof TextInput> & { | ||||
|   label?: string; | ||||
|   inputClassName?: string; | ||||
|   error?: string; | ||||
| }; | ||||
| 
 | ||||
| type InputProps<T extends FieldValues> = BaseInputProps & { | ||||
|   form?: UseFormReturn<T>; | ||||
|   path?: Path<T>; | ||||
| }; | ||||
| 
 | ||||
| const BaseInput = ({ | ||||
|   className, | ||||
|   inputClassName, | ||||
|   label, | ||||
|   error, | ||||
|   ...props | ||||
| }: BaseInputProps) => { | ||||
|   return ( | ||||
|     <Box className={className}> | ||||
|       {label ? <Text className="text-sm mb-1">{label}</Text> : null} | ||||
| 
 | ||||
|       <TextInput | ||||
|         style={cn( | ||||
|           "border border-gray-300 rounded-lg px-3 h-10 w-full", | ||||
|           inputClassName | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
| 
 | ||||
|       {error ? ( | ||||
|         <Text className="text-red-500 text-sm mt-1">{error}</Text> | ||||
|       ) : null} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Input = <T extends FieldValues>({ | ||||
|   form, | ||||
|   path, | ||||
|   ...props | ||||
| }: InputProps<T>) => { | ||||
|   if (form && path) { | ||||
|     return ( | ||||
|       <Controller | ||||
|         control={form.control} | ||||
|         name={path} | ||||
|         render={({ field: { ref, ...field }, fieldState }) => ( | ||||
|           <BaseInput {...props} {...field} error={fieldState.error?.message} /> | ||||
|         )} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
|   return <BaseInput {...props} />; | ||||
| }; | ||||
| 
 | ||||
| export default Input; | ||||
| @ -7,9 +7,77 @@ import type { | ||||
|   ClientRequestOptions, | ||||
|   ClientResponse, | ||||
| } from "hono/client"; | ||||
| import type { AppType } from "../../backend"; | ||||
| import type { AppType } from "../../backend/routes/_routes"; | ||||
| import authStore, { logout } from "@/stores/authStore"; | ||||
| 
 | ||||
| const api = hc(API_BASEURL) as ReturnType<typeof hono<AppType>>; | ||||
| const api: ReturnType<typeof hono<AppType>> = hc(API_BASEURL, { | ||||
|   fetch: fetchHandler, | ||||
| }); | ||||
| 
 | ||||
| export class ApiError extends Error { | ||||
|   code = 400; | ||||
| 
 | ||||
|   constructor(res: Response, data?: any) { | ||||
|     const message = | ||||
|       typeof data === "string" | ||||
|         ? data | ||||
|         : typeof data === "object" | ||||
|         ? data?.message | ||||
|         : res.statusText; | ||||
| 
 | ||||
|     super(message); | ||||
|     this.name = "ApiError"; | ||||
|     this.code = res.status; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function fetchHandler(input: any, init?: RequestInit) { | ||||
|   const token = authStore.getState().token; | ||||
| 
 | ||||
|   if (init) { | ||||
|     init.headers = new Headers(init.headers); | ||||
|     if (token) { | ||||
|       init.headers.set("Authorization", `Bearer ${token}`); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (typeof input === "object") { | ||||
|     input.headers = new Headers(init.headers); | ||||
|     if (token) { | ||||
|       input.headers.set("Authorization", `Bearer ${token}`); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const res = await fetch(input, init); | ||||
|   await checkResponse(res); | ||||
| 
 | ||||
|   return res; | ||||
| } | ||||
| 
 | ||||
| async function checkResponse(res: Response) { | ||||
|   if (res.ok) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   let data: any = null; | ||||
| 
 | ||||
|   try { | ||||
|     const isJson = res.headers | ||||
|       .get("Content-Type") | ||||
|       ?.includes("application/json"); | ||||
|     if (isJson) { | ||||
|       data = await res.json(); | ||||
|     } else { | ||||
|       data = await res.text(); | ||||
|     } | ||||
|   } catch (err) {} | ||||
| 
 | ||||
|   if (res.status === 401) { | ||||
|     logout(); | ||||
|   } | ||||
| 
 | ||||
|   throw new ApiError(res, data); | ||||
| } | ||||
| 
 | ||||
| export { | ||||
|   InferRequestType, | ||||
|  | ||||
| @ -1,2 +1 @@ | ||||
| // export const API_BASEURL = "http://localhost:3000";
 | ||||
| export const API_BASEURL = ""; | ||||
| export const API_BASEURL = __DEV__ ? "http://localhost:3000" : ""; | ||||
|  | ||||
							
								
								
									
										27
									
								
								src/stores/authStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/stores/authStore.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| import { createStore, useStore } from "zustand"; | ||||
| import { createJSONStorage, persist } from "zustand/middleware"; | ||||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||||
| 
 | ||||
| const initialState = { | ||||
|   token: "", | ||||
|   isLoggedIn: false, | ||||
| }; | ||||
| 
 | ||||
| const authStore = createStore( | ||||
|   persist(() => initialState, { | ||||
|     name: "auth", | ||||
|     storage: createJSONStorage(() => AsyncStorage), | ||||
|   }) | ||||
| ); | ||||
| 
 | ||||
| export const setAuthToken = (token: string) => { | ||||
|   authStore.setState({ token, isLoggedIn: true }); | ||||
| }; | ||||
| 
 | ||||
| export const logout = () => { | ||||
|   authStore.setState(initialState); | ||||
| }; | ||||
| 
 | ||||
| export const useAuth = () => useStore(authStore); | ||||
| 
 | ||||
| export default authStore; | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user