Compare commits

..

2 Commits

Author SHA1 Message Date
70d4743eef feat: hook syncUser to login callback
Co-authored-by: Ona <no-reply@ona.com>
2025-09-15 22:58:23 +00:00
72ac4df486 feat: add auth to convex fns 2025-09-15 21:44:41 +00:00
15 changed files with 262 additions and 44 deletions

View File

@@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.27.0", "convex": "^1.27.0",
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0", "jotai": "^2.14.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -453,6 +454,8 @@
"convex": ["convex@1.27.0", "", { "dependencies": { "esbuild": "0.25.4", "jwt-decode": "^4.0.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-IHkqZX3GtY4nKFPTAR4mvWHHhDiQX9PM7EjpEv0pJWoMoq0On6oOL3iZ7Xz4Ls96dF7WJd4AjfitJsg2hUnLSQ=="], "convex": ["convex@1.27.0", "", { "dependencies": { "esbuild": "0.25.4", "jwt-decode": "^4.0.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-IHkqZX3GtY4nKFPTAR4mvWHHhDiQX9PM7EjpEv0pJWoMoq0On6oOL3iZ7Xz4Ls96dF7WJd4AjfitJsg2hUnLSQ=="],
"convex-helpers": ["convex-helpers@0.1.104", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.24.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.22.4 || ^4.0.15" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-7CYvx7T3K6n+McDTK4ZQaQNNGBzq5aWezpjzsKbOxPXx7oNcTP9wrpef3JxeXWFzkByJv5hRCjseh9B7eNJ7Ig=="],
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -523,7 +526,7 @@
"jotai": ["jotai@2.14.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-JQkNkTnqjk1BlSUjHfXi+pGG/573bVN104gp6CymhrWDseZGDReTNniWrLhJ+zXbM6pH+82+UNJ2vwYQUkQMWQ=="], "jotai": ["jotai@2.14.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-JQkNkTnqjk1BlSUjHfXi+pGG/573bVN104gp6CymhrWDseZGDReTNniWrLhJ+zXbM6pH+82+UNJ2vwYQUkQMWQ=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -679,8 +682,6 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -695,6 +696,8 @@
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"tsdown/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "tsdown/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],

View File

@@ -14,8 +14,10 @@ import type {
FunctionReference, FunctionReference,
} from "convex/server"; } from "convex/server";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as functions from "../functions.js";
import type * as model_directories from "../model/directories.js"; import type * as model_directories from "../model/directories.js";
import type * as model_error from "../model/error.js"; import type * as model_error from "../model/error.js";
import type * as model_user from "../model/user.js";
import type * as users from "../users.js"; import type * as users from "../users.js";
/** /**
@@ -28,8 +30,10 @@ import type * as users from "../users.js";
*/ */
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
files: typeof files; files: typeof files;
functions: typeof functions;
"model/directories": typeof model_directories; "model/directories": typeof model_directories;
"model/error": typeof model_error; "model/error": typeof model_error;
"model/user": typeof model_user;
users: typeof users; users: typeof users;
}>; }>;
export declare const api: FilterApi< export declare const api: FilterApi<

View File

@@ -1,16 +1,17 @@
import { v } from "convex/values" import { v } from "convex/values"
import type { Id } from "./_generated/dataModel" import type { Id } from "./_generated/dataModel"
import { mutation, query } from "./_generated/server" import { authenticatedMutation, authenticatedQuery } from "./functions"
import type { DirectoryItem } from "./model/directories" import type { DirectoryItem } from "./model/directories"
import * as Directories from "./model/directories" import * as Directories from "./model/directories"
export const generateUploadUrl = mutation({ export const generateUploadUrl = authenticatedMutation({
handler: async (ctx) => { handler: async (ctx) => {
// ctx.user and ctx.identity are automatically available
return await ctx.storage.generateUploadUrl() return await ctx.storage.generateUploadUrl()
}, },
}) })
export const fetchFiles = query({ export const fetchFiles = authenticatedQuery({
args: { args: {
directoryId: v.optional(v.id("directories")), directoryId: v.optional(v.id("directories")),
}, },
@@ -18,49 +19,51 @@ export const fetchFiles = query({
return await ctx.db return await ctx.db
.query("files") .query("files")
.withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId)) .withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId))
.filter((q) => q.eq(q.field("userId"), ctx.user._id))
.collect() .collect()
}, },
}) })
export const fetchDirectoryContent = query({ export const fetchDirectoryContent = authenticatedQuery({
args: { args: {
directoryId: v.optional(v.id("directories")), directoryId: v.optional(v.id("directories")),
}, },
handler: async (ctx, { directoryId }): Promise<DirectoryItem[]> => { handler: async (ctx, { directoryId }): Promise<DirectoryItem[]> => {
return await Directories.fetchContent(ctx, directoryId) return await Directories.fetchContent(ctx, directoryId, ctx.user._id)
}, },
}) })
export const createDirectory = mutation({ export const createDirectory = authenticatedMutation({
args: { args: {
name: v.string(), name: v.string(),
directoryId: v.optional(v.id("directories")), directoryId: v.optional(v.id("directories")),
}, },
handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => { handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => {
return await Directories.create(ctx, { name, parentId: directoryId }) return await Directories.create(ctx, {
name,
parentId: directoryId,
userId: ctx.user._id,
})
}, },
}) })
export const saveFile = mutation({ export const saveFile = authenticatedMutation({
args: { args: {
name: v.string(), name: v.string(),
size: v.number(), size: v.number(),
directoryId: v.optional(v.id("directories")), directoryId: v.optional(v.id("directories")),
storageId: v.id("_storage"), storageId: v.id("_storage"),
userId: v.id("users"),
mimeType: v.optional(v.string()), mimeType: v.optional(v.string()),
}, },
handler: async ( handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => {
ctx,
{ name, storageId, directoryId, userId, size, mimeType },
) => {
const now = new Date().toISOString() const now = new Date().toISOString()
await ctx.db.insert("files", { await ctx.db.insert("files", {
name, name,
size, size,
storageId, storageId,
directoryId, directoryId,
userId, userId: ctx.user._id,
mimeType, mimeType,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -68,25 +71,37 @@ export const saveFile = mutation({
}, },
}) })
export const moveToTrash = mutation({ export const moveToTrash = authenticatedMutation({
args: { args: {
kind: v.union(v.literal("file"), v.literal("directory")), kind: v.union(v.literal("file"), v.literal("directory")),
itemId: v.union(v.id("files"), v.id("directories")), itemId: v.union(v.id("files"), v.id("directories")),
}, },
handler: async (ctx, { itemId, kind }) => { handler: async (ctx, { itemId, kind }) => {
// Verify ownership before allowing deletion
switch (kind) { switch (kind) {
case "file": case "file": {
const file = await ctx.db.get(itemId as Id<"files">)
if (!file || file.userId !== ctx.user._id) {
throw new Error("File not found or access denied")
}
await ctx.db.patch(itemId, { await ctx.db.patch(itemId, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
}) })
break break
case "directory": }
case "directory": {
const directory = await ctx.db.get(itemId as Id<"directories">)
if (!directory || directory.userId !== ctx.user._id) {
throw new Error("Directory not found or access denied")
}
await Directories.moveToTrashRecursive( await Directories.moveToTrashRecursive(
ctx, ctx,
itemId as Id<"directories">, itemId as Id<"directories">,
ctx.user._id,
) )
break break
} }
}
return itemId return itemId
}, },

46
convex/functions.ts Normal file
View File

@@ -0,0 +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,
customCtx(async (ctx: QueryCtx) => {
const user = await userOrThrow(ctx)
const identity = await userIdentityOrThrow(ctx)
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,
customCtx(async (ctx: MutationCtx) => {
const user = await userOrThrow(ctx)
const identity = await userIdentityOrThrow(ctx)
return { user, identity }
}),
)

View File

@@ -18,6 +18,7 @@ export type DirectoryItemKind = DirectoryItem["kind"]
export async function fetchContent( export async function fetchContent(
ctx: QueryCtx, ctx: QueryCtx,
directoryId?: Id<"directories">, directoryId?: Id<"directories">,
userId?: Id<"users">,
): Promise<DirectoryItem[]> { ): Promise<DirectoryItem[]> {
const [files, directories] = await Promise.all([ const [files, directories] = await Promise.all([
ctx.db ctx.db
@@ -25,11 +26,13 @@ export async function fetchContent(
.withIndex("byDirectoryId", (q) => .withIndex("byDirectoryId", (q) =>
q.eq("directoryId", directoryId).eq("deletedAt", undefined), q.eq("directoryId", directoryId).eq("deletedAt", undefined),
) )
.filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null))
.collect(), .collect(),
ctx.db ctx.db
.query("directories") .query("directories")
.withIndex("byParentId", (q) => q.eq("parentId", directoryId)) .withIndex("byParentId", (q) => q.eq("parentId", directoryId))
.filter((q) => q.eq(q.field("deletedAt"), undefined)) .filter((q) => q.eq(q.field("deletedAt"), undefined))
.filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null))
.collect(), .collect(),
]) ])
@@ -46,13 +49,25 @@ export async function fetchContent(
export async function create( export async function create(
ctx: MutationCtx, ctx: MutationCtx,
{ name, parentId }: { name: string; parentId?: Id<"directories"> }, { name, parentId, userId }: { name: string; parentId?: Id<"directories">; userId: Id<"users"> },
): Promise<Id<"directories">> { ): Promise<Id<"directories">> {
// Check if parent directory exists and belongs to user
if (parentId) {
const parentDir = await ctx.db.get(parentId)
if (!parentDir || parentDir.userId !== userId) {
throw Err.create(
Err.Code.DirectoryExists,
"Parent directory not found or access denied",
)
}
}
const existing = await ctx.db const existing = await ctx.db
.query("directories") .query("directories")
.withIndex("uniqueDirectoryInDirectory", (q) => .withIndex("uniqueDirectoryInDirectory", (q) =>
q.eq("parentId", parentId).eq("name", name), q.eq("parentId", parentId).eq("name", name),
) )
.filter((q) => q.eq(q.field("userId"), userId))
.first() .first()
if (existing) { if (existing) {
@@ -66,6 +81,7 @@ export async function create(
return await ctx.db.insert("directories", { return await ctx.db.insert("directories", {
name, name,
parentId, parentId,
userId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@@ -74,6 +90,7 @@ export async function create(
export async function moveToTrashRecursive( export async function moveToTrashRecursive(
ctx: MutationCtx, ctx: MutationCtx,
directoryId: Id<"directories">, directoryId: Id<"directories">,
userId: Id<"users">,
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -93,6 +110,7 @@ export async function moveToTrashRecursive(
.eq("directoryId", currentDirectoryId) .eq("directoryId", currentDirectoryId)
.eq("deletedAt", undefined), .eq("deletedAt", undefined),
) )
.filter((q) => q.eq(q.field("userId"), userId))
.collect() .collect()
for (const file of files) { for (const file of files) {
@@ -104,6 +122,7 @@ export async function moveToTrashRecursive(
.withIndex("byParentId", (q) => .withIndex("byParentId", (q) =>
q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined), q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined),
) )
.filter((q) => q.eq(q.field("userId"), userId))
.collect() .collect()
for (const subdirectory of subdirectories) { for (const subdirectory of subdirectories) {

View File

@@ -4,19 +4,16 @@ export enum Code {
DirectoryExists = "DirectoryExists", DirectoryExists = "DirectoryExists",
FileExists = "FileExists", FileExists = "FileExists",
Internal = "Internal", Internal = "Internal",
Unauthenticated = "Unauthenticated",
} }
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,

46
convex/model/user.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { Id } from "../_generated/dataModel"
import type { MutationCtx, QueryCtx } from "../_generated/server"
import type { AuthenticatedMutationCtx } from "../functions"
import * as Err from "./error"
/**
* Get the current authenticated user identity
* Throws an error if the user is not authenticated */
export async function userIdentityOrThrow(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity()
if (!identity) {
throw Err.create(Err.Code.Unauthenticated, "Not authenticated")
}
return identity
}
/**
* Get internal user document from JWT authentication
* Throws an error if the user is not authenticated
*/
export async function userOrThrow(ctx: QueryCtx | MutationCtx) {
const identity = await userIdentityOrThrow(ctx)
// Look for existing user by JWT subject
const user = await ctx.db
.query("users")
.withIndex("byJwtSubject", (q) => q.eq("jwtSubject", identity.subject))
.first()
if (!user) {
throw Err.create(
Err.Code.Unauthenticated,
"User not found - please sync user first",
)
}
return user
}
export async function register(ctx: AuthenticatedMutationCtx) {
await ctx.db.insert("users", {
jwtSubject: ctx.identity.subject,
})
}

View File

@@ -3,11 +3,8 @@ import { v } from "convex/values"
const schema = defineSchema({ const schema = defineSchema({
users: defineTable({ users: defineTable({
name: v.string(), jwtSubject: v.string(),
email: v.string(), }).index("byJwtSubject", ["jwtSubject"]),
createdAt: v.string(),
updatedAt: v.string(),
}),
files: defineTable({ files: defineTable({
storageId: v.id("_storage"), storageId: v.id("_storage"),
userId: v.id("users"), userId: v.id("users"),
@@ -20,6 +17,7 @@ const schema = defineSchema({
deletedAt: v.optional(v.string()), deletedAt: v.optional(v.string()),
}) })
.index("byDirectoryId", ["directoryId", "deletedAt"]) .index("byDirectoryId", ["directoryId", "deletedAt"])
.index("byUserId", ["userId", "deletedAt"])
.index("byDeletedAt", ["deletedAt"]) .index("byDeletedAt", ["deletedAt"])
.index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]), .index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]),
directories: defineTable({ directories: defineTable({
@@ -30,7 +28,7 @@ const schema = defineSchema({
updatedAt: v.string(), updatedAt: v.string(),
deletedAt: v.optional(v.string()), deletedAt: v.optional(v.string()),
}) })
.index("byUserId", ["userId"]) .index("byUserId", ["userId", "deletedAt"])
.index("byParentId", ["parentId", "deletedAt"]) .index("byParentId", ["parentId", "deletedAt"])
.index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]), .index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]),
}) })

View File

@@ -1,10 +1,32 @@
import type { Id } from "./_generated/dataModel" import { mutation } from "./_generated/server"
import { query } from "./_generated/server" import { authenticatedQuery } from "./functions"
import * as Err from "./model/error"
export const getCurrentUser = query({ export const getCurrentUser = authenticatedQuery({
handler: async (ctx) => { handler: async (ctx) => {
return await ctx.db.get( // ctx.user is the internal Convex user document
"j574n657f521n19v1stnr88ysd7qhbs1" as Id<"users">, return ctx.user
) },
})
export const syncUser = mutation({
handler: async (ctx) => {
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,
})
}
}, },
}) })

View File

@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.27.0", "convex": "^1.27.0",
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0", "jotai": "^2.14.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

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>
)
}