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