feat: hook syncUser to login callback
Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@@ -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"),
|
||||||
|
@@ -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,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@@ -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)
|
||||||
|
@@ -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"
|
||||||
|
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
45
src/routes/login_.callback.tsx
Normal file
45
src/routes/login_.callback.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
Reference in New Issue
Block a user