started adding supabase

This commit is contained in:
Nate Kelley 2025-08-12 10:30:48 -06:00
parent 9c052be5ad
commit e2ee9e4c27
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
20 changed files with 864 additions and 262 deletions

View File

@ -36,7 +36,7 @@
},
"suspicious": {
"noExplicitAny": "error",
"noConsole": "warn"
"noConsole": "off"
},
"complexity": {
"noExcessiveCognitiveComplexity": "off",

View File

@ -21,26 +21,29 @@
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@supabase/ssr": "^0.6.1",
"@t3-oss/env-core": "^0.13.8",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/db": "^0.1.1",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/query-db-collection": "^0.2.0",
"@tanstack/react-db": "^0.1.1",
"@tanstack/react-devtools": "^0.2.2",
"@tanstack/react-devtools": "^0.3.0",
"@tanstack/react-form": "^1.19.1",
"@tanstack/react-query": "^5.84.2",
"@tanstack/react-query-devtools": "^5.84.2",
"@tanstack/react-router": "^1.131.5",
"@tanstack/react-router-devtools": "^1.131.5",
"@tanstack/react-query": "^5.85.0",
"@tanstack/react-query-devtools": "^5.85.0",
"@tanstack/react-router": "^1.131.7",
"@tanstack/react-router-devtools": "^1.131.7",
"@tanstack/react-router-with-query": "^1.130.17",
"@tanstack/react-start": "^1.131.6",
"@tanstack/react-start": "^1.131.7",
"@tanstack/react-store": "^0.7.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.131.5",
"@tanstack/router-plugin": "^1.131.7",
"@tanstack/store": "^0.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ky": "^1.8.2",
"lodash": "^4.17.21",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@ -64,7 +67,7 @@
"@vitejs/plugin-react": "^5.0.0",
"@vitest/ui": "3.2.4",
"jsdom": "^26.1.0",
"typescript": "^5.9.2",
"typescript": "^5.9.0",
"vite": "^7.1.2",
"vite-tsconfig-paths": "catalog:",
"vitest": "^3.2.4",

View File

@ -0,0 +1,4 @@
export const BASE_API_URL = `${process.env.NEXT_PUBLIC_API_URL}`;
export const BASE_API_URL_V2 = `${process.env.NEXT_PUBLIC_API2_URL}`;
export const BASE_URL = `${BASE_API_URL}/api/v1`;
export const BASE_URL_V2 = `${BASE_API_URL_V2}/api/v2`;

View File

@ -0,0 +1,51 @@
import isString from 'lodash/isString';
export const rustErrorHandler = (errors: unknown = {}): RustApiError => {
// Type guards and safe property access
const isErrorObject = (obj: unknown): obj is Record<string, unknown> =>
typeof obj === 'object' && obj !== null;
const data =
isErrorObject(errors) && isErrorObject(errors.response) ? errors.response.data : undefined;
const status =
isErrorObject(errors) && typeof errors.status === 'number' ? errors.status : undefined;
if (data && isString(data)) {
return { message: String(data), status };
}
if (isErrorObject(data) && data.message) {
return { message: String(data.message), status };
}
if (isErrorObject(data) && data.detail) {
if (typeof data.detail === 'string') {
return { message: String(data.detail), status };
}
if (
Array.isArray(data.detail) &&
data.detail[0] &&
isErrorObject(data.detail[0]) &&
data.detail[0].msg
) {
return { message: String(data.detail[0].msg), status };
}
return { message: String(data.detail), status };
}
if (isErrorObject(errors) && errors.message) {
return { message: String(errors.message), status };
}
if (typeof errors === 'string') {
return { message: String(errors), status };
}
return {};
};
export interface RustApiError {
message?: string;
status?: number;
}

View File

@ -0,0 +1,17 @@
import ky from "ky";
import { BASE_URL, BASE_URL_V2 } from "./config";
const mainApi = ky.create({
prefixUrl: BASE_URL,
hooks: {
beforeRequest: [
async (request) => {
// request.headers.set("Authorization", `Bearer ${token}`);
},
],
},
});
const mainApiV2 = ky.create({ prefixUrl: BASE_URL_V2 });
export default mainApi;
export { mainApi, mainApiV2 };

View File

@ -41,3 +41,4 @@ export function makeData(...lens: number[]): Person[] {
return makeDataLevel();
}

View File

@ -0,0 +1,29 @@
"use client";
import { createBrowserClient as createBrowserClientSSR } from "@supabase/ssr";
function createBrowserClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables for browser client");
}
return createBrowserClientSSR(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});
}
let browserClient: ReturnType<typeof createBrowserClient> | null = null;
export const getBrowserClient = () => {
if (!browserClient) {
browserClient = createBrowserClient();
}
return browserClient;
};

