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:
parent
0e6be48fdc
commit
77e697db56
7 changed files with 646 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
337
apps/web/src/app/dashboard/settings/profile/page.tsx
Normal file
337
apps/web/src/app/dashboard/settings/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
155
packages/db/prisma/backfill-usernames.ts
Normal file
155
packages/db/prisma/backfill-usernames.ts
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue