From 2dc472c60ed384199e86ff3956941027c905f5f6 Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Wed, 3 Sep 2025 20:03:13 -0300 Subject: [PATCH] feat: migrate authentication to Appwrite and remove Better-Auth references --- README.md | 36 ++- apps/server/package.json | 18 +- apps/server/src/index.ts | 3 - apps/server/src/lib/auth.ts | 23 -- apps/server/src/lib/context.ts | 74 +++++- apps/server/src/lib/trpc.ts | 6 +- apps/server/src/routers/index.ts | 2 +- apps/web/package.json | 26 +- apps/web/src/components/sign-in-form.tsx | 43 +-- apps/web/src/components/sign-up-form.tsx | 42 +-- apps/web/src/components/user-menu.tsx | 22 +- apps/web/src/lib/auth-client.ts | 70 ++++- apps/web/src/routes/dashboard.tsx | 4 +- apps/web/src/utils/trpc.ts | 9 +- pnpm-lock.yaml | 319 +++++++++-------------- 15 files changed, 380 insertions(+), 317 deletions(-) delete mode 100644 apps/server/src/lib/auth.ts diff --git a/README.md b/README.md index 60ed0cd..49a92b6 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,29 @@ Reflecto/ ## Available Scripts -- `pnpm dev`: Start all applications in development mode -- `pnpm build`: Build all applications -- `pnpm dev:web`: Start only the web application -- `pnpm dev:server`: Start only the server -- `pnpm check-types`: Check TypeScript types across all apps -- `pnpm db:push`: Push schema changes to database -- `pnpm db:studio`: Open database studio UI -- `cd apps/web && pnpm generate-pwa-assets`: Generate PWA assets -- `cd apps/web && pnpm desktop:dev`: Start Tauri desktop app in development -- `cd apps/web && pnpm desktop:build`: Build Tauri desktop app + +## Authentication (Appwrite) + +The project now uses Appwrite Authentication instead of Better-Auth. + +Environment variables: + +- Server (`apps/server/.env`) + - `APPWRITE_ENDPOINT` — e.g. https://.cloud.appwrite.io/v1 + - `APPWRITE_PROJECT_ID` — your Appwrite Project ID + +- Web (`apps/web/.env`) + - `VITE_APPWRITE_ENDPOINT` — same endpoint as above + - `VITE_APPWRITE_PROJECT_ID` — same project id + - `VITE_SERVER_URL` — TRPC server URL (e.g. http://localhost:3000) + +How it works: + +- Web uses Appwrite's Account SDK to sign up/in and get the current user. +- For server calls, web obtains a short-lived JWT via `account.createJWT()` and sends it as `Authorization: Bearer `. +- Server initializes an Appwrite Server SDK per request, reads the JWT (or falls back to `a_session_` cookie), and resolves the user with `account.get()` in TRPC context. +- Protected routes remain enforced via `protectedProcedure`. + +Notes: + +- If relying on session cookies from the browser, use a custom domain for Appwrite so cookies are first-party (or enable 3rd-party cookies in local dev). diff --git a/apps/server/package.json b/apps/server/package.json index fe681b0..d4cdb4a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,21 +18,21 @@ "db:down": "docker compose down" }, "dependencies": { - "dotenv": "^17.2.1", - "zod": "^4.0.2", - "@trpc/server": "^11.5.0", - "@trpc/client": "^11.5.0", "@hono/trpc-server": "^0.4.0", - "hono": "^4.8.2", + "@trpc/client": "^11.5.0", + "@trpc/server": "^11.5.0", + "dotenv": "^17.2.1", "drizzle-orm": "^0.44.2", + "hono": "^4.8.2", + "node-appwrite": "^14.2.0", "pg": "^8.14.1", - "better-auth": "^1.3.7" + "zod": "^4.0.2" }, "devDependencies": { - "tsdown": "^0.14.1", - "typescript": "^5.8.2", "@types/bun": "^1.2.6", + "@types/pg": "^8.11.11", "drizzle-kit": "^0.31.2", - "@types/pg": "^8.11.11" + "tsdown": "^0.14.1", + "typescript": "^5.8.2" } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 714062f..272e526 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,7 +5,6 @@ import { trpcServer } from "@hono/trpc-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger as honoLogger } from "hono/logger"; -import { auth } from "./lib/auth"; import { createContext } from "./lib/context"; import { logger } from "./lib/logger"; import { appRouter } from "./routers/index"; @@ -35,8 +34,6 @@ app.use( }) ); -app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw)); - app.use( "/trpc/*", trpcServer({ diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts deleted file mode 100644 index 5980bbc..0000000 --- a/apps/server/src/lib/auth.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { db } from "../db"; -import * as schema from "../db/schema/auth"; - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - - schema, - }), - trustedOrigins: [process.env.CORS_ORIGIN || ""], - emailAndPassword: { - enabled: true, - }, - advanced: { - defaultCookieAttributes: { - sameSite: "none", - secure: true, - httpOnly: true, - }, - }, -}); diff --git a/apps/server/src/lib/context.ts b/apps/server/src/lib/context.ts index e1ec247..aafd3d1 100644 --- a/apps/server/src/lib/context.ts +++ b/apps/server/src/lib/context.ts @@ -1,17 +1,77 @@ import type { Context as HonoContext } from "hono"; -import { auth } from "./auth"; +import { Account, Client } from "node-appwrite"; + +// Hoisted regex for performance and linting +const BEARER_REGEX = /^Bearer\s+(.+)$/i; + +// Minimal user shape to avoid leaking node-appwrite model types +export type AuthUser = { + $id: string; + name?: string | null; + email?: string | null; +} | null; export type CreateContextOptions = { context: HonoContext; }; export async function createContext({ context }: CreateContextOptions) { - const session = await auth.api.getSession({ - headers: context.req.raw.headers, - }); - return { - session, - }; + const endpoint = process.env.APPWRITE_ENDPOINT; + const projectId = process.env.APPWRITE_PROJECT_ID; + + if (!(endpoint && projectId)) { + // Appwrite not configured; treat as unauthenticated + return { user: null as AuthUser }; + } + + // Initialize client per request + const client = new Client().setEndpoint(endpoint).setProject(projectId); + + // Prefer JWT from Authorization header if provided + const authHeader = context.req.header("Authorization"); + const bearer = authHeader?.match(BEARER_REGEX)?.[1]; + + if (bearer) { + client.setJWT(bearer); + } else { + // Fallback: Appwrite session cookie from browser + // Cookie name format: a_session_ + const cookieHeader = + context.req.header("Cookie") || context.req.header("cookie"); + if (cookieHeader) { + const cookieName = `a_session_${projectId}`; + const cookies = Object.fromEntries( + cookieHeader.split("; ").map((c) => { + const idx = c.indexOf("="); + return idx === -1 + ? [c, ""] + : [c.substring(0, idx), decodeURIComponent(c.substring(idx + 1))]; + }) + ); + const session = cookies[cookieName]; + if (session) { + client.setSession(session); + } + } + } + + const account = new Account(client); + + try { + const user = (await account.get()) as unknown as { + $id: string; + name?: string | null; + email?: string | null; + }; + const minimal: AuthUser = { + $id: user.$id, + name: user.name ?? null, + email: user.email ?? null, + }; + return { user: minimal }; + } catch { + return { user: null as AuthUser }; + } } export type Context = Awaited>; diff --git a/apps/server/src/lib/trpc.ts b/apps/server/src/lib/trpc.ts index 3affce2..bdf0fb6 100644 --- a/apps/server/src/lib/trpc.ts +++ b/apps/server/src/lib/trpc.ts @@ -8,17 +8,17 @@ export const router = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { - if (!ctx.session) { + if (!ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required", - cause: "No session", + cause: "No user", }); } return next({ ctx: { ...ctx, - session: ctx.session, + user: ctx.user, }, }); }); diff --git a/apps/server/src/routers/index.ts b/apps/server/src/routers/index.ts index 170eb64..a14289e 100644 --- a/apps/server/src/routers/index.ts +++ b/apps/server/src/routers/index.ts @@ -7,7 +7,7 @@ export const appRouter = router({ privateData: protectedProcedure.query(({ ctx }) => { return { message: "This is private", - user: ctx.session.user, + user: ctx.user, }; }), }); diff --git a/apps/web/package.json b/apps/web/package.json index cdddfb3..17fb5d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index 7179063..15d2dd4 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -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` + ), }), }, }); diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index 63b7748..3427acb 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -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` + ), }), }, }); diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index a5b3009..6492d91 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -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 ( - + My Account - {session.user.email} + {session.email}