View File

@ -0,0 +1,96 @@
import { getSupabaseServerClient } from "./server";
import { signInWithAnonymousUser } from "./signIn";
type PromiseType<T extends Promise<unknown>> = T extends Promise<infer U> ? U : never;
export type UseSupabaseUserContextType = PromiseType<ReturnType<typeof getSupabaseUserContext>>;
export const getSupabaseUserContext = async (preemptiveRefreshMinutes = 5) => {
const supabase = await getSupabaseServerClient();
// Get the session first
const sessionResult = await supabase.auth.getSession();
let sessionData = sessionResult.data;
const sessionError = sessionResult.error;
if (sessionError) {
console.error("Error getting session:", sessionError);
}
// Check if we need to refresh the session
if (sessionData.session) {
const refreshedSessionData = (await refreshSessionIfNeeded(
supabase,
sessionData.session,
preemptiveRefreshMinutes,
)) as Awaited<ReturnType<typeof refreshSessionIfNeeded>>;
// If session was refreshed, get the updated session
if (refreshedSessionData && "session" in refreshedSessionData) {
// Replace the entire sessionData object to avoid type issues
sessionData = refreshedSessionData;
}
}
// Get user data
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError) {
console.error("Error getting user:", userData, userError);
}
if (!userData.user) {
const { session: anonSession } = await signInWithAnonymousUser();
console.info("created anon session", anonSession);
return {
user: anonSession?.user || null,
accessToken: anonSession?.access_token,
};
}
const user = userData.user;
const accessToken = sessionData.session?.access_token;
const refreshToken = sessionData.session?.refresh_token;
if (!accessToken) {
console.error("No access token found for user:", user);
}
return { user, accessToken, refreshToken };
};
/**
* Helper function to refresh the session if it's about to expire (less than 50 minutes)
* Returns true if session was refreshed, false otherwise
*/
const refreshSessionIfNeeded = async (
supabase: Awaited<ReturnType<typeof getSupabaseServerClient>>,
session: NonNullable<Awaited<ReturnType<typeof supabase.auth.getSession>>["data"]["session"]>,
preemptiveRefreshMinutes = 5,
): Promise<false | Awaited<ReturnType<typeof supabase.auth.getSession>>["data"]> => {
// Calculate if session is about to expire (less than 50 minutes)
const expiresAt = session.expires_at;
if (!expiresAt) {
return false;
}
const expiresAtTimestamp = expiresAt * 1000; // Convert to milliseconds
const now = Date.now();
const timeUntilExpiry = expiresAtTimestamp - now;
const preemptiveRefreshInMs = preemptiveRefreshMinutes * 60 * 1000;
// If session expires in less than X minutes, refresh it
if (timeUntilExpiry < preemptiveRefreshInMs) {
const { data, error } = await supabase.auth.refreshSession();
if (error || !data.session) {
console.error("Failed to refresh session:", error);
return false;
}
// Session was successfully refreshed
return data;
}
return false;
};

View File

@ -0,0 +1 @@
export * from './getSupabaseUserContext';

View File

@ -0,0 +1,36 @@
'use server';
import { BusterRoutes, createBusterRoute } from '@/routes';
import { createSupabaseServerClient } from './server';
export const resetPasswordEmailSend = async ({ email }: { email: string }) => {
const supabase = await createSupabaseServerClient();
const authURLFull = `${process.env.NEXT_PUBLIC_URL}${createBusterRoute({
route: BusterRoutes.AUTH_CALLBACK
})}`;
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: authURLFull
});
if (error) {
return { error: error.message };
}
return;
};
export const resetPassword = async ({ password }: { password: string }) => {
'use server';
const supabase = await createSupabaseServerClient();
const { data, error } = await supabase.auth.updateUser({ password });
if (error) {
return { error: error.message };
}
return;
};

View File

