feat: Projects backend slice — auth, primitives, backend, UI (#3)

This commit is contained in:
2026-07-23 14:09:25 +00:00
parent ab9426b8fc
commit 609badc4ed
32 changed files with 1926 additions and 664 deletions

View File

@@ -5,7 +5,6 @@
"type": "module",
"exports": {
"./native": "./src/native/index.ts",
"./server": "./src/server/index.ts",
"./web": "./src/web/index.ts"
},
"scripts": {

View File

@@ -1 +0,0 @@
export { authComponent, createAuth } from "@code/backend/convex/auth";

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>

View File

@@ -1,5 +1,7 @@
export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider";
export { useConvexAccessToken } from "./hooks";
export { signOutWeb, useWebAuth } from "./use-web-auth";
export type { WebAuthState, WebAuthUser } from "./use-web-auth";
export { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form";

View File

@@ -0,0 +1,81 @@
import { api } from "@code/backend/convex/_generated/api";
import { useConvexAuth, useQuery } from "convex/react";
import { useEffect, useRef } from "react";
import { authClient } from "./auth-client";
export interface WebAuthUser {
readonly id: string;
readonly name: string;
readonly email: string;
}
export type WebAuthState =
| { readonly status: "loading" }
| { readonly status: "unauthenticated" }
| { readonly status: "authenticated"; readonly user: WebAuthUser }
| { readonly status: "inconsistent" };
/**
* Sign out of the web session. Clears the Better Auth session, which in turn
* invalidates the cached Convex JWT (the session-token resolver keys on the
* session id). Protected Convex queries after sign-out are denied.
*/
export const signOutWeb = async (): Promise<void> => {
await authClient.signOut({ fetchOptions: { throw: false } });
};
/**
* The single web-facing auth interface. Combines Convex auth state with the
* normalized {@link api.auth.getCurrentUser} projection, skipping the user
* query until Convex has authenticated. Reports `inconsistent` only when a JWT
* is accepted but the Better Auth user row cannot be resolved.
*/
export const useWebAuth = (): WebAuthState => {
const { isAuthenticated, isLoading } = useConvexAuth();
// Skip the user query until Convex has accepted the JWT. This keeps the
// status `loading` during token restoration and `unauthenticated` when no
// session exists, without firing a query that would return null.
const userQuery = useQuery(
api.auth.getCurrentUser,
isAuthenticated ? {} : "skip"
);
const signOutWebRef = useRef(signOutWeb);
const inconsistent =
isAuthenticated && userQuery !== undefined && userQuery === null;
// If Convex accepted the JWT but the Better Auth user row cannot be resolved,
// the session is inconsistent: sign out so the next sign-in starts clean.
useEffect(() => {
if (inconsistent) {
void signOutWebRef.current();
}
}, [inconsistent]);
if (isLoading) {
return { status: "loading" };
}
if (!isAuthenticated) {
return { status: "unauthenticated" };
}
if (userQuery === undefined) {
return { status: "loading" };
}
if (userQuery === null) {
return { status: "inconsistent" };
}
return {
status: "authenticated",
user: {
email: userQuery.email,
id: userQuery.id,
name: userQuery.name,
},
};
};