feat: add authentication

This commit is contained in:
2024-03-16 07:52:42 +07:00
parent ed5c715429
commit 1e8252d9da
28 changed files with 8519 additions and 138 deletions
+25 -10
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,22 +7,37 @@ 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,
})}
>
<Slot />
</View>
<View style={cn("flex-1 bg-[#f2f7fb]", { paddingTop: insets.top })}>
<Slot />
</View>
</QueryClientProvider>
);
+77
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;
@@ -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";
+12 -9
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;
+75
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;
+6 -2
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>
);
}
+32
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;
+65
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;
+70 -2
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,
+1 -2
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
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;