@ -0,0 +1,38 @@
import { type CookieOptions, createServerClient } from "@supabase/ssr";
import { parseCookies, setCookie } from "@tanstack/react-start/server";
export const COOKIE_OPTIONS: CookieOptions = {
path: "/",
secure: process.env.NODE_ENV === "production", // Only use secure in production
sameSite: "lax", // Type assertion to fix the error
httpOnly: true, // Make cookies HttpOnly
maxAge: 60 * 60 * 24 * 7, // 1 week
};
export function getSupabaseServerClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables for server client");
}
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return Object.entries(parseCookies()).map(([name, value]) => ({
name,
value,
}));
},
setAll(cookies) {
cookies.forEach((cookie) => {
setCookie(cookie.name, cookie.value, {
...COOKIE_OPTIONS,
...cookie.options,
});
});
},
},
});
}

View File

@ -0,0 +1,175 @@
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { ServerRoute as AuthCallbackRoute } from "../../routes/auth.callback";
import { getSupabaseServerClient } from "./server";
const isValidRedirectUrl = (url: string): boolean => {
try {
const decoded = decodeURIComponent(url);
return decoded.startsWith("/") && !decoded.startsWith("//");
} catch {
return false;
}
};
export const signInWithEmailAndPassword = createServerFn({ method: "POST" })
.validator(
z.object({ email: z.string(), password: z.string(), redirectUrl: z.string().optional() }),
)
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase.auth.signUp({
email: data.email,
password: data.password,
});
if (error) {
return {
error: true,
message: error.message,
};
}
// Redirect to the prev page stored in the "redirect" search param
throw redirect({
href: data.redirectUrl || "/",
});
});
export const signInWithGoogle = createServerFn({ method: "POST" })
.validator(z.object({ redirectUrl: z.string().optional() }))
.handler(async ({ data: { redirectUrl } }) => {
const supabase = getSupabaseServerClient();
const redirectTo = redirectUrl || "/";
const callbackUrl = new URL(AuthCallbackRoute.to);
if (redirectTo && isValidRedirectUrl(redirectTo)) {
callbackUrl.searchParams.set("next", redirectTo);
}
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: callbackUrl.toString(),
},
});
if (error) {
return { success: false, error: error.message };
}
throw redirect({ to: data.url });
});
// export const signInWithGithub = async ({
// redirectTo,
// }: {
// redirectTo?: string | null;
// } = {}): Promise<ServerActionResult<string>> => {
// "use server";
// const supabase = await createSupabaseServerClient();
// const callbackUrl = new URL(authURLFull);
// if (redirectTo && isValidRedirectUrl(redirectTo)) {
// callbackUrl.searchParams.set("next", redirectTo);
// }
// const { data, error } = await supabase.auth.signInWithOAuth({
// provider: "github",
// options: {
// redirectTo: callbackUrl.toString(),
// },
// });
// if (error) {
// return { success: false, error: error.message };
// }
// revalidatePath("/", "layout");
// return redirect(data.url);
// };
// export const signInWithAzure = async ({
// redirectTo,
// }: {
// redirectTo?: string | null;
// } = {}): Promise<ServerActionResult<string>> => {
// "use server";
// const supabase = await createSupabaseServerClient();
// const callbackUrl = new URL(authURLFull);
// if (redirectTo && isValidRedirectUrl(redirectTo)) {
// callbackUrl.searchParams.set("next", redirectTo);
// }
// const { data, error } = await supabase.auth.signInWithOAuth({
// provider: "azure",
// options: {
// redirectTo: callbackUrl.toString(),
// scopes: "email",
// },
// });
// if (error) {
// return { success: false, error: error.message };
// }
// revalidatePath("/", "layout");
// return redirect(data.url);
// };
// export const signUp = async ({
// email,
// password,
// redirectTo,
// }: {
// email: string;
// password: string;
// redirectTo?: string | null;
// }): Promise<ServerActionResult> => {
// "use server";
// const supabase = await createSupabaseServerClient();
// const authURL = createBusterRoute({
// route: BusterRoutes.AUTH_CONFIRM,
// });
// const authURLFull = `${process.env.NEXT_PUBLIC_URL}${authURL}`;
// const { error } = await supabase.auth.signUp({
// email,
// password,
// options: {
// emailRedirectTo: authURLFull,
// },
// });
// if (error) {
// console.error("supabase error in signUp", error);
// // Return the actual Supabase error message
// return { success: false, error: error.message };
// }
// revalidatePath("/", "layout");
// const finalRedirect =
// redirectTo && isValidRedirectUrl(redirectTo)
// ? decodeURIComponent(redirectTo)
// : createBusterRoute({ route: BusterRoutes.APP_HOME });
// return redirect(finalRedirect);
// };
// export const signInWithAnonymousUser = async () => {
// "use server";
// const supabase = await createSupabaseServerClient();
// const { data, error } = await supabase.auth.signInAnonymously();
// if (error) {
// throw error;
// }
// revalidatePath("/", "layout");
// return data;
// };

