feat: add auth to convex fns
This commit is contained in:
9
bun.lock
9
bun.lock
@@ -21,6 +21,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.27.0",
|
||||
"convex-helpers": "^0.1.104",
|
||||
"jotai": "^2.14.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -679,8 +682,6 @@
|
||||
|
||||
"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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -14,8 +14,10 @@ import type {
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
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_error from "../model/error.js";
|
||||
import type * as model_user from "../model/user.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
/**
|
||||
@@ -28,8 +30,10 @@ import type * as users from "../users.js";
|
||||
*/
|
||||
declare const fullApi: ApiFromModules<{
|
||||
files: typeof files;
|
||||
functions: typeof functions;
|
||||
"model/directories": typeof model_directories;
|
||||
"model/error": typeof model_error;
|
||||
"model/user": typeof model_user;
|
||||
users: typeof users;
|
||||
}>;
|
||||
export declare const api: FilterApi<
|
||||
|
@@ -1,16 +1,17 @@
|
||||
import { v } from "convex/values"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { authenticatedMutation, authenticatedQuery } from "./functions"
|
||||
import type { DirectoryItem } from "./model/directories"
|
||||
import * as Directories from "./model/directories"
|
||||
|
||||
export const generateUploadUrl = mutation({
|
||||
export const generateUploadUrl = authenticatedMutation({
|
||||
handler: async (ctx) => {
|
||||
// ctx.user and ctx.identity are automatically available
|
||||
return await ctx.storage.generateUploadUrl()
|
||||
},
|
||||
})
|
||||
|
||||
export const fetchFiles = query({
|
||||
export const fetchFiles = authenticatedQuery({
|
||||
args: {
|
||||
directoryId: v.optional(v.id("directories")),
|
||||
},
|
||||
@@ -18,49 +19,51 @@ export const fetchFiles = query({
|
||||
return await ctx.db
|
||||
.query("files")
|
||||
.withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId))
|
||||
.filter((q) => q.eq(q.field("userId"), ctx.user._id))
|
||||
.collect()
|
||||
},
|
||||
})
|
||||
|
||||
export const fetchDirectoryContent = query({
|
||||
export const fetchDirectoryContent = authenticatedQuery({
|
||||
args: {
|
||||
directoryId: v.optional(v.id("directories")),
|
||||
},
|
||||
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: {
|
||||
name: v.string(),
|
||||
directoryId: v.optional(v.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: {
|
||||
name: v.string(),
|
||||
size: v.number(),
|
||||
directoryId: v.optional(v.id("directories")),
|
||||
storageId: v.id("_storage"),
|
||||
userId: v.id("users"),
|
||||
mimeType: v.optional(v.string()),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
{ name, storageId, directoryId, userId, size, mimeType },
|
||||
) => {
|
||||
handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await ctx.db.insert("files", {
|
||||
name,
|
||||
size,
|
||||
storageId,
|
||||
directoryId,
|
||||
userId,
|
||||
userId: ctx.user._id,
|
||||
mimeType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -68,24 +71,36 @@ export const saveFile = mutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const moveToTrash = mutation({
|
||||
export const moveToTrash = authenticatedMutation({
|
||||
args: {
|
||||
kind: v.union(v.literal("file"), v.literal("directory")),
|
||||
itemId: v.union(v.id("files"), v.id("directories")),
|
||||
},
|
||||
handler: async (ctx, { itemId, kind }) => {
|
||||
// Verify ownership before allowing deletion
|
||||
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, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
})
|
||||
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(
|
||||
ctx,
|
||||
itemId as Id<"directories">,
|
||||
ctx.user._id,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return itemId
|
||||
|
39
convex/functions.ts
Normal file
39
convex/functions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
customMutation,
|
||||
customQuery,
|
||||
} from "convex-helpers/server/customFunctions"
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { userIdentityOrThrow, userOrThrow } from "./model/user"
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const user = await userOrThrow(ctx)
|
||||
const identity = await userIdentityOrThrow(ctx)
|
||||
return {
|
||||
ctx: { ...ctx, user, identity },
|
||||
args,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const user = await userOrThrow(ctx)
|
||||
const identity = await userIdentityOrThrow(ctx)
|
||||
|
||||
return {
|
||||
ctx: { ...ctx, user, identity },
|
||||
args,
|
||||
}
|
||||
},
|
||||
})
|
@@ -18,6 +18,7 @@ export type DirectoryItemKind = DirectoryItem["kind"]
|
||||
export async function fetchContent(
|
||||
ctx: QueryCtx,
|
||||
directoryId?: Id<"directories">,
|
||||
userId?: Id<"users">,
|
||||
): Promise<DirectoryItem[]> {
|
||||
const [files, directories] = await Promise.all([
|
||||
ctx.db
|
||||
@@ -25,11 +26,13 @@ export async function fetchContent(
|
||||
.withIndex("byDirectoryId", (q) =>
|
||||
q.eq("directoryId", directoryId).eq("deletedAt", undefined),
|
||||
)
|
||||
.filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("directories")
|
||||
.withIndex("byParentId", (q) => q.eq("parentId", directoryId))
|
||||
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
||||
.filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null))
|
||||
.collect(),
|
||||
])
|
||||
|
||||
@@ -46,13 +49,25 @@ export async function fetchContent(
|
||||
|
||||
export async function create(
|
||||
ctx: MutationCtx,
|
||||
{ name, parentId }: { name: string; parentId?: Id<"directories"> },
|
||||
{ name, parentId, userId }: { name: string; parentId?: Id<"directories">; userId: Id<"users"> },
|
||||
): 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
|
||||
.query("directories")
|
||||
.withIndex("uniqueDirectoryInDirectory", (q) =>
|
||||
q.eq("parentId", parentId).eq("name", name),
|
||||
)
|
||||
.filter((q) => q.eq(q.field("userId"), userId))
|
||||
.first()
|
||||
|
||||
if (existing) {
|
||||
@@ -66,6 +81,7 @@ export async function create(
|
||||
return await ctx.db.insert("directories", {
|
||||
name,
|
||||
parentId,
|
||||
userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -74,6 +90,7 @@ export async function create(
|
||||
export async function moveToTrashRecursive(
|
||||
ctx: MutationCtx,
|
||||
directoryId: Id<"directories">,
|
||||
userId: Id<"users">,
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
@@ -93,6 +110,7 @@ export async function moveToTrashRecursive(
|
||||
.eq("directoryId", currentDirectoryId)
|
||||
.eq("deletedAt", undefined),
|
||||
)
|
||||
.filter((q) => q.eq(q.field("userId"), userId))
|
||||
.collect()
|
||||
|
||||
for (const file of files) {
|
||||
@@ -104,6 +122,7 @@ export async function moveToTrashRecursive(
|
||||
.withIndex("byParentId", (q) =>
|
||||
q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined),
|
||||
)
|
||||
.filter((q) => q.eq(q.field("userId"), userId))
|
||||
.collect()
|
||||
|
||||
for (const subdirectory of subdirectories) {
|
||||
|
@@ -4,6 +4,7 @@ export enum Code {
|
||||
DirectoryExists = "DirectoryExists",
|
||||
FileExists = "FileExists",
|
||||
Internal = "Internal",
|
||||
Unauthenticated = "Unauthenticated",
|
||||
}
|
||||
|
||||
export type ApplicationError = ConvexError<{ code: Code; message: string }>
|
||||
|
38
convex/model/user.ts
Normal file
38
convex/model/user.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { MutationCtx, QueryCtx } from "../_generated/server"
|
||||
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
|
||||
}
|
@@ -3,11 +3,8 @@ import { v } from "convex/values"
|
||||
|
||||
const schema = defineSchema({
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
createdAt: v.string(),
|
||||
updatedAt: v.string(),
|
||||
}),
|
||||
jwtSubject: v.optional(v.string()), // JWT subject from identity provider (optional for migration)
|
||||
}).index("byJwtSubject", ["jwtSubject"]), // Unique index for JWT subject lookup
|
||||
files: defineTable({
|
||||
storageId: v.id("_storage"),
|
||||
userId: v.id("users"),
|
||||
@@ -20,6 +17,7 @@ const schema = defineSchema({
|
||||
deletedAt: v.optional(v.string()),
|
||||
})
|
||||
.index("byDirectoryId", ["directoryId", "deletedAt"])
|
||||
.index("byUserId", ["userId", "deletedAt"])
|
||||
.index("byDeletedAt", ["deletedAt"])
|
||||
.index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]),
|
||||
directories: defineTable({
|
||||
@@ -30,7 +28,7 @@ const schema = defineSchema({
|
||||
updatedAt: v.string(),
|
||||
deletedAt: v.optional(v.string()),
|
||||
})
|
||||
.index("byUserId", ["userId"])
|
||||
.index("byUserId", ["userId", "deletedAt"])
|
||||
.index("byParentId", ["parentId", "deletedAt"])
|
||||
.index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]),
|
||||
})
|
||||
|
@@ -1,10 +1,25 @@
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import { query } from "./_generated/server"
|
||||
import { mutation } from "./_generated/server"
|
||||
import { authenticatedQuery } from "./functions"
|
||||
import { getOrCreateUser, userIdentityOrThrow } from "./model/user"
|
||||
|
||||
export const getCurrentUser = query({
|
||||
export const getCurrentUser = authenticatedQuery({
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.get(
|
||||
"j574n657f521n19v1stnr88ysd7qhbs1" as Id<"users">,
|
||||
)
|
||||
// ctx.user is the internal Convex user document
|
||||
return ctx.user
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@@ -26,6 +26,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.27.0",
|
||||
"convex-helpers": "^0.1.104",
|
||||
"jotai": "^2.14.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
Reference in New Issue
Block a user