feat(auth): finally better auth working as i wanted it to

This commit is contained in:
2025-09-22 22:40:44 -05:00
parent 4ab43d91b9
commit 8f1375ab7b
50 changed files with 7939 additions and 5909 deletions

View File

@@ -1,11 +1,139 @@
import { createAuthClient } from "better-auth/client";
import { usernameClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: `${window.location.origin}/lst/api/auth`, // 👈 This is fine
callbacks: {
onUpdate: (session: any) => console.log("Session updated", session),
onSignIn: (session: any) => console.log("Signed in!", session),
onSignOut: () => console.log("Signed out!"),
import { create } from "zustand";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useRouter } from "@tanstack/react-router";
// ---- TYPES ----
export type Session = typeof authClient.$Infer.Session | null;
// Zustand store type
type SessionState = {
session: Session;
setSession: (session: Session) => void;
clearSession: () => void;
};
export type UserRoles = {
userRoleId: string;
userId: string;
module: string;
role: "systemAdmin" | "admin" | "manager" | "user" | "viewer";
};
type UserRoleState = {
userRoles: UserRoles[] | null;
fetchRoles: (userId: string) => Promise<void>;
clearRoles: () => void;
};
// ---- ZUSTAND STORE ----
export const useAuth = create<SessionState>((set) => ({
session: null,
setSession: (session) => set({ session }),
clearSession: () => set({ session: null }),
}));
export const useUserRoles = create<UserRoleState>((set) => ({
userRoles: null,
fetchRoles: async (userId: string) => {
try {
const res = await fetch(`/api/${userId}/roles`, {
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch roles");
const roles = await res.json();
set({ userRoles: roles });
} catch (err) {
console.error("Error fetching roles:", err);
set({ userRoles: null });
}
},
clearRoles: () => set({ userRoles: null }),
}));
// ---- BETTER AUTH CLIENT ----
export const authClient = createAuthClient({
baseURL: `${window.location.origin}/lst/api/auth`,
plugins: [usernameClient()],
callbacks: {
callbacks: {
onUpdate: (res: any) => {
// res has strong type
// res.data is `Session | null`
useAuth.getState().setSession(res?.data ?? null);
},
onSignIn: (res: any) => {
console.log("Setting session to ", res?.data);
useAuth.getState().setSession(res?.data ?? null);
},
onSignOut: () => {
useAuth.getState().clearSession();
},
},
},
});
// ---- AUTH API HELPERS ----
export async function signin(data: { username: string; password: string }) {
const res = await authClient.signIn.username(data);
if (res.error) throw res.error;
await authClient.getSession();
return res.data;
}
export const useLogout = () => {
const { clearSession } = useAuth();
const router = useRouter();
const logout = async () => {
await authClient.signOut();
router.invalidate();
router.clearCache();
clearSession();
window.location.reload();
};
return logout;
};
export async function getSession() {
const res = await authClient.getSession({
query: { disableCookieCache: true },
});
if (res.error) return null;
return res.data;
}
// ---- REACT QUERY INTEGRATION ----
export function useSession() {
const { setSession, clearSession } = useAuth();
const qc = useQueryClient();
const query = useQuery({
queryKey: ["session"],
queryFn: getSession,
refetchInterval: 60_000,
refetchOnWindowFocus: true,
});
//console.log("Auth Check", query.data);
// react to data change
useEffect(() => {
if (query.data !== undefined) {
setSession(query.data);
}
}, [query.data, setSession]);
// react to error
useEffect(() => {
if (query.error) {
clearSession();
qc.removeQueries({ queryKey: ["session"] });
}
}, [query.error, qc, clearSession]);
return query;
}

View File

@@ -0,0 +1,34 @@
import { Label } from "@radix-ui/react-label";
import { useFieldContext } from "..";
import { FieldErrors } from "./FieldErrors";
import { Checkbox } from "../../../components/ui/checkbox";
type CheckboxFieldProps = {
label: string;
description?: string;
};
export const CheckboxField = ({ label }: CheckboxFieldProps) => {
const field = useFieldContext<boolean>();
return (
<div>
<div className="m-2 p-2 flex flex-row">
<div>
<Label htmlFor="active">
<span>{label}</span>
</Label>
</div>
<Checkbox
id={field.name}
checked={field.state.value}
onCheckedChange={(checked) => {
field.handleChange(checked === true);
}}
onBlur={field.handleBlur}
/>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};

View File

@@ -0,0 +1,16 @@
import type { AnyFieldMeta } from "@tanstack/react-form";
import { ZodError } from "zod";
type FieldErrorsProps = {
meta: AnyFieldMeta;
};
export const FieldErrors = ({ meta }: FieldErrorsProps) => {
if (!meta.isTouched) return null;
return meta.errors.map(({ message }: ZodError, index) => (
<p key={index} className="text-sm font-medium text-destructive">
{message}
</p>
));
};

View File

@@ -0,0 +1,28 @@
import { useFieldContext } from "..";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { FieldErrors } from "./FieldErrors";
type InputFieldProps = {
label: string;
inputType: string;
required: boolean;
};
export const InputField = ({ label, inputType, required }: InputFieldProps) => {
const field = useFieldContext<any>();
return (
<div className="grid gap-3">
<Label htmlFor={field.name}>{label}</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
type={inputType}
required={required}
/>
<FieldErrors meta={field.state.meta} />
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { useFieldContext } from "..";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import { FieldErrors } from "./FieldErrors";
import { Label } from "../../../components/ui/label";
type SelectOption = {
value: string;
label: string;
};
type SelectFieldProps = {
label: string;
options: SelectOption[];
placeholder?: string;
};
export const SelectField = ({
label,
options,
placeholder,
}: SelectFieldProps) => {
const field = useFieldContext<string>();
return (
<div className="grid gap-3">
<div className="grid gap-3">
<Label htmlFor={field.name}>{label}</Label>
<Select
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
>
<SelectTrigger
id={field.name}
onBlur={field.handleBlur}
className="w-[380px]"
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};

View File

@@ -0,0 +1,24 @@
import { useStore } from "@tanstack/react-form";
import { useFormContext } from "..";
import { Button } from "../../../components/ui/button";
type SubmitButtonProps = {
children: React.ReactNode;
};
export const SubmitButton = ({ children }: SubmitButtonProps) => {
const form = useFormContext();
const [isSubmitting] = useStore(form.store, (state) => [
state.isSubmitting,
state.canSubmit,
]);
return (
<div className="">
<Button type="submit" disabled={isSubmitting}>
{children}
</Button>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { SubmitButton } from "./components/SubmitButton";
import { InputField } from "./components/InputField";
import { SelectField } from "./components/SelectField";
import { CheckboxField } from "./components/CheckBox";
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts();
export const { useAppForm } = createFormHook({
fieldComponents: { InputField, SelectField, CheckboxField },
formComponents: { SubmitButton },
fieldContext,
formContext,
});

View File

@@ -0,0 +1,18 @@
import { useRouter } from "@tanstack/react-router";
import { useEffect } from "react";
import { useSession } from "../authClient";
export function SessionGuard({ children }: { children: React.ReactNode }) {
const { data: session, isLoading } = useSession();
const router = useRouter();
useEffect(() => {
if (!isLoading && !session) {
router.navigate({ to: "/" }); // redirect if not logged in
}
}, [isLoading, session, router]);
if (isLoading) return <div>Checking session</div>;
return <>{children}</>;
}

View File

@@ -0,0 +1,17 @@
import { queryOptions } from "@tanstack/react-query";
import axios from "axios";
export function getAuthSession() {
return queryOptions({
queryKey: ["session"],
queryFn: () => fetchSession(),
staleTime: 5000,
refetchOnWindowFocus: true,
});
}
const fetchSession = async () => {
const { data } = await axios.get("/lst/api/me");
return data;
};