feat(auth): finally better auth working as i wanted it to
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
34
frontend/src/lib/formStuff/components/CheckBox.tsx
Normal file
34
frontend/src/lib/formStuff/components/CheckBox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
frontend/src/lib/formStuff/components/FieldErrors.tsx
Normal file
16
frontend/src/lib/formStuff/components/FieldErrors.tsx
Normal 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>
|
||||
));
|
||||
};
|
||||
28
frontend/src/lib/formStuff/components/InputField.tsx
Normal file
28
frontend/src/lib/formStuff/components/InputField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
frontend/src/lib/formStuff/components/SelectField.tsx
Normal file
57
frontend/src/lib/formStuff/components/SelectField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
frontend/src/lib/formStuff/components/SubmitButton.tsx
Normal file
24
frontend/src/lib/formStuff/components/SubmitButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
frontend/src/lib/formStuff/index.tsx
Normal file
15
frontend/src/lib/formStuff/index.tsx
Normal 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,
|
||||
});
|
||||
18
frontend/src/lib/providers/SessionProvider.tsx
Normal file
18
frontend/src/lib/providers/SessionProvider.tsx
Normal 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}</>;
|
||||
}
|
||||
17
frontend/src/lib/querys/session.ts
Normal file
17
frontend/src/lib/querys/session.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user