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",
|
"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=="],
|
||||||
|
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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<
|
||||||
|
@@ -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,24 +71,36 @@ 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
|
||||||
|
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(
|
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) {
|
||||||
|
@@ -4,6 +4,7 @@ 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 }>
|
||||||
|
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({
|
const schema = defineSchema({
|
||||||
users: defineTable({
|
users: defineTable({
|
||||||
name: v.string(),
|
jwtSubject: v.optional(v.string()), // JWT subject from identity provider (optional for migration)
|
||||||
email: v.string(),
|
}).index("byJwtSubject", ["jwtSubject"]), // Unique index for JWT subject lookup
|
||||||
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"]),
|
||||||
})
|
})
|
||||||
|
@@ -1,10 +1,25 @@
|
|||||||
import type { Id } from "./_generated/dataModel"
|
import { mutation } from "./_generated/server"
|
||||||
import { query } from "./_generated/server"
|
import { authenticatedQuery } from "./functions"
|
||||||
|
import { getOrCreateUser, userIdentityOrThrow } from "./model/user"
|
||||||
|
|
||||||
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) => {
|
||||||
|
// 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",
|
"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",
|
||||||
|
Reference in New Issue
Block a user