mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-28 16:49:36 +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