View File

@ -0,0 +1,28 @@
"use server";
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { parseCookies, setCookie } from "@tanstack/react-start/server";
import { getSupabaseServerClient } from "./server";
export const signOut = createServerFn({ method: "POST" }).handler(async () => {
const supabase = await getSupabaseServerClient();
const { error } = await supabase.auth.signOut();
if (error) {
return { error: error.message };
}
// Clear all cookies by setting them with maxAge: 0
const allCookies = parseCookies();
for (const [cookieName] of Object.entries(allCookies)) {
setCookie(cookieName, "", {
path: "/",
maxAge: 0, // This effectively deletes the cookie
});
}
throw redirect({
href: "/",
});
});

View File

@ -14,6 +14,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DemoDbChatRouteImport } from './routes/demo.db-chat'
import { ServerRoute as DemoDbChatApiServerRouteImport } from './routes/demo.db-chat-api'
import { ServerRoute as AuthCallbackServerRouteImport } from './routes/auth.callback'
import { ServerRoute as ApiDemoTqTodosServerRouteImport } from './routes/api.demo-tq-todos'
import { ServerRoute as ApiDemoNamesServerRouteImport } from './routes/api.demo-names'
@ -34,6 +35,11 @@ const DemoDbChatApiServerRoute = DemoDbChatApiServerRouteImport.update({
path: '/demo/db-chat-api',
getParentRoute: () => rootServerRouteImport,
} as any)
const AuthCallbackServerRoute = AuthCallbackServerRouteImport.update({
id: '/auth/callback',
path: '/auth/callback',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiDemoTqTodosServerRoute = ApiDemoTqTodosServerRouteImport.update({
id: '/api/demo-tq-todos',
path: '/api/demo-tq-todos',
@ -73,34 +79,47 @@ export interface RootRouteChildren {
export interface FileServerRoutesByFullPath {
'/api/demo-names': typeof ApiDemoNamesServerRoute
'/api/demo-tq-todos': typeof ApiDemoTqTodosServerRoute
'/auth/callback': typeof AuthCallbackServerRoute
'/demo/db-chat-api': typeof DemoDbChatApiServerRoute
}
export interface FileServerRoutesByTo {
'/api/demo-names': typeof ApiDemoNamesServerRoute
'/api/demo-tq-todos': typeof ApiDemoTqTodosServerRoute
'/auth/callback': typeof AuthCallbackServerRoute
'/demo/db-chat-api': typeof DemoDbChatApiServerRoute
}
export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport
'/api/demo-names': typeof ApiDemoNamesServerRoute
'/api/demo-tq-todos': typeof ApiDemoTqTodosServerRoute
'/auth/callback': typeof AuthCallbackServerRoute
'/demo/db-chat-api': typeof DemoDbChatApiServerRoute
}
export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths: '/api/demo-names' | '/api/demo-tq-todos' | '/demo/db-chat-api'
fullPaths:
| '/api/demo-names'
| '/api/demo-tq-todos'
| '/auth/callback'
| '/demo/db-chat-api'
fileServerRoutesByTo: FileServerRoutesByTo
to: '/api/demo-names' | '/api/demo-tq-todos' | '/demo/db-chat-api'
to:
| '/api/demo-names'
| '/api/demo-tq-todos'
| '/auth/callback'
| '/demo/db-chat-api'
id:
| '__root__'
| '/api/demo-names'
| '/api/demo-tq-todos'
| '/auth/callback'
| '/demo/db-chat-api'
fileServerRoutesById: FileServerRoutesById
}
export interface RootServerRouteChildren {
ApiDemoNamesServerRoute: typeof ApiDemoNamesServerRoute
ApiDemoTqTodosServerRoute: typeof ApiDemoTqTodosServerRoute
AuthCallbackServerRoute: typeof AuthCallbackServerRoute
DemoDbChatApiServerRoute: typeof DemoDbChatApiServerRoute
}
@ -131,6 +150,13 @@ declare module '@tanstack/react-start/server' {
preLoaderRoute: typeof DemoDbChatApiServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/auth/callback': {
id: '/auth/callback'
path: '/auth/callback'
fullPath: '/auth/callback'
preLoaderRoute: typeof AuthCallbackServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/demo-tq-todos': {
id: '/api/demo-tq-todos'
path: '/api/demo-tq-todos'
@ -158,6 +184,7 @@ export const routeTree = rootRouteImport
const rootServerRouteChildren: RootServerRouteChildren = {
ApiDemoNamesServerRoute: ApiDemoNamesServerRoute,
ApiDemoTqTodosServerRoute: ApiDemoTqTodosServerRoute,
AuthCallbackServerRoute: AuthCallbackServerRoute,
DemoDbChatApiServerRoute: DemoDbChatApiServerRoute,
}
export const serverRouteTree = rootServerRouteImport

View File

@ -1,10 +1,13 @@
import type { QueryClient } from "@tanstack/react-query";
import { createRouter as createTanstackRouter } from "@tanstack/react-router";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import * as TanstackQuery from "./integrations/tanstack-query/root-provider";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
export interface AppRouterContext {
queryClient: QueryClient;
}
// Create a new router instance
export const createRouter = () => {
const rqContext = TanstackQuery.getContext();
@ -12,7 +15,8 @@ export const createRouter = () => {
return routerWithQueryClient(
createTanstackRouter({
routeTree,
context: { ...rqContext },
context: { ...rqContext }, //context is defined in the root route
scrollRestoration: true,
defaultPreload: "intent",
Wrap: (props) => {
return <TanstackQuery.Provider {...rqContext}>{props.children}</TanstackQuery.Provider>;

View File

@ -1,18 +1,13 @@
import { TanstackDevtools } from "@tanstack/react-devtools";
import type { QueryClient } from "@tanstack/react-query";
import { createRootRouteWithContext, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import Header from "../components/Header";
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
import StoreDevtools from "../lib/demo-store-devtools";
import type { AppRouterContext } from "../router";
import appCss from "../styles.css?url";
interface MyRouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
export const Route = createRootRouteWithContext<AppRouterContext>()({
head: () => ({
meta: [
{

View File

@ -0,0 +1,73 @@
import { createServerFileRoute } from "@tanstack/react-start/server";
import { z } from "zod";
import { getSupabaseServerClient } from "@/integrations/supabase/server";
// Define the search parameters schema for type safety
const searchParamsSchema = z.object({
code: z.string().optional(),
code_challenge: z.string().optional(),
next: z.string().optional(),
});
// Type for the validated search parameters
type SearchParams = z.infer<typeof searchParamsSchema>;
export const ServerRoute = createServerFileRoute("/auth/callback").methods({
GET: async ({ request }) => {
// Parse query parameters from the URL
const url = new URL(request.url);
// Extract and validate search parameters
const searchParams: SearchParams = {
code: url.searchParams.get("code") || undefined,
code_challenge: url.searchParams.get("code_challenge") || undefined,
next: url.searchParams.get("next") || undefined,
};
// Validate the parameters (optional - provides runtime validation)
const validatedParams = searchParamsSchema.parse(searchParams);
const code = validatedParams.code_challenge || validatedParams.code;
const next = validatedParams.next;
if (!next) {
return new Response(null, { status: 302, headers: { Location: "/" } });
}
if (!code) {
return new Response("Missing code exchange code", { status: 400 });
}
const supabase = await getSupabaseServerClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return new Response("Error exchanging code for session", { status: 500 });
}
const forwardedHost = request.headers.get("x-forwarded-host");
const origin = request.headers.get("origin");
const isLocalEnv = process.env.NODE_ENV === "development";
if (isLocalEnv) {
const redirectPath = next?.startsWith("/") ? next : "/app";
return new Response(null, {
status: 302,
headers: { Location: `${origin}${redirectPath}` },
});
}
if (forwardedHost) {
return new Response(null, {
status: 302,
headers: { Location: `https://${forwardedHost}${next}` },
});
}
return new Response(null, {
status: 302,
headers: { Location: `${origin}${next}` },
});
},
});

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@buster/typescript-config/web-vite.json",
"include": ["**/*.ts", "**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"baseUrl": ".",

View File

@ -5,6 +5,9 @@ import { defineConfig } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
const config = defineConfig({
server: {
port: 3000,
},
plugins: [
// this is the plugin that enables path aliases
viteTsConfigPaths({

File diff suppressed because it is too large Load Diff