feat: hook syncUser to login callback

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-09-15 22:58:23 +00:00
parent 72ac4df486
commit 70d4743eef
10 changed files with 127 additions and 42 deletions

View File

@@ -1,39 +1,46 @@
import type { UserIdentity } from "convex/server"
import { import {
customCtx,
customMutation, customMutation,
customQuery, customQuery,
} from "convex-helpers/server/customFunctions" } 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 { mutation, query } from "./_generated/server"
import { userIdentityOrThrow, userOrThrow } from "./model/user" 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 * Custom query that automatically provides authenticated user context
* Throws an error if the user is not authenticated * Throws an error if the user is not authenticated
*/ */
export const authenticatedQuery = customQuery(query, { export const authenticatedQuery = customQuery(
args: {}, query,
input: async (ctx, args) => { customCtx(async (ctx: QueryCtx) => {
const user = await userOrThrow(ctx) const user = await userOrThrow(ctx)
const identity = await userIdentityOrThrow(ctx) const identity = await userIdentityOrThrow(ctx)
return { return { user, identity }
ctx: { ...ctx, user, identity }, }),
args, )
}
},
})
/** /**
* Custom mutation that automatically provides authenticated user context * Custom mutation that automatically provides authenticated user context
* Throws an error if the user is not authenticated * Throws an error if the user is not authenticated
*/ */
export const authenticatedMutation = customMutation(mutation, { export const authenticatedMutation = customMutation(
args: {}, mutation,
input: async (ctx, args) => { customCtx(async (ctx: MutationCtx) => {
const user = await userOrThrow(ctx) const user = await userOrThrow(ctx)
const identity = await userIdentityOrThrow(ctx) const identity = await userIdentityOrThrow(ctx)
return { user, identity }
return { }),
ctx: { ...ctx, user, identity }, )
args,
}
},
})

View File

