feat: migrate authentication to Appwrite and remove Better-Auth references

This commit is contained in:
2025-09-03 20:03:13 -03:00
parent 7b0526ebee
commit 2dc472c60e
15 changed files with 380 additions and 317 deletions

View File

@@ -16,40 +16,40 @@
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"radix-ui": "^1.4.2",
"@tanstack/react-form": "^1.12.3",
"@tailwindcss/vite": "^4.0.15",
"@tanstack/react-form": "^1.12.3",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.114.25",
"@trpc/client": "^11.5.0",
"@trpc/server": "^11.5.0",
"@trpc/tanstack-react-query": "^11.5.0",
"appwrite": "^14.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.473.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.2.5",
"zod": "^4.0.2",
"@trpc/tanstack-react-query": "^11.5.0",
"@trpc/client": "^11.5.0",
"@trpc/server": "^11.5.0",
"@tanstack/react-query": "^5.85.5",
"vite-plugin-pwa": "^1.0.1",
"better-auth": "^1.3.7"
"zod": "^4.0.2"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.85.5",
"@tanstack/react-router-devtools": "^1.114.27",
"@tanstack/router-plugin": "^1.114.27",
"@tauri-apps/cli": "^2.4.0",
"@types/node": "^22.13.13",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@vite-pwa/assets-generator": "^1.0.0",
"@vitejs/plugin-react": "^4.3.4",
"postcss": "^8.5.3",
"typescript": "^5.8.3",
"tailwindcss": "^4.0.15",
"vite": "^6.2.2",
"@tanstack/react-query-devtools": "^5.85.5",
"@vite-pwa/assets-generator": "^1.0.0",
"@tauri-apps/cli": "^2.4.0"
"typescript": "^5.8.3",
"vite": "^6.2.2"
}
}

View File

@@ -1,8 +1,11 @@
const MIN_PASSWORD_LENGTH = 8;
import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { authClient } from "@/lib/auth-client";
import { account, authClient } from "@/lib/auth-client";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -17,6 +20,7 @@ export default function SignInForm({
from: "/",
});
const { isPending } = authClient.useSession();
const queryClient = useQueryClient();
const form = useForm({
defaultValues: {
@@ -24,28 +28,33 @@ export default function SignInForm({
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
try {
await authClient.signIn.email({
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
}
);
});
// Hydrate session cache immediately for instant UI update
const me = await account.get();
queryClient.setQueryData(["session", "me"], me);
// Ensure session state updates immediately in UI
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
navigate({ to: "/dashboard" });
toast.success("Sign in successful");
} catch (error) {
const msg =
error instanceof Error ? error.message : "Failed to sign in";
toast.error(msg);
}
},
validators: {
onSubmit: z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
password: z
.string()
.min(
MIN_PASSWORD_LENGTH,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
),
}),
},
});

View File

@@ -1,13 +1,16 @@
import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { authClient } from "@/lib/auth-client";
import { account, authClient } from "@/lib/auth-client";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
const MIN_PASSWORD_LENGTH = 8;
export default function SignUpForm({
onSwitchToSignIn,
}: {
@@ -17,6 +20,7 @@ export default function SignUpForm({
from: "/",
});
const { isPending } = authClient.useSession();
const queryClient = useQueryClient();
const form = useForm({
defaultValues: {
@@ -25,30 +29,34 @@ export default function SignUpForm({
name: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
try {
await authClient.signUp.email({
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
toast.success("Sign up successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
}
);
});
// Hydrate session cache immediately and invalidate for freshness
const me = await account.get();
queryClient.setQueryData(["session", "me"], me);
await queryClient.invalidateQueries({ queryKey: ["session", "me"] });
navigate({ to: "/dashboard" });
toast.success("Sign up successful");
} catch (error) {
const msg =
error instanceof Error ? error.message : "Failed to sign up";
toast.error(msg);
}
},
validators: {
onSubmit: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
password: z
.string()
.min(
MIN_PASSWORD_LENGTH,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
),
}),
},
});

View File

@@ -1,3 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import {
DropdownMenu,
@@ -13,6 +14,7 @@ import { Skeleton } from "./ui/skeleton";
export default function UserMenu() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
@@ -30,25 +32,23 @@ export default function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{session.user.name}</Button>
<Button variant="outline">{session.name ?? session.email}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
<DropdownMenuItem>{session.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
className="w-full"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
onClick={async () => {
await authClient.signOut();
// Immediately reflect logout in UI
queryClient.setQueryData(["session", "me"], null);
await queryClient.invalidateQueries({
queryKey: ["session", "me"],
});
navigate({ to: "/" });
}}
variant="destructive"
>

View File

@@ -1,5 +1,67 @@
import { createAuthClient } from "better-auth/react";
import { useQuery } from "@tanstack/react-query";
import type { Models } from "appwrite";
import { Account, Client, ID } from "appwrite";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_SERVER_URL,
});
// Initialize Appwrite web client
export const appwriteClient = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID);
export const account = new Account(appwriteClient);
// Simple session hook using React Query
export function useSession() {
return useQuery({
queryKey: ["session", "me"],
queryFn: async () => {
try {
const me = await account.get();
return me as Models.User<Models.Preferences>;
} catch {
return null;
}
},
staleTime: 30_000,
});
}
// Sign up, sign in, sign out helpers
export const authClient = {
useSession,
signUp: {
email: async ({
email,
password,
name,
}: {
email: string;
password: string;
name?: string;
}) => {
await account.create(ID.unique(), email, password, name);
// Immediately create session after sign up
await account.createEmailPasswordSession(email, password);
},
},
signIn: {
email: async ({ email, password }: { email: string; password: string }) => {
await account.createEmailPasswordSession(email, password);
},
},
signOut: async () => {
try {
await account.deleteSessions();
} catch {
// ignore
}
},
// Get a short-lived JWT for server-side calls (15 min)
getJWT: async (): Promise<string | null> => {
try {
const jwt = await account.createJWT();
return jwt.jwt ?? null;
} catch {
return null;
}
},
};

View File

@@ -21,7 +21,7 @@ function RouteComponent() {
to: "/login",
});
}
}, [session, isPending]);
}, [session, isPending, navigate]);
if (isPending) {
return <div>Loading...</div>;
@@ -30,7 +30,7 @@ function RouteComponent() {
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p>
<p>Welcome {session?.name ?? session?.email}</p>
<p>privateData: {privateData.data?.message}</p>
</div>
);

View File

@@ -2,6 +2,7 @@ import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import type { AppRouter } from "../../../server/src/routers";
export const queryClient = new QueryClient({
@@ -23,9 +24,15 @@ export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
fetch(url, options) {
async fetch(url, options) {
const jwt = await authClient.getJWT();
const headers = new Headers(options?.headers);
if (jwt) {
headers.set("Authorization", `Bearer ${jwt}`);
}
return fetch(url, {
...options,
headers,
credentials: "include",
});
},