From 70d4743eefe71e82146d237c965e5a0a0c1bfb75 Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 15 Sep 2025 22:58:23 +0000 Subject: [PATCH] feat: hook syncUser to login callback Co-authored-by: Ona --- convex/functions.ts | 45 +++++++++++++++++------------ convex/model/error.ts | 8 ++--- convex/model/user.ts | 8 +++++ convex/schema.ts | 4 +-- convex/users.ts | 27 ++++++++++------- src/routeTree.gen.ts | 23 +++++++++++++-- src/routes/__root.tsx | 1 - src/routes/_authenticated/index.tsx | 2 +- src/routes/login.tsx | 6 +++- src/routes/login_.callback.tsx | 45 +++++++++++++++++++++++++++++ 10 files changed, 127 insertions(+), 42 deletions(-) create mode 100644 src/routes/login_.callback.tsx diff --git a/convex/functions.ts b/convex/functions.ts index 3fd5ba6..a5e45e8 100644 --- a/convex/functions.ts +++ b/convex/functions.ts @@ -1,39 +1,46 @@ +import type { UserIdentity } from "convex/server" import { + customCtx, customMutation, customQuery, } from "convex-helpers/server/customFunctions" +import type { Doc } from "./_generated/dataModel" +import type { MutationCtx, QueryCtx } from "./_generated/server" import { mutation, query } from "./_generated/server" import { userIdentityOrThrow, userOrThrow } from "./model/user" +export type AuthenticatedQueryCtx = QueryCtx & { + user: Doc<"users"> + identity: UserIdentity +} + +export type AuthenticatedMutationCtx = MutationCtx & { + user: Doc<"users"> + identity: UserIdentity +} + /** * Custom query that automatically provides authenticated user context * Throws an error if the user is not authenticated */ -export const authenticatedQuery = customQuery(query, { - args: {}, - input: async (ctx, args) => { +export const authenticatedQuery = customQuery( + query, + customCtx(async (ctx: QueryCtx) => { const user = await userOrThrow(ctx) const identity = await userIdentityOrThrow(ctx) - return { - ctx: { ...ctx, user, identity }, - args, - } - }, -}) + return { user, identity } + }), +) /** * Custom mutation that automatically provides authenticated user context * Throws an error if the user is not authenticated */ -export const authenticatedMutation = customMutation(mutation, { - args: {}, - input: async (ctx, args) => { +export const authenticatedMutation = customMutation( + mutation, + customCtx(async (ctx: MutationCtx) => { const user = await userOrThrow(ctx) const identity = await userIdentityOrThrow(ctx) - - return { - ctx: { ...ctx, user, identity }, - args, - } - }, -}) + return { user, identity } + }), +) diff --git a/convex/model/error.ts b/convex/model/error.ts index 108b8e4..76a1673 100644 --- a/convex/model/error.ts +++ b/convex/model/error.ts @@ -10,14 +10,10 @@ export enum Code { export type ApplicationError = ConvexError<{ code: Code; message: string }> export function isApplicationError(error: unknown): error is ApplicationError { - return ( - error instanceof ConvexError && - "code" in error.data && - "message" in error.data - ) + return error instanceof ConvexError && "code" in error.data } -export function create(code: Code, message: string = "unknown error") { +export function create(code: Code, message?: string) { return new ConvexError({ code, message, diff --git a/convex/model/user.ts b/convex/model/user.ts index 6f5fb23..68e27c6 100644 --- a/convex/model/user.ts +++ b/convex/model/user.ts @@ -1,4 +1,6 @@ +import type { Id } from "../_generated/dataModel" import type { MutationCtx, QueryCtx } from "../_generated/server" +import type { AuthenticatedMutationCtx } from "../functions" import * as Err from "./error" /** @@ -36,3 +38,9 @@ export async function userOrThrow(ctx: QueryCtx | MutationCtx) { return user } + +export async function register(ctx: AuthenticatedMutationCtx) { + await ctx.db.insert("users", { + jwtSubject: ctx.identity.subject, + }) +} diff --git a/convex/schema.ts b/convex/schema.ts index 68416d8..835e592 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3,8 +3,8 @@ import { v } from "convex/values" const schema = defineSchema({ users: defineTable({ - jwtSubject: v.optional(v.string()), // JWT subject from identity provider (optional for migration) - }).index("byJwtSubject", ["jwtSubject"]), // Unique index for JWT subject lookup + jwtSubject: v.string(), + }).index("byJwtSubject", ["jwtSubject"]), files: defineTable({ storageId: v.id("_storage"), userId: v.id("users"), diff --git a/convex/users.ts b/convex/users.ts index a06af6a..c8732d6 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,6 +1,6 @@ import { mutation } from "./_generated/server" import { authenticatedQuery } from "./functions" -import { getOrCreateUser, userIdentityOrThrow } from "./model/user" +import * as Err from "./model/error" export const getCurrentUser = authenticatedQuery({ handler: async (ctx) => { @@ -11,15 +11,22 @@ export const getCurrentUser = authenticatedQuery({ export const syncUser = mutation({ handler: async (ctx) => { - // This function creates or updates the internal user from identity provider - const userId = await getOrCreateUser(ctx) - const identity = await userIdentityOrThrow(ctx) - - return { - userId, - jwtSubject: identity.subject, - name: identity.name, - email: identity.email, + const identity = await ctx.auth.getUserIdentity() + if (!identity) { + throw Err.create(Err.Code.Unauthenticated) + } + + const existingUser = await ctx.db + .query("users") + .withIndex("byJwtSubject", (q) => + q.eq("jwtSubject", identity.subject), + ) + .first() + + if (!existingUser) { + await ctx.db.insert("users", { + jwtSubject: identity.subject, + }) } }, }) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 387a5ca..5d9165c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' +import { Route as LoginCallbackRouteImport } from './routes/login_.callback' import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout' import { Route as AuthenticatedSidebarLayoutFilesRouteImport } from './routes/_authenticated/_sidebar-layout/files' @@ -29,6 +30,11 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({ path: '/', getParentRoute: () => AuthenticatedRoute, } as any) +const LoginCallbackRoute = LoginCallbackRouteImport.update({ + id: '/login_/callback', + path: '/login/callback', + getParentRoute: () => rootRouteImport, +} as any) const AuthenticatedSidebarLayoutRoute = AuthenticatedSidebarLayoutRouteImport.update({ id: '/_sidebar-layout', @@ -43,11 +49,13 @@ const AuthenticatedSidebarLayoutFilesRoute = export interface FileRoutesByFullPath { '/login': typeof LoginRoute + '/login/callback': typeof LoginCallbackRoute '/': typeof AuthenticatedIndexRoute '/files': typeof AuthenticatedSidebarLayoutFilesRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/login/callback': typeof LoginCallbackRoute '/': typeof AuthenticatedIndexRoute '/files': typeof AuthenticatedSidebarLayoutFilesRoute } @@ -56,19 +64,21 @@ export interface FileRoutesById { '/_authenticated': typeof AuthenticatedRouteWithChildren '/login': typeof LoginRoute '/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren + '/login_/callback': typeof LoginCallbackRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/_sidebar-layout/files': typeof AuthenticatedSidebarLayoutFilesRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/login' | '/' | '/files' + fullPaths: '/login' | '/login/callback' | '/' | '/files' fileRoutesByTo: FileRoutesByTo - to: '/login' | '/' | '/files' + to: '/login' | '/login/callback' | '/' | '/files' id: | '__root__' | '/_authenticated' | '/login' | '/_authenticated/_sidebar-layout' + | '/login_/callback' | '/_authenticated/' | '/_authenticated/_sidebar-layout/files' fileRoutesById: FileRoutesById @@ -76,6 +86,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { AuthenticatedRoute: typeof AuthenticatedRouteWithChildren LoginRoute: typeof LoginRoute + LoginCallbackRoute: typeof LoginCallbackRoute } declare module '@tanstack/react-router' { @@ -101,6 +112,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedIndexRouteImport parentRoute: typeof AuthenticatedRoute } + '/login_/callback': { + id: '/login_/callback' + path: '/login/callback' + fullPath: '/login/callback' + preLoaderRoute: typeof LoginCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_authenticated/_sidebar-layout': { id: '/_authenticated/_sidebar-layout' path: '' @@ -149,6 +167,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { AuthenticatedRoute: AuthenticatedRouteWithChildren, LoginRoute: LoginRoute, + LoginCallbackRoute: LoginCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 40c87b9..d0bf9b9 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,7 +2,6 @@ import "@/styles/globals.css" import { ConvexProviderWithAuthKit } from "@convex-dev/workos" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { createRootRoute, Outlet } from "@tanstack/react-router" -import { TanStackRouterDevtools } from "@tanstack/router-devtools" import { AuthKitProvider, useAuth } from "@workos-inc/authkit-react" import { ConvexReactClient } from "convex/react" import { toast } from "sonner" diff --git a/src/routes/_authenticated/index.tsx b/src/routes/_authenticated/index.tsx index e2f8f1f..acaee93 100644 --- a/src/routes/_authenticated/index.tsx +++ b/src/routes/_authenticated/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Navigate } from "@tanstack/react-router" -export const Route = createFileRoute("/_authenticated")({ +export const Route = createFileRoute("/_authenticated/")({ component: RouteComponent, }) diff --git a/src/routes/login.tsx b/src/routes/login.tsx index feb8647..5b6ba5a 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -9,5 +9,9 @@ export const Route = createFileRoute("/login")({ function RouteComponent() { const { signIn } = useAuth() - return + return ( +
+ +
+ ) } diff --git a/src/routes/login_.callback.tsx b/src/routes/login_.callback.tsx new file mode 100644 index 0000000..de4ae41 --- /dev/null +++ b/src/routes/login_.callback.tsx @@ -0,0 +1,45 @@ +import { api } from "@convex/_generated/api" +import { useMutation } from "@tanstack/react-query" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useConvexAuth, useMutation as useConvexMutation } from "convex/react" +import { useEffect } from "react" +import { LoadingSpinner } from "@/components/ui/loading-spinner" + +export const Route = createFileRoute("/login_/callback")({ + component: RouteComponent, +}) + +function RouteComponent() { + const { isLoading: isLoadingConvexAuth, isAuthenticated } = useConvexAuth() + const { mutate: syncUser, isPending: isSyncingUser } = useMutation({ + mutationFn: useConvexMutation(api.users.syncUser), + retry: true, + }) + const navigate = useNavigate() + + useEffect(() => { + if (!isLoadingConvexAuth && isAuthenticated && !isSyncingUser) { + console.log({ isLoadingConvexAuth, isAuthenticated, isSyncingUser }) + syncUser(undefined, { + onSuccess: () => { + navigate({ + to: "/", + replace: true, + }) + }, + }) + } + }, [ + isLoadingConvexAuth, + isAuthenticated, + syncUser, + isSyncingUser, + navigate, + ]) + + return ( +
+ +
+ ) +}