diff --git a/apps/mesh/migrations/seeds/benchmark.ts b/apps/mesh/migrations/seeds/benchmark.ts index df1d31e1b..93da763ac 100644 --- a/apps/mesh/migrations/seeds/benchmark.ts +++ b/apps/mesh/migrations/seeds/benchmark.ts @@ -55,8 +55,13 @@ export async function seed(db: Kysely): Promise { .values({ id: userId, email: "benchmark@test.local", + emailVerified: 1, name: "Benchmark User", + image: null, role: "admin", + banned: null, + banReason: null, + banExpires: null, createdAt: now, updatedAt: now, }) diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index 70dd052f9..c2f43674e 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -15,7 +15,9 @@ import { verifyMeshToken } from "../auth/jwt"; import { CredentialVault } from "../encryption/credential-vault"; import { ConnectionStorage } from "../storage/connection"; import { GatewayStorage } from "../storage/gateway"; +import { SqlMonitoringStorage } from "../storage/monitoring"; import { OrganizationSettingsStorage } from "../storage/organization-settings"; +import { UserStorage } from "../storage/user"; import type { Database, Permission } from "../storage/types"; import { AccessControl } from "./access-control"; import type { @@ -339,7 +341,6 @@ function createBoundAuthClient(ctx: AuthContext): BoundAuthClient { } // Import built-in roles from separate module to avoid circular dependency -import { SqlMonitoringStorage } from "@/storage/monitoring"; import { BUILTIN_ROLES } from "../auth/roles"; import { WellKnownMCPId } from "./well-known-mcp"; import { ConnectionEntity } from "@/tools/connection/schema"; @@ -622,6 +623,7 @@ export function createMeshContextFactory( organizationSettings: new OrganizationSettingsStorage(config.db), monitoring: new SqlMonitoringStorage(config.db), gateways: new GatewayStorage(config.db), + users: new UserStorage(config.db), // Note: Organizations, teams, members, roles managed by Better Auth organization plugin // Note: Policies handled by Better Auth permissions directly // Note: API keys (tokens) managed by Better Auth API Key plugin diff --git a/apps/mesh/src/core/define-tool.test.ts b/apps/mesh/src/core/define-tool.test.ts index db713bf12..8b2b5f90f 100644 --- a/apps/mesh/src/core/define-tool.test.ts +++ b/apps/mesh/src/core/define-tool.test.ts @@ -44,6 +44,7 @@ const createMockContext = (): MeshContext => ({ }), } as never, gateways: null as never, + users: null as never, }, vault: null as never, authInstance: null as never, diff --git a/apps/mesh/src/core/mesh-context.test.ts b/apps/mesh/src/core/mesh-context.test.ts index c4e95723b..81dd85be0 100644 --- a/apps/mesh/src/core/mesh-context.test.ts +++ b/apps/mesh/src/core/mesh-context.test.ts @@ -18,6 +18,7 @@ const createMockContext = (overrides?: Partial): MeshContext => ({ organizationSettings: null as never, monitoring: null as never, gateways: null as never, + users: null as never, }, vault: null as never, authInstance: null as never, diff --git a/apps/mesh/src/core/mesh-context.ts b/apps/mesh/src/core/mesh-context.ts index f556001b8..523d5031d 100644 --- a/apps/mesh/src/core/mesh-context.ts +++ b/apps/mesh/src/core/mesh-context.ts @@ -224,6 +224,7 @@ import type { ConnectionStorage } from "../storage/connection"; import type { GatewayStorage } from "../storage/gateway"; import type { SqlMonitoringStorage } from "../storage/monitoring"; import type { OrganizationSettingsStorage } from "../storage/organization-settings"; +import type { UserStorage } from "../storage/user"; // Better Auth instance type - flexible for testing // In production, this is the actual Better Auth instance @@ -243,6 +244,7 @@ export interface MeshStorage { organizationSettings: OrganizationSettingsStorage; monitoring: SqlMonitoringStorage; gateways: GatewayStorage; + users: UserStorage; } // ============================================================================ diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index ee69f06c7..6e320c27c 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -65,6 +65,23 @@ export interface UserTable { updatedAt: ColumnType; } +/** + * Better Auth core user table definition (singular: "user") + * Includes avatar image and other auth-related fields. + */ +export interface BetterAuthUserTable { + id: string; + email: string; + emailVerified: number; + name: string; + image: string | null; + role: string | null; + banned: number | null; + banReason: string | null; + banExpires: string | null; + createdAt: ColumnType; + updatedAt: ColumnType; +} // ============================================================================ // Runtime Entity Types (for application code) // ============================================================================ @@ -81,6 +98,13 @@ export interface User { updatedAt: Date | string; } +/** + * User entity with image - Extended representation including Better Auth avatar + */ +export interface UserWithImage extends User { + image?: string; +} + /** * Organization entity - Runtime representation (from Better Auth) * Better Auth organization plugin provides this data @@ -633,6 +657,7 @@ export interface GatewayWithConnections extends Gateway { export interface Database { // Core tables (all within organization scope) users: UserTable; // System users + user: BetterAuthUserTable; // Better Auth core table (singular) connections: MCPConnectionTable; // MCP connections (organization-scoped) organization_settings: OrganizationSettingsTable; // Organization-level configuration api_keys: ApiKeyTable; // Better Auth API keys diff --git a/apps/mesh/src/storage/user.ts b/apps/mesh/src/storage/user.ts new file mode 100644 index 000000000..cd1d111df --- /dev/null +++ b/apps/mesh/src/storage/user.ts @@ -0,0 +1,77 @@ +/** + * User Storage + * + * Provides access to Better Auth user data with organization-scoped access control. + * Users can only fetch data for users in their shared organizations. + */ + +import type { Kysely } from "kysely"; +import type { Database, UserWithImage } from "./types"; + +/** + * User storage interface + */ +export interface UserStoragePort { + findById( + userId: string, + requestingUserId: string, + ): Promise; +} + +/** + * User storage implementation using Kysely + */ +export class UserStorage implements UserStoragePort { + constructor(private db: Kysely) {} + + /** + * Find a user by ID, ensuring the requesting user shares at least one organization + * + * @param userId - The user ID to fetch + * @param requestingUserId - The user making the request (for authorization) + * @returns User data or null if not found/unauthorized + */ + async findById( + userId: string, + requestingUserId: string, + ): Promise { + // Query the user table, but only if the requesting user shares an organization + // with the target user (via the member table) + const result = await this.db + .selectFrom("user") + .select([ + "user.id", + "user.name", + "user.email", + "user.image", + "user.createdAt", + "user.updatedAt", + ]) + .where("user.id", "=", userId) + .where((eb) => + eb.exists( + eb + .selectFrom("member as m1") + .innerJoin("member as m2", "m1.organizationId", "m2.organizationId") + .select("m1.id") + .where("m1.userId", "=", userId) + .where("m2.userId", "=", requestingUserId), + ), + ) + .executeTakeFirst(); + + if (!result) { + return null; + } + + return { + id: result.id, + name: result.name, + email: result.email, + role: "", // Not exposed in this context + createdAt: result.createdAt, + updatedAt: result.updatedAt, + image: result.image ?? undefined, + }; + } +} diff --git a/apps/mesh/src/tools/connection/connection-tools.test.ts b/apps/mesh/src/tools/connection/connection-tools.test.ts index cc7961e52..0b780d2a5 100644 --- a/apps/mesh/src/tools/connection/connection-tools.test.ts +++ b/apps/mesh/src/tools/connection/connection-tools.test.ts @@ -71,6 +71,7 @@ describe("Connection Tools", () => { } as never, monitoring: null as never, gateways: null as never, + users: null as never, }, vault: null as never, authInstance: null as never, diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 17f694011..7e18375d3 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -14,6 +14,7 @@ import * as EventBusTools from "./eventbus"; import * as GatewayTools from "./gateway"; import * as MonitoringTools from "./monitoring"; import * as OrganizationTools from "./organization"; +import * as UserTools from "./user"; import { ToolName } from "./registry"; // All available tools - types are inferred @@ -65,6 +66,9 @@ export const ALL_TOOLS = [ EventBusTools.EVENT_ACK, EventBusTools.EVENT_SUBSCRIPTION_LIST, EventBusTools.EVENT_SYNC_SUBSCRIPTIONS, + + // User tools + UserTools.USER_GET, ] as const satisfies { name: ToolName }[]; export type MCPMeshTools = typeof ALL_TOOLS; diff --git a/apps/mesh/src/tools/organization/organization-tools.test.ts b/apps/mesh/src/tools/organization/organization-tools.test.ts index 5d43f90d4..7af16ebd2 100644 --- a/apps/mesh/src/tools/organization/organization-tools.test.ts +++ b/apps/mesh/src/tools/organization/organization-tools.test.ts @@ -186,6 +186,7 @@ const createMockContext = ( } as never, monitoring: null as never, gateways: null as never, + users: null as never, }, vault: null as never, authInstance: authInstance as unknown as BetterAuthInstance, diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index 4363c8845..d2fc5269a 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -23,6 +23,7 @@ export type ToolCategory = | "Connections" | "Gateways" | "Monitoring" + | "Users" | "API Keys" | "Event Bus"; @@ -73,6 +74,8 @@ const ALL_TOOL_NAMES = [ "EVENT_ACK", "EVENT_SUBSCRIPTION_LIST", "EVENT_SYNC_SUBSCRIPTIONS", + // User tools + "USER_GET", ] as const; /** @@ -305,6 +308,12 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Sync subscriptions to desired state", category: "Event Bus", }, + // User tools + { + name: "USER_GET", + description: "Get a user by id", + category: "Users", + }, ]; /** @@ -347,6 +356,8 @@ const TOOL_LABELS: Record = { EVENT_ACK: "Acknowledge event delivery", EVENT_SUBSCRIPTION_LIST: "List event subscriptions", EVENT_SYNC_SUBSCRIPTIONS: "Sync subscriptions to desired state", + + USER_GET: "Get user by id", }; // ============================================================================ @@ -362,6 +373,7 @@ export function getToolsByCategory() { Connections: [], Gateways: [], Monitoring: [], + Users: [], "API Keys": [], "Event Bus": [], }; diff --git a/apps/mesh/src/tools/user/get.ts b/apps/mesh/src/tools/user/get.ts new file mode 100644 index 000000000..db5b70632 --- /dev/null +++ b/apps/mesh/src/tools/user/get.ts @@ -0,0 +1,55 @@ +/** + * USER_GET Tool + * + * Fetch a user's public profile (name/email/avatar) by user id. + * Access is restricted to users in shared organizations. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { getUserId, requireAuth } from "../../core/mesh-context"; + +const InputSchema = z.object({ + id: z.string().min(1), +}); + +const OutputSchema = z.object({ + user: z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), + }) + .nullable(), +}); + +export const USER_GET = defineTool({ + name: "USER_GET", + description: "Get a user by id (restricted to shared organizations)", + inputSchema: InputSchema, + outputSchema: OutputSchema, + handler: async (input, ctx) => { + await ctx.access.check(); + requireAuth(ctx); + + const requesterUserId = getUserId(ctx); + if (!requesterUserId) { + throw new Error("Authentication required"); + } + + const user = await ctx.storage.users.findById(input.id, requesterUserId); + if (!user) { + return { user: null }; + } + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + image: user.image ?? null, + }, + }; + }, +}); diff --git a/apps/mesh/src/tools/user/index.ts b/apps/mesh/src/tools/user/index.ts new file mode 100644 index 000000000..bae9d63f9 --- /dev/null +++ b/apps/mesh/src/tools/user/index.ts @@ -0,0 +1 @@ +export { USER_GET } from "./get"; diff --git a/apps/mesh/src/web/components/collections/collections-list.tsx b/apps/mesh/src/web/components/collections/collections-list.tsx index 4a3ff18d7..f65d2c4c4 100644 --- a/apps/mesh/src/web/components/collections/collections-list.tsx +++ b/apps/mesh/src/web/components/collections/collections-list.tsx @@ -22,6 +22,7 @@ import { } from "@untitledui/icons"; import type { JsonSchema } from "@/web/utils/constants"; import { IntegrationIcon } from "../integration-icon.tsx"; +import { User } from "../user/user.tsx"; // Field names that should be rendered as icon columns const ICON_FIELD_NAMES = ["icon", "avatar", "logo"]; @@ -373,6 +374,21 @@ function generateColumnsFromSchema( }; } + // Handle user fields (created_by, updated_by) + if (key === "created_by" || key === "updated_by") { + return { + id: key, + header: key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "), + render: (row) => { + const val = row[key as keyof T]; + if (!val) return "—"; + return ; + }, + sortable: isSortable, + cellClassName: "max-w-[250px]", + }; + } + // Handle date fields if (fieldSchema?.format === "date-time" || key.endsWith("_at")) { return { diff --git a/apps/mesh/src/web/components/user/user.tsx b/apps/mesh/src/web/components/user/user.tsx new file mode 100644 index 000000000..90d90ba75 --- /dev/null +++ b/apps/mesh/src/web/components/user/user.tsx @@ -0,0 +1,87 @@ +/** + * User Display Component + * + * Displays user information (avatar + name/email) by fetching user data from the API. + * Handles loading and error states gracefully. + */ + +import { Avatar, type AvatarProps } from "@deco/ui/components/avatar.tsx"; +import { useUserById } from "../../hooks/use-user-by-id"; + +export interface UserProps { + /** + * User ID to display + */ + id: string; + /** + * Avatar size (default: "xs") + */ + size?: AvatarProps["size"]; + /** + * Whether to show the email below the name (default: false) + */ + showEmail?: boolean; + /** + * Additional CSS classes + */ + className?: string; +} + +/** + * User component - displays user avatar and name/email + * + * Fetches user data from the API and renders it with proper loading and error states. + */ +export function User({ + id, + size = "xs", + showEmail = false, + className, +}: UserProps) { + const { data: user, isLoading, isError } = useUserById(id); + + // Loading state + if (isLoading) { + return ( +
+ +
+
+ {showEmail && ( +
+ )} +
+
+ ); + } + + // Error or not found state + if (isError || !user) { + return ( +
+ +
+
Unknown User
+
+
+ ); + } + + // Success state + return ( +
+ +
+
{user.name}
+ {showEmail && ( +
{user.email}
+ )} +
+
+ ); +} diff --git a/apps/mesh/src/web/hooks/use-user-by-id.ts b/apps/mesh/src/web/hooks/use-user-by-id.ts new file mode 100644 index 000000000..1b93e2f8b --- /dev/null +++ b/apps/mesh/src/web/hooks/use-user-by-id.ts @@ -0,0 +1,43 @@ +/** + * User Hook using React Query + * + * Provides a React hook for fetching user data from the API. + * Users can only fetch data for users in their shared organizations. + */ + +import { useQuery } from "@tanstack/react-query"; +import { KEYS } from "../lib/query-keys"; +import { createToolCaller } from "../../tools/client"; + +/** + * User data returned by the API + */ +export interface UserData { + id: string; + name: string; + email: string; + image: string | null; +} + +type UserGetOutput = { user: UserData | null }; + +/** + * Hook for fetching user data + * + * @param userId - The user ID to fetch + * @returns React Query result with user data + */ +export function useUserById(userId: string) { + const toolCaller = createToolCaller<{ id: string }, UserGetOutput>(); + + return useQuery({ + queryKey: KEYS.user(userId), + queryFn: async () => { + const result = await toolCaller("USER_GET", { id: userId }); + return result.user; + }, + staleTime: 5 * 60 * 1000, // 5 minutes - users don't change frequently + retry: 1, + enabled: !!userId, + }); +} diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index cd2f9a3b7..1e1bdad34 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -135,4 +135,7 @@ export const KEYS = { // Connection resources (for gateway settings) connectionResources: (connectionId: string) => ["connection", connectionId, "resources"] as const, + + // User data + user: (userId: string) => ["user", userId] as const, } as const;