mirror of
				https://github.com/khairul169/home-lab.git
				synced 2025-10-31 11:49:34 +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 { Hono } from "hono"; | ||||||
| import { cors } from "hono/cors"; | import { cors } from "hono/cors"; | ||||||
| import { serveStatic } from "hono/bun"; | import { serveStatic } from "hono/bun"; | ||||||
| import { zValidator } from "@hono/zod-validator"; | import { HTTPException } from "hono/http-exception"; | ||||||
| import z from "zod"; | import routes from "./routes/_routes"; | ||||||
| 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`; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const app = new Hono() | const app = new Hono() | ||||||
|   .use(cors()) |   .use(cors()) | ||||||
| 
 |   .use("*", serveStatic({ root: "./public" })) | ||||||
|   .get("/system", async (c) => { |   .route("/", routes) | ||||||
|     const date = new Date().toISOString(); |   .onError((err, c) => { | ||||||
|     const uptime = secondsToTime(si.time().uptime || 0); |     if (err instanceof HTTPException) { | ||||||
|     const system = await si.system(); |       return err.getResponse(); | ||||||
| 
 |  | ||||||
|     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; |  | ||||||
|     } |     } | ||||||
|       } |     return c.json({ message: err.message }, 500); | ||||||
| 
 |   }); | ||||||
|       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; |  | ||||||
| 
 | 
 | ||||||
| export default app; | 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" |     "start": "bun run index.ts" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@types/bun": "latest" |     "@types/bun": "latest", | ||||||
|  |     "@types/jsonwebtoken": "^9.0.6" | ||||||
|   }, |   }, | ||||||
|   "peerDependencies": { |   "peerDependencies": { | ||||||
|     "typescript": "^5.0.0" |     "typescript": "^5.0.0" | ||||||
| @ -15,6 +16,7 @@ | |||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@hono/zod-validator": "^0.2.0", |     "@hono/zod-validator": "^0.2.0", | ||||||
|     "hono": "^4.1.0", |     "hono": "^4.1.0", | ||||||
|  |     "nanoid": "^5.0.6", | ||||||
|     "systeminformation": "^5.22.2", |     "systeminformation": "^5.22.2", | ||||||
|     "zod": "^3.22.4" |     "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", |     "android": "expo start --android", | ||||||
|     "ios": "expo start --ios", |     "ios": "expo start --ios", | ||||||
|     "web": "expo start --web", |     "web": "expo start --web", | ||||||
|     "build:web": "expo export -p web" |     "build:web": "expo export -p web", | ||||||
|  |     "postinstall": "patch-package" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@expo/metro-runtime": "~3.1.3", |     "@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", |     "@types/react": "~18.2.45", | ||||||
|     "class-variance-authority": "^0.7.0", |     "class-variance-authority": "^0.7.0", | ||||||
|     "dayjs": "^1.11.10", |     "dayjs": "^1.11.10", | ||||||
| @ -22,6 +25,7 @@ | |||||||
|     "hono": "^4.1.0", |     "hono": "^4.1.0", | ||||||
|     "react": "18.2.0", |     "react": "18.2.0", | ||||||
|     "react-dom": "18.2.0", |     "react-dom": "18.2.0", | ||||||
|  |     "react-hook-form": "^7.51.0", | ||||||
|     "react-native": "0.73.4", |     "react-native": "0.73.4", | ||||||
|     "react-native-circular-progress": "^1.3.9", |     "react-native-circular-progress": "^1.3.9", | ||||||
|     "react-native-safe-area-context": "4.8.2", |     "react-native-safe-area-context": "4.8.2", | ||||||
| @ -30,11 +34,14 @@ | |||||||
|     "react-native-web": "~0.19.6", |     "react-native-web": "~0.19.6", | ||||||
|     "react-query": "^3.39.3", |     "react-query": "^3.39.3", | ||||||
|     "twrnc": "^4.1.0", |     "twrnc": "^4.1.0", | ||||||
|     "typescript": "^5.3.0" |     "typescript": "^5.3.0", | ||||||
|  |     "zod": "^3.22.4", | ||||||
|  |     "zustand": "^4.5.2" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@babel/core": "^7.20.0", |     "@babel/core": "^7.20.0", | ||||||
|     "babel-plugin-module-resolver": "^5.0.0", |     "babel-plugin-module-resolver": "^5.0.0", | ||||||
|  |     "patch-package": "^8.0.0", | ||||||
|     "react-native-svg-transformer": "^1.3.0" |     "react-native-svg-transformer": "^1.3.0" | ||||||
|   }, |   }, | ||||||
|   "private": true |   "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 React, { useEffect, useState } from "react"; | ||||||
| import { Slot } from "expo-router"; | import { Slot, router, usePathname } from "expo-router"; | ||||||
| import { QueryClientProvider } from "react-query"; | import { QueryClientProvider } from "react-query"; | ||||||
| import queryClient from "@/lib/queryClient"; | import queryClient from "@/lib/queryClient"; | ||||||
| import { View } from "react-native"; | import { View } from "react-native"; | ||||||
| @ -7,23 +7,38 @@ import { cn, tw } from "@/lib/utils"; | |||||||
| import { useDeviceContext } from "twrnc"; | import { useDeviceContext } from "twrnc"; | ||||||
| import { StatusBar } from "expo-status-bar"; | import { StatusBar } from "expo-status-bar"; | ||||||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||||||
|  | import { useStore } from "zustand"; | ||||||
|  | import authStore from "@/stores/authStore"; | ||||||
| 
 | 
 | ||||||
| const RootLayout = () => { | const RootLayout = () => { | ||||||
|   const insets = useSafeAreaInsets(); |   const insets = useSafeAreaInsets(); | ||||||
|  |   const pathname = usePathname(); | ||||||
|  |   const isLoggedIn = useStore(authStore, (i) => i.isLoggedIn); | ||||||
|  |   const [isLoaded, setLoaded] = useState(false); | ||||||
|  | 
 | ||||||
|   useDeviceContext(tw); |   useDeviceContext(tw); | ||||||
| 
 | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!isLoaded) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (!isLoggedIn && !pathname.startsWith("/auth")) { | ||||||
|  |       router.navigate("/auth/login"); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |   }, [pathname, isLoggedIn, isLoaded]); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     setLoaded(true); | ||||||
|  |   }, []); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <QueryClientProvider client={queryClient}> |     <QueryClientProvider client={queryClient}> | ||||||
|       <StatusBar style="auto" /> |       <StatusBar style="auto" /> | ||||||
|       <View style={cn("flex-1 bg-[#f2f7fb]")}> |       <View style={cn("flex-1 bg-[#f2f7fb]", { paddingTop: insets.top })}> | ||||||
|         <View |  | ||||||
|           style={cn("flex-1 mx-auto w-full max-w-xl", { |  | ||||||
|             paddingTop: insets.top, |  | ||||||
|           })} |  | ||||||
|         > |  | ||||||
|         <Slot /> |         <Slot /> | ||||||
|       </View> |       </View> | ||||||
|       </View> |  | ||||||
|     </QueryClientProvider> |     </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 { HStack, VStack } from "@ui/Stack"; | ||||||
| import Box from "@ui/Box"; | import Box from "@ui/Box"; | ||||||
| import ProcessList from "./ProcessList"; | import ProcessList from "./ProcessList"; | ||||||
| import Divider from "@ui/Divider"; |  | ||||||
| import Button from "@ui/Button"; | import Button from "@ui/Button"; | ||||||
| import { Ionicons } from "@ui/Icons"; | import { Ionicons } from "@ui/Icons"; | ||||||
| 
 | 
 | ||||||
| @ -5,28 +5,31 @@ import Text from "@ui/Text"; | |||||||
| import Performance from "./_sections/Performance"; | import Performance from "./_sections/Performance"; | ||||||
| import Summary from "./_sections/Summary"; | import Summary from "./_sections/Summary"; | ||||||
| import Storage from "./_sections/Storage"; | import Storage from "./_sections/Storage"; | ||||||
| import { ScrollView } from "react-native"; | import Container from "@ui/Container"; | ||||||
| import { cn } from "@/lib/utils"; | import { useAuth } from "@/stores/authStore"; | ||||||
| 
 | 
 | ||||||
| const App = () => { | const HomePage = () => { | ||||||
|  |   const { isLoggedIn } = useAuth(); | ||||||
|   const { data: system } = useQuery({ |   const { data: system } = useQuery({ | ||||||
|     queryKey: ["system"], |     queryKey: ["system"], | ||||||
|     queryFn: () => api.system.$get().then((r) => r.json()), |     queryFn: () => api.system.$get().then((r) => r.json()), | ||||||
|     refetchInterval: 1000, |     refetchInterval: 1000, | ||||||
|  |     enabled: isLoggedIn, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |   if (!isLoggedIn) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <ScrollView |     <Container scrollable className="px-4 py-8 md:py-16"> | ||||||
|       contentContainerStyle={cn("px-4 py-8 md:py-16")} |  | ||||||
|       showsVerticalScrollIndicator={false} |  | ||||||
|     > |  | ||||||
|       <Text className="text-2xl font-medium">Home Lab</Text> |       <Text className="text-2xl font-medium">Home Lab</Text> | ||||||
| 
 | 
 | ||||||
|       <Summary data={system} /> |       <Summary data={system} /> | ||||||
|       <Performance data={system} /> |       <Performance data={system} /> | ||||||
|       <Storage 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 | interface ButtonProps | ||||||
|   extends React.ComponentPropsWithoutRef<typeof Pressable>, |   extends Omit<React.ComponentPropsWithoutRef<typeof Pressable>, "children">, | ||||||
|     VariantProps<typeof buttonVariants> { |     VariantProps<typeof buttonVariants> { | ||||||
|   label?: string; |   label?: string; | ||||||
|   labelClasses?: string; |   labelClasses?: string; | ||||||
|   className?: string; |   className?: string; | ||||||
|   icon?: React.ReactNode; |   icon?: React.ReactNode; | ||||||
|  |   children?: string; | ||||||
| } | } | ||||||
| function Button({ | function Button({ | ||||||
|   label, |   label, | ||||||
| @ -68,6 +69,7 @@ function Button({ | |||||||
|   variant, |   variant, | ||||||
|   size, |   size, | ||||||
|   icon, |   icon, | ||||||
|  |   children, | ||||||
|   ...props |   ...props | ||||||
| }: ButtonProps) { | }: ButtonProps) { | ||||||
|   const textStyles = cn( |   const textStyles = cn( | ||||||
| @ -86,7 +88,9 @@ function Button({ | |||||||
|     > |     > | ||||||
|       {icon ? <Slot.View style={textStyles}>{icon}</Slot.View> : null} |       {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> |     </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, |   ClientRequestOptions, | ||||||
|   ClientResponse, |   ClientResponse, | ||||||
| } from "hono/client"; | } 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 { | export { | ||||||
|   InferRequestType, |   InferRequestType, | ||||||
|  | |||||||
| @ -1,2 +1 @@ | |||||||
| // export const API_BASEURL = "http://localhost:3000";
 | export const API_BASEURL = __DEV__ ? "http://localhost:3000" : ""; | ||||||
| export const API_BASEURL = ""; |  | ||||||
|  | |||||||
							
								
								
									
										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