@@ -10,14 +10,10 @@ export enum Code {
export type ApplicationError = ConvexError<{ code: Code; message: string }> export type ApplicationError = ConvexError<{ code: Code; message: string }>
export function isApplicationError(error: unknown): error is ApplicationError { export function isApplicationError(error: unknown): error is ApplicationError {
return ( return error instanceof ConvexError && "code" in error.data
error instanceof ConvexError &&
"code" in error.data &&
"message" in error.data
)
} }
export function create(code: Code, message: string = "unknown error") { export function create(code: Code, message?: string) {
return new ConvexError({ return new ConvexError({
code, code,
message, message,

View File

@@ -1,4 +1,6 @@
import type { Id } from "../_generated/dataModel"
import type { MutationCtx, QueryCtx } from "../_generated/server" import type { MutationCtx, QueryCtx } from "../_generated/server"
import type { AuthenticatedMutationCtx } from "../functions"
import * as Err from "./error" import * as Err from "./error"
/** /**
@@ -36,3 +38,9 @@ export async function userOrThrow(ctx: QueryCtx | MutationCtx) {
return user return user
} }
export async function register(ctx: AuthenticatedMutationCtx) {
await ctx.db.insert("users", {
jwtSubject: ctx.identity.subject,
})
}

View File

@@ -3,8 +3,8 @@ import { v } from "convex/values"
const schema = defineSchema({ const schema = defineSchema({
users: defineTable({ users: defineTable({
jwtSubject: v.optional(v.string()), // JWT subject from identity provider (optional for migration) jwtSubject: v.string(),
}).index("byJwtSubject", ["jwtSubject"]), // Unique index for JWT subject lookup }).index("byJwtSubject", ["jwtSubject"]),
files: defineTable({ files: defineTable({
storageId: v.id("_storage"), storageId: v.id("_storage"),
userId: v.id("users"), userId: v.id("users"),

View File

@@ -1,6 +1,6 @@
import { mutation } from "./_generated/server" import { mutation } from "./_generated/server"
import { authenticatedQuery } from "./functions" import { authenticatedQuery } from "./functions"
import { getOrCreateUser, userIdentityOrThrow } from "./model/user" import * as Err from "./model/error"
export const getCurrentUser = authenticatedQuery({ export const getCurrentUser = authenticatedQuery({
handler: async (ctx) => { handler: async (ctx) => {
@@ -11,15 +11,22 @@ export const getCurrentUser = authenticatedQuery({
export const syncUser = mutation({ export const syncUser = mutation({
handler: async (ctx) => { handler: async (ctx) => {
// This function creates or updates the internal user from identity provider const identity = await ctx.auth.getUserIdentity()
const userId = await getOrCreateUser(ctx) if (!identity) {
const identity = await userIdentityOrThrow(ctx) throw Err.create(Err.Code.Unauthenticated)
}
return { const existingUser = await ctx.db
userId, .query("users")
.withIndex("byJwtSubject", (q) =>
q.eq("jwtSubject", identity.subject),
)
.first()
if (!existingUser) {
await ctx.db.insert("users", {
jwtSubject: identity.subject, jwtSubject: identity.subject,
name: identity.name, })
email: identity.email,
} }
}, },
}) })

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' 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 AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout'
import { Route as AuthenticatedSidebarLayoutFilesRouteImport } from './routes/_authenticated/_sidebar-layout/files' import { Route as AuthenticatedSidebarLayoutFilesRouteImport } from './routes/_authenticated/_sidebar-layout/files'
@@ -29,6 +30,11 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const LoginCallbackRoute = LoginCallbackRouteImport.update({
id: '/login_/callback',
path: '/login/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSidebarLayoutRoute = const AuthenticatedSidebarLayoutRoute =
AuthenticatedSidebarLayoutRouteImport.update({ AuthenticatedSidebarLayoutRouteImport.update({
id: '/_sidebar-layout', id: '/_sidebar-layout',
@@ -43,11 +49,13 @@ const AuthenticatedSidebarLayoutFilesRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/files': typeof AuthenticatedSidebarLayoutFilesRoute '/files': typeof AuthenticatedSidebarLayoutFilesRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/files': typeof AuthenticatedSidebarLayoutFilesRoute '/files': typeof AuthenticatedSidebarLayoutFilesRoute
} }
@@ -56,19 +64,21 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren '/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/files': typeof AuthenticatedSidebarLayoutFilesRoute '/_authenticated/_sidebar-layout/files': typeof AuthenticatedSidebarLayoutFilesRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/' | '/files' fullPaths: '/login' | '/login/callback' | '/' | '/files'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/login' | '/' | '/files' to: '/login' | '/login/callback' | '/' | '/files'
id: id:
| '__root__' | '__root__'
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/_authenticated/_sidebar-layout' | '/_authenticated/_sidebar-layout'
| '/login_/callback'
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/_sidebar-layout/files' | '/_authenticated/_sidebar-layout/files'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -76,6 +86,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren { export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
LoginCallbackRoute: typeof LoginCallbackRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -101,6 +112,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedIndexRouteImport preLoaderRoute: typeof AuthenticatedIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/login_/callback': {
id: '/login_/callback'
path: '/login/callback'
fullPath: '/login/callback'
preLoaderRoute: typeof LoginCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/_sidebar-layout': { '/_authenticated/_sidebar-layout': {
id: '/_authenticated/_sidebar-layout' id: '/_authenticated/_sidebar-layout'
path: '' path: ''
@@ -149,6 +167,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren, AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
LoginCallbackRoute: LoginCallbackRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -2,7 +2,6 @@ import "@/styles/globals.css"
import { ConvexProviderWithAuthKit } from "@convex-dev/workos" import { ConvexProviderWithAuthKit } from "@convex-dev/workos"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { createRootRoute, Outlet } from "@tanstack/react-router" import { createRootRoute, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/router-devtools"
import { AuthKitProvider, useAuth } from "@workos-inc/authkit-react" import { AuthKitProvider, useAuth } from "@workos-inc/authkit-react"
import { ConvexReactClient } from "convex/react" import { ConvexReactClient } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Navigate } from "@tanstack/react-router" import { createFileRoute, Navigate } from "@tanstack/react-router"
export const Route = createFileRoute("/_authenticated")({ export const Route = createFileRoute("/_authenticated/")({
component: RouteComponent, component: RouteComponent,
}) })

View File

@@ -9,5 +9,9 @@ export const Route = createFileRoute("/login")({
function RouteComponent() { function RouteComponent() {
const { signIn } = useAuth() const { signIn } = useAuth()
return <Button onClick={() => signIn()}>Login</Button> return (
<div className="flex h-screen w-full items-center justify-center">
<Button onClick={() => signIn()}>Login</Button>
</div>
)
} }

View File

@@ -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 (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />
</div>
)
}