From 77e697db56329bed8b86751f93d9e0a04da373b9 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 14 Jan 2026 04:21:41 +1000 Subject: [PATCH] fix: make username compulsory and improve MCP error messages - Add profile settings page for username management - Add username prompt in dashboard when not set - Improve MCP endpoint to show specific error for missing user vs collection - Make sign-up flow retry username PATCH and redirect to setup if fails - Add backfill script for existing users without usernames - Add Profile link to dashboard sidebar --- apps/web/src/app/(auth)/sign-up/page.tsx | 50 ++- .../[username]/[slug]/[transport]/route.ts | 107 +++++- .../app/dashboard/collections/[id]/page.tsx | 22 ++ .../app/dashboard/settings/profile/page.tsx | 337 ++++++++++++++++++ .../components/dashboard/DashboardLayout.tsx | 1 + packages/db/package.json | 1 + packages/db/prisma/backfill-usernames.ts | 155 ++++++++ 7 files changed, 646 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/app/dashboard/settings/profile/page.tsx create mode 100644 packages/db/prisma/backfill-usernames.ts diff --git a/apps/web/src/app/(auth)/sign-up/page.tsx b/apps/web/src/app/(auth)/sign-up/page.tsx index 6ea7ae0..e050dc2 100644 --- a/apps/web/src/app/(auth)/sign-up/page.tsx +++ b/apps/web/src/app/(auth)/sign-up/page.tsx @@ -112,19 +112,47 @@ export default function SignUpPage() { } if (data) { - // Account created - now set the username - try { - const profileResponse = await fetch('/api/user/profile', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username }), - }); + // Account created - now set the username (REQUIRED) + let usernameSet = false; + let retries = 3; - if (!profileResponse.ok) { - console.warn('Failed to set username, user can set it later'); + while (!usernameSet && retries > 0) { + try { + const profileResponse = await fetch('/api/user/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); + + if (profileResponse.ok) { + usernameSet = true; + } else { + const errorData = await profileResponse.json(); + if (errorData.error === 'This username is already taken') { + // Username was taken between check and signup + setError('Username was taken. Please choose a different one and try signing in.'); + setLoading(false); + return; + } + retries--; + if (retries > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } catch { + retries--; + if (retries > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } } - } catch { - console.warn('Failed to set username, user can set it later'); + } + + if (!usernameSet) { + // Critical: Username couldn't be set, but account exists + // Redirect to profile page to set it manually + console.error('Failed to set username after retries'); + window.location.href = '/dashboard/settings/profile?setup=1'; + return; } // Redirect to verify email page diff --git a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts index 13b5805..2a63f49 100644 --- a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts +++ b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts @@ -42,20 +42,32 @@ function withTimeout(promise: Promise, ms: number, errorMessage: string): } /** - * Find a public collection by username and slug or ID - * Supports both human-readable slugs and collection IDs for flexibility + * Find a user by username */ -async function getPublicCollectionByUsernameAndSlugOrId(username: string, slugOrId: string) { +async function getUserByUsername(username: string) { + return withTimeout( + prisma.user.findUnique({ + where: { username }, + select: { id: true, username: true }, + }), + DB_TIMEOUT_MS, + `Database query timed out after ${DB_TIMEOUT_MS}ms` + ); +} + +/** + * Find a collection by user ID and slug or collection ID + * Supports both human-readable slugs and collection IDs for flexibility + * Returns the collection regardless of public/private status - authorization is checked separately + */ +async function getCollectionByUserIdAndSlugOrId(userId: string, slugOrId: string) { return withTimeout( prisma.collection.findFirst({ where: { - OR: [ - { slug: slugOrId, user: { username } }, - { id: slugOrId, user: { username } }, - ], - isPublic: true, + userId, + OR: [{ slug: slugOrId }, { id: slugOrId }], }, - select: { id: true, name: true, description: true, userId: true }, + select: { id: true, name: true, description: true, userId: true, isPublic: true }, }), DB_TIMEOUT_MS, `Database query timed out after ${DB_TIMEOUT_MS}ms` @@ -283,17 +295,54 @@ export async function POST(request: NextRequest, context: RouteContext): Promise ); } - const collection = await getPublicCollectionByUsernameAndSlugOrId(username, slug); + // First, find the user by username + const user = await getUserByUsername(username); - if (!collection) { + if (!user) { return NextResponse.json( - { jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null }, + { + jsonrpc: '2.0', + error: { + code: -32001, + message: `User '${username}' not found. Check the username in your MCP endpoint URL.`, + }, + id: null, + }, { status: 404 } ); } - // Owner-only enforcement: Only the collection owner can execute tools via MCP - if (authResult.userId !== collection.userId) { + // Then find the collection by user ID and slug/ID + const collection = await getCollectionByUserIdAndSlugOrId(user.id, slug); + + if (!collection) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { + code: -32001, + message: `Collection '${slug}' not found for user '${username}'.`, + }, + id: null, + }, + { status: 404 } + ); + } + + // Authorization check: + // - Owners can always access their own collections (public or private) + // - Non-owners can only access public collections (and must fork to use) + const isOwner = authResult.userId === collection.userId; + + if (!isOwner) { + if (!collection.isPublic) { + // Private collection, not the owner - don't reveal existence + return NextResponse.json( + { jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null }, + { status: 404 } + ); + } + // Public collection but not the owner - they need to fork it return NextResponse.json( { jsonrpc: '2.0', @@ -379,6 +428,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise /** * GET /api/mcp/[username]/[slug]/[transport] * Returns server info (for http) or establishes SSE connection (for sse) + * Allows owners to access their private collections when authenticated */ export async function GET(_request: NextRequest, context: RouteContext): Promise { try { @@ -388,10 +438,35 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise return NextResponse.json({ error: `Invalid transport: ${transport}` }, { status: 400 }); } - const collection = await getPublicCollectionByUsernameAndSlugOrId(username, slug); + // First, find the user by username + const user = await getUserByUsername(username); + + if (!user) { + return NextResponse.json( + { error: `User '${username}' not found. Check the username in your MCP endpoint URL.` }, + { status: 404 } + ); + } + + // Then find the collection + const collection = await getCollectionByUserIdAndSlugOrId(user.id, slug); if (!collection) { - return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); + return NextResponse.json( + { error: `Collection '${slug}' not found for user '${username}'.` }, + { status: 404 } + ); + } + + // For GET requests, check if user can access this collection: + // - Public collections are accessible to anyone + // - Private collections are only accessible to the owner (when authenticated) + if (!collection.isPublic) { + const authResult = await authenticateRequest(); + if (!authResult.authenticated || authResult.userId !== collection.userId) { + // Don't reveal existence of private collections + return NextResponse.json({ error: 'Collection not found' }, { status: 404 }); + } } if (transport === 'sse') { diff --git a/apps/web/src/app/dashboard/collections/[id]/page.tsx b/apps/web/src/app/dashboard/collections/[id]/page.tsx index f9cbc6e..a71f537 100644 --- a/apps/web/src/app/dashboard/collections/[id]/page.tsx +++ b/apps/web/src/app/dashboard/collections/[id]/page.tsx @@ -524,6 +524,28 @@ export default function CollectionDetailPage(): React.ReactElement { )} + {/* Prompt to set username if not set */} + {collection.isPublic && !collection.user.username && ( +
+
+ +
+

Set your username to enable MCP

+

+ You need to set a username before you can share this collection as an MCP server. + Your MCP endpoint URL will be: tpmjs.com/api/mcp/your-username/{collection.slug}/http +

+ + + +
+
+
+ )} + {/* Add Tool Search */} {collection.isOwner && (
diff --git a/apps/web/src/app/dashboard/settings/profile/page.tsx b/apps/web/src/app/dashboard/settings/profile/page.tsx new file mode 100644 index 0000000..3192b7e --- /dev/null +++ b/apps/web/src/app/dashboard/settings/profile/page.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; + +interface UserProfile { + id: string; + name: string; + username: string | null; + email: string; + image: string | null; + createdAt: string; +} + +interface UsernameCheckResult { + available: boolean; + reason?: string; +} + +export default function ProfileSettingsPage(): React.ReactElement { + const searchParams = useSearchParams(); + const isSetupMode = searchParams.get('setup') === '1'; + + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Form state + const [name, setName] = useState(''); + const [username, setUsername] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Username validation + const [usernameCheck, setUsernameCheck] = useState(null); + const [checkingUsername, setCheckingUsername] = useState(false); + + const fetchProfile = useCallback(async () => { + try { + const response = await fetch('/api/user/profile'); + const data = await response.json(); + + if (data.success) { + setProfile(data.data); + setName(data.data.name || ''); + setUsername(data.data.username || ''); + } else { + setError(data.error || 'Failed to load profile'); + } + } catch { + setError('Failed to load profile'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + // Debounced username availability check + const checkUsernameAvailability = useCallback( + async (usernameToCheck: string) => { + if (usernameToCheck.length < 3) { + setUsernameCheck({ available: false, reason: 'Username must be at least 3 characters' }); + return; + } + + // Don't check if it's the current username + if (usernameToCheck === profile?.username) { + setUsernameCheck({ available: true }); + return; + } + + setCheckingUsername(true); + try { + const response = await fetch( + `/api/user/username/check?username=${encodeURIComponent(usernameToCheck)}` + ); + const data = await response.json(); + + if (data.success) { + setUsernameCheck(data.data); + } else { + setUsernameCheck({ available: false, reason: 'Failed to check availability' }); + } + } catch { + setUsernameCheck({ available: false, reason: 'Failed to check availability' }); + } finally { + setCheckingUsername(false); + } + }, + [profile?.username] + ); + + // Debounce the username check + useEffect(() => { + if (username.length < 3) { + setUsernameCheck(null); + return; + } + + const timeout = setTimeout(() => { + checkUsernameAvailability(username); + }, 300); + + return () => clearTimeout(timeout); + }, [username, checkUsernameAvailability]); + + function handleUsernameChange(e: React.ChangeEvent) { + // Force lowercase and remove invalid characters + const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''); + setUsername(value); + setSaveSuccess(false); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaveError(null); + setSaveSuccess(false); + + // Validate username if changed + if (username !== profile?.username) { + if (!username || username.length < 3) { + setSaveError('Username must be at least 3 characters'); + return; + } + + if (usernameCheck && !usernameCheck.available) { + setSaveError(usernameCheck.reason || 'Please choose a different username'); + return; + } + } + + setIsSaving(true); + + try { + const response = await fetch('/api/user/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, username }), + }); + + const data = await response.json(); + + if (data.success) { + setProfile(data.data); + setSaveSuccess(true); + // Refresh profile to get updated data + await fetchProfile(); + } else { + setSaveError(data.error || 'Failed to update profile'); + } + } catch { + setSaveError('Failed to update profile'); + } finally { + setIsSaving(false); + } + } + + if (isLoading) { + return ( + +
+
+
+
+ + ); + } + + if (error || !profile) { + return ( + +
+ +

Error

+

{error || 'Failed to load profile'}

+ + + +
+
+ ); + } + + return ( + +
+
+ {/* Setup mode banner */} + {isSetupMode && !profile?.username && ( +
+ +
+

Complete your account setup

+

+ Please set your username to complete your account setup. This is required to use MCP endpoints and public profiles. +

+
+
+ )} + + {/* Success message */} + {saveSuccess && ( +
+ + Profile updated successfully! +
+ )} + + {/* Error message */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Name field */} +
+ + { + setName(e.target.value); + setSaveSuccess(false); + }} + required + className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="Your name" + /> +
+ + {/* Username field */} +
+ +
+
+ @ +
+ + {/* Status indicator */} +
+ {checkingUsername && ( + + )} + {!checkingUsername && usernameCheck?.available && ( + + )} + {!checkingUsername && + usernameCheck && + !usernameCheck.available && + username.length >= 3 && } +
+
+ {/* Username availability message */} + {username.length >= 3 && usernameCheck && !usernameCheck.available && ( +

{usernameCheck.reason}

+ )} + {username.length >= 3 && usernameCheck?.available && ( +

+ {username === profile.username ? 'Current username' : 'Username available'} +

+ )} +

+ Your public profile: tpmjs.com/@{username || 'your-username'} +

+
+ + {/* Email (read-only) */} +
+ + +

+ Email cannot be changed +

+
+ + {/* MCP URL Preview */} + {username && username.length >= 3 && ( +
+
+ + Your MCP Server URLs +
+

+ Once you save your username, your collection MCP endpoints will be available at: +

+ + tpmjs.com/api/mcp/{username}/[collection-slug]/http + +
+ )} + + {/* Submit button */} +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/dashboard/DashboardLayout.tsx b/apps/web/src/components/dashboard/DashboardLayout.tsx index e718a36..6cd1a58 100644 --- a/apps/web/src/components/dashboard/DashboardLayout.tsx +++ b/apps/web/src/components/dashboard/DashboardLayout.tsx @@ -20,6 +20,7 @@ const navItems: NavItem[] = [ { href: '/dashboard/agents', label: 'Agents', icon: 'terminal' }, { href: '/dashboard/collections', label: 'Collections', icon: 'folder' }, { href: '/dashboard/usage', label: 'Usage', icon: 'globe' }, + { href: '/dashboard/settings/profile', label: 'Profile', icon: 'user' }, { href: '/dashboard/settings/tpmjs-api-keys', label: 'TPMJS API Keys', icon: 'key' }, { href: '/dashboard/settings/api-keys', label: 'Provider Keys', icon: 'edit' }, { href: '/dashboard/settings/bridge', label: 'Bridge', icon: 'link' }, diff --git a/packages/db/package.json b/packages/db/package.json index 1d92103..e1c10c8 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,6 +12,7 @@ "db:migrate": "prisma migrate dev", "db:studio": "prisma studio", "db:seed": "tsx prisma/seed.ts", + "db:backfill-usernames": "tsx prisma/backfill-usernames.ts", "type-check": "tsc --noEmit" }, "dependencies": { diff --git a/packages/db/prisma/backfill-usernames.ts b/packages/db/prisma/backfill-usernames.ts new file mode 100644 index 0000000..7ce8934 --- /dev/null +++ b/packages/db/prisma/backfill-usernames.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env tsx +/** + * Backfill usernames for all users who don't have one. + * Converts user's name to a URL-friendly slug. + * + * Run with: npx tsx packages/db/prisma/backfill-usernames.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const RESERVED_USERNAMES = [ + 'admin', + 'api', + 'auth', + 'dashboard', + 'help', + 'support', + 'system', + 'www', + 'settings', + 'login', + 'logout', + 'register', + 'signup', + 'signin', + 'agents', + 'collections', + 'tools', + 'tool', + 'playground', + 'explore', + 'search', + 'about', + 'blog', + 'docs', + 'pricing', + 'terms', + 'privacy', + 'contact', + 'status', + 'tpmjs', + 'tpm', + 'official', +]; + +/** + * Convert a display name to a URL-friendly username. + */ +function suggestUsername(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special chars + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Remove consecutive hyphens + .replace(/^-+|-+$/g, '') // Trim hyphens + .slice(0, 30); +} + +/** + * Generate a unique username by appending numbers if needed. + */ +async function generateUniqueUsername(baseName: string): Promise { + let username = suggestUsername(baseName); + + // Ensure minimum length + if (username.length < 3) { + username = `user-${username || 'anon'}`; + } + + // Check if reserved + if (RESERVED_USERNAMES.includes(username)) { + username = `${username}-user`; + } + + // Check if already taken + const existing = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + + if (!existing) { + return username; + } + + // Append numbers until unique + let counter = 1; + while (counter < 1000) { + const candidate = `${username.slice(0, 26)}-${counter}`; + const exists = await prisma.user.findUnique({ + where: { username: candidate }, + select: { id: true }, + }); + if (!exists) { + return candidate; + } + counter++; + } + + // Fallback: use random suffix + return `${username.slice(0, 22)}-${Date.now().toString(36)}`; +} + +async function main() { + console.log('🔧 Backfilling usernames for users without one...\n'); + + // Find all users without a username + const usersWithoutUsername = await prisma.user.findMany({ + where: { username: null }, + select: { id: true, name: true, email: true }, + }); + + console.log(`Found ${usersWithoutUsername.length} users without a username.\n`); + + if (usersWithoutUsername.length === 0) { + console.log('✅ All users already have usernames!'); + return; + } + + let updated = 0; + let failed = 0; + + for (const user of usersWithoutUsername) { + try { + const username = await generateUniqueUsername(user.name); + + await prisma.user.update({ + where: { id: user.id }, + data: { username }, + }); + + console.log(`✅ ${user.email} → @${username}`); + updated++; + } catch (error) { + console.error(`❌ Failed to update ${user.email}:`, error); + failed++; + } + } + + console.log(`\n${'='.repeat(50)}`); + console.log(`Updated: ${updated}`); + console.log(`Failed: ${failed}`); + console.log(`${'='.repeat(50)}`); +} + +main() + .catch((e) => { + console.error('Backfill failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });