mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: migrate authentication to Appwrite and remove Better-Auth references
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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_<PROJECT_ID>
|
||||
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<ReturnType<typeof createContext>>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export const appRouter = router({
|
||||
privateData: protectedProcedure.query(({ ctx }) => {
|
||||
return {
|
||||
message: "This is private",
|
||||
user: ctx.session.user,
|
||||
user: ctx.user,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user