feat: add authentication

This commit is contained in:
Khairul Hidayat 2024-03-16 07:52:42 +07:00
parent ed5c715429
commit 1e8252d9da
28 changed files with 8519 additions and 138 deletions

5
backend/.env.example Normal file
View File

@ -0,0 +1,5 @@
#
JWT_SECRET=
AUTH_USERNAME=
AUTH_PASSWORD=

Binary file not shown.

View File

@ -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
View 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
View 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`;
};

View File

@ -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
View 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
View 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
View 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
View 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;

BIN
bun.lockb

Binary file not shown.

View File

@ -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

View 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),

View File

@ -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
View 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;

View File

@ -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";

View File

@ -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;

View 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;

View File

@ -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>
);
}

View 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;

View 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;

View File

@ -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,

View File

@ -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
View 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;

7913
yarn.lock Normal file

File diff suppressed because it is too large Load Diff