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
This commit is contained in:
Ajax Davis 2026-01-14 04:21:41 +10:00
parent 0e6be48fdc
commit 77e697db56
7 changed files with 646 additions and 27 deletions

View file

@ -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

View file

@ -42,20 +42,32 @@ function withTimeout<T>(promise: Promise<T>, 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<Response> {
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') {

View file

@ -524,6 +524,28 @@ export default function CollectionDetailPage(): React.ReactElement {
<McpUrlSection username={collection.user.username} slug={collection.slug} />
)}
{/* Prompt to set username if not set */}
{collection.isPublic && !collection.user.username && (
<div className="mb-8 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<div className="flex items-start gap-3">
<Icon icon="alertCircle" size="sm" className="text-amber-600 mt-0.5" />
<div>
<h3 className="font-semibold text-amber-900">Set your username to enable MCP</h3>
<p className="text-sm text-amber-700 mt-1">
You need to set a username before you can share this collection as an MCP server.
Your MCP endpoint URL will be: <code className="bg-amber-100 px-1 rounded">tpmjs.com/api/mcp/your-username/{collection.slug}/http</code>
</p>
<Link href="/dashboard/settings/profile" className="inline-block mt-3">
<Button size="sm" variant="secondary">
<Icon icon="user" size="xs" className="mr-1" />
Set Username
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Add Tool Search */}
{collection.isOwner && (
<div className="mb-6">

View file

@ -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<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form state
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
// Username validation
const [usernameCheck, setUsernameCheck] = useState<UsernameCheckResult | null>(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<HTMLInputElement>) {
// 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 (
<DashboardLayout title="Profile" showBackButton backUrl="/dashboard">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-48 mb-4" />
<div className="h-32 bg-surface-secondary rounded mb-6" />
</div>
</DashboardLayout>
);
}
if (error || !profile) {
return (
<DashboardLayout title="Profile" showBackButton backUrl="/dashboard">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error || 'Failed to load profile'}</p>
<Link href="/dashboard">
<Button>Back to Dashboard</Button>
</Link>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout title="Profile Settings" showBackButton backUrl="/dashboard">
<div className="max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Setup mode banner */}
{isSetupMode && !profile?.username && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 px-4 py-3 rounded-lg text-sm flex items-start gap-2">
<Icon icon="alertCircle" size="sm" className="mt-0.5 shrink-0" />
<div>
<p className="font-medium">Complete your account setup</p>
<p className="text-amber-700 mt-1">
Please set your username to complete your account setup. This is required to use MCP endpoints and public profiles.
</p>
</div>
</div>
)}
{/* Success message */}
{saveSuccess && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg text-sm flex items-center gap-2">
<Icon icon="check" size="sm" />
Profile updated successfully!
</div>
)}
{/* Error message */}
{saveError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{saveError}
</div>
)}
{/* Name field */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground mb-1">
Display Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => {
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"
/>
</div>
{/* Username field */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-secondary">
@
</div>
<input
id="username"
type="text"
value={username}
onChange={handleUsernameChange}
required
minLength={3}
maxLength={30}
className="w-full pl-7 pr-10 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="username"
/>
{/* Status indicator */}
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
{checkingUsername && (
<Icon icon="loader" size="sm" className="animate-spin text-foreground-secondary" />
)}
{!checkingUsername && usernameCheck?.available && (
<Icon icon="check" size="sm" className="text-green-500" />
)}
{!checkingUsername &&
usernameCheck &&
!usernameCheck.available &&
username.length >= 3 && <Icon icon="x" size="sm" className="text-red-500" />}
</div>
</div>
{/* Username availability message */}
{username.length >= 3 && usernameCheck && !usernameCheck.available && (
<p className="mt-1 text-xs text-red-500">{usernameCheck.reason}</p>
)}
{username.length >= 3 && usernameCheck?.available && (
<p className="mt-1 text-xs text-green-600">
{username === profile.username ? 'Current username' : 'Username available'}
</p>
)}
<p className="mt-2 text-xs text-foreground-tertiary">
Your public profile: tpmjs.com/@{username || 'your-username'}
</p>
</div>
{/* Email (read-only) */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground mb-1">
Email
</label>
<input
id="email"
type="email"
value={profile.email}
disabled
className="w-full px-3 py-2 border border-border rounded-lg bg-surface-secondary text-foreground-secondary cursor-not-allowed"
/>
<p className="mt-1 text-xs text-foreground-tertiary">
Email cannot be changed
</p>
</div>
{/* MCP URL Preview */}
{username && username.length >= 3 && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="link" size="sm" className="text-primary" />
<span className="text-sm font-medium text-foreground">Your MCP Server URLs</span>
</div>
<p className="text-xs text-foreground-secondary mb-2">
Once you save your username, your collection MCP endpoints will be available at:
</p>
<code className="text-xs text-primary block">
tpmjs.com/api/mcp/{username}/[collection-slug]/http
</code>
</div>
)}
{/* Submit button */}
<div className="flex gap-3">
<Button type="submit" loading={isSaving} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
<Link href="/dashboard">
<Button variant="secondary">Cancel</Button>
</Link>
</div>
</form>
</div>
</DashboardLayout>
);
}

View file

@ -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' },

View file

@ -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": {

View file

@ -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<string> {
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();
});