Skip to content

Commit a554e37

Browse files
committed
Fix environment detection for local prod builds, improve admin user deletion
- Fix bug where local production builds (bun build && bun start) used production collections instead of DEV_ collections - Add REST API fallback for admin user deletion when Firebase Admin SDK fails - Add deleteUserByUid() function to firebase-rest.ts for reliable user deletion - Add scripts for checking/migrating test users between collections - Add OpenGraph images admin page (WIP) This prevents accidental contamination of production data when testing locally.
1 parent 0a8f63d commit a554e37

File tree

7 files changed

+599
-43
lines changed

7 files changed

+599
-43
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
"use client";
2+
3+
import React, { useState } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { useAuth } from '../../providers/AuthProvider';
6+
import { Button } from '../../components/ui/button';
7+
import { Badge } from '../../components/ui/badge';
8+
import { ArrowLeft, ExternalLink, Loader, RefreshCw, Image as ImageIcon } from 'lucide-react';
9+
import { isAdmin } from '../../utils/isAdmin';
10+
import { FloatingHeader } from '../../components/ui/FloatingCard';
11+
12+
interface OGImageType {
13+
id: string;
14+
name: string;
15+
description: string;
16+
route: string;
17+
previewUrl: string;
18+
params?: Record<string, string>;
19+
usedIn: string[];
20+
}
21+
22+
const OG_IMAGE_TYPES: OGImageType[] = [
23+
{
24+
id: 'default',
25+
name: 'Default WeWrite',
26+
description: 'Default branding image shown when sharing the homepage or pages without specific metadata.',
27+
route: '/opengraph-image',
28+
previewUrl: '/opengraph-image',
29+
usedIn: ['Homepage', 'Fallback for all pages'],
30+
},
31+
{
32+
id: 'api-default',
33+
name: 'API Default Branding',
34+
description: 'Dynamic API route that returns WeWrite branding when no page ID is provided.',
35+
route: '/api/og',
36+
previewUrl: '/api/og',
37+
usedIn: ['API fallback'],
38+
},
39+
{
40+
id: 'api-test',
41+
name: 'Test Image (Red)',
42+
description: 'Simple red test image to verify the OG image generation pipeline is working.',
43+
route: '/api/og?id=test',
44+
previewUrl: '/api/og?id=test',
45+
usedIn: ['Debug/Testing'],
46+
},
47+
{
48+
id: 'content-page',
49+
name: 'Content Page',
50+
description: 'Dynamic OG image for content pages showing title, author, content preview, and sponsor count.',
51+
route: '/api/og?id={pageId}',
52+
previewUrl: '/api/og?id=sample&title=Example%20Page%20Title&author=JohnDoe&content=This%20is%20a%20preview%20of%20what%20the%20content%20looks%20like%20when%20shared%20on%20social%20media.&sponsors=5',
53+
params: {
54+
id: 'Page ID',
55+
title: 'Page title (optional)',
56+
author: 'Author username (optional)',
57+
content: 'Content preview (optional)',
58+
sponsors: 'Sponsor count (optional)',
59+
},
60+
usedIn: ['Individual content pages', 'Social media shares'],
61+
},
62+
{
63+
id: 'api-png',
64+
name: 'PNG Format',
65+
description: 'Same as content page but served from /api/og.png route for compatibility.',
66+
route: '/api/og.png',
67+
previewUrl: '/api/og.png?id=sample&title=PNG%20Format%20Example&author=TestUser&content=This%20demonstrates%20the%20PNG%20endpoint%20variant.&sponsors=3',
68+
usedIn: ['Alternative endpoint'],
69+
},
70+
];
71+
72+
export default function OpenGraphImagesPage() {
73+
const { user, isLoading: authLoading } = useAuth();
74+
const router = useRouter();
75+
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
76+
const [refreshKey, setRefreshKey] = useState(0);
77+
78+
// Check if user is admin
79+
React.useEffect(() => {
80+
if (!authLoading && user) {
81+
if (!isAdmin(user.email)) {
82+
router.push('/');
83+
}
84+
} else if (!authLoading && !user) {
85+
router.push('/auth/login?redirect=/admin/opengraph-images');
86+
}
87+
}, [user, authLoading, router]);
88+
89+
const handleRefreshAll = () => {
90+
setRefreshKey(prev => prev + 1);
91+
};
92+
93+
const handleImageLoad = (id: string) => {
94+
setLoadingImages(prev => ({ ...prev, [id]: false }));
95+
};
96+
97+
const handleImageLoadStart = (id: string) => {
98+
setLoadingImages(prev => ({ ...prev, [id]: true }));
99+
};
100+
101+
if (authLoading) {
102+
return (
103+
<div className="flex justify-center items-center min-h-screen">
104+
<div className="text-center">
105+
<Loader className="h-8 w-8 animate-spin text-primary mx-auto mb-4" />
106+
<p className="text-muted-foreground">Loading...</p>
107+
</div>
108+
</div>
109+
);
110+
}
111+
112+
if (!user || !isAdmin(user.email)) {
113+
return (
114+
<div className="flex justify-center items-center min-h-screen">
115+
<div className="text-center">
116+
<p className="text-muted-foreground">Access denied</p>
117+
</div>
118+
</div>
119+
);
120+
}
121+
122+
return (
123+
<div className="min-h-screen bg-background">
124+
<div className="py-6 px-4 container mx-auto max-w-5xl">
125+
<FloatingHeader className="fixed top-3 left-3 right-3 sm:left-4 sm:right-4 md:left-6 md:right-6 z-40 px-4 py-3 mb-6 flex items-center justify-between lg:relative lg:top-0 lg:left-0 lg:right-0 lg:z-auto lg:mb-6 lg:px-0 lg:py-2">
126+
<div className="flex items-center gap-4">
127+
<Button
128+
variant="ghost"
129+
size="icon"
130+
onClick={() => router.push('/admin')}
131+
className="h-10 w-10"
132+
>
133+
<ArrowLeft className="h-5 w-5" />
134+
</Button>
135+
<div>
136+
<h1 className="text-2xl font-bold leading-tight">OpenGraph Images</h1>
137+
<p className="text-muted-foreground text-sm">
138+
Preview all OG image designs
139+
</p>
140+
</div>
141+
</div>
142+
<Button
143+
variant="outline"
144+
size="sm"
145+
onClick={handleRefreshAll}
146+
className="gap-2"
147+
>
148+
<RefreshCw className="h-4 w-4" />
149+
Refresh All
150+
</Button>
151+
</FloatingHeader>
152+
153+
<div className="space-y-8 pt-24 lg:pt-0">
154+
<div className="mb-4">
155+
<p className="text-muted-foreground">
156+
These are all the OpenGraph images used when WeWrite pages are shared on social media.
157+
Each design is generated dynamically based on the context.
158+
</p>
159+
</div>
160+
161+
{OG_IMAGE_TYPES.map((ogType) => (
162+
<div key={ogType.id} className="wewrite-card">
163+
<div className="flex flex-col gap-4">
164+
{/* Header */}
165+
<div className="flex items-start justify-between">
166+
<div>
167+
<div className="flex items-center gap-2 mb-1">
168+
<h3 className="text-lg font-semibold">{ogType.name}</h3>
169+
<Badge variant="secondary" className="text-xs">
170+
{ogType.id}
171+
</Badge>
172+
</div>
173+
<p className="text-sm text-muted-foreground">{ogType.description}</p>
174+
</div>
175+
<a
176+
href={ogType.previewUrl}
177+
target="_blank"
178+
rel="noopener noreferrer"
179+
className="flex-shrink-0"
180+
>
181+
<Button variant="ghost" size="sm" className="gap-1">
182+
<ExternalLink className="h-4 w-4" />
183+
Open
184+
</Button>
185+
</a>
186+
</div>
187+
188+
{/* Route info */}
189+
<div className="bg-muted/50 rounded-lg p-3">
190+
<code className="text-sm text-muted-foreground">{ogType.route}</code>
191+
</div>
192+
193+
{/* Parameters if any */}
194+
{ogType.params && (
195+
<div>
196+
<p className="text-sm font-medium mb-2">Parameters:</p>
197+
<div className="flex flex-wrap gap-2">
198+
{Object.entries(ogType.params).map(([key, desc]) => (
199+
<Badge key={key} variant="outline" className="text-xs">
200+
{key}: {desc}
201+
</Badge>
202+
))}
203+
</div>
204+
</div>
205+
)}
206+
207+
{/* Used in */}
208+
<div>
209+
<p className="text-sm font-medium mb-2">Used in:</p>
210+
<div className="flex flex-wrap gap-2">
211+
{ogType.usedIn.map((use) => (
212+
<Badge key={use} variant="secondary" className="text-xs">
213+
{use}
214+
</Badge>
215+
))}
216+
</div>
217+
</div>
218+
219+
{/* Preview */}
220+
<div className="mt-2">
221+
<p className="text-sm font-medium mb-2">Preview (1200×630):</p>
222+
<div className="relative rounded-lg overflow-hidden border border-border bg-muted/30" style={{ aspectRatio: '1200/630' }}>
223+
{loadingImages[ogType.id] && (
224+
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
225+
<Loader className="h-6 w-6 animate-spin text-primary" />
226+
</div>
227+
)}
228+
{/* eslint-disable-next-line @next/next/no-img-element */}
229+
<img
230+
key={`${ogType.id}-${refreshKey}`}
231+
src={ogType.previewUrl}
232+
alt={`${ogType.name} preview`}
233+
className="w-full h-full object-cover"
234+
onLoadStart={() => handleImageLoadStart(ogType.id)}
235+
onLoad={() => handleImageLoad(ogType.id)}
236+
onError={() => handleImageLoad(ogType.id)}
237+
/>
238+
</div>
239+
</div>
240+
</div>
241+
</div>
242+
))}
243+
244+
{/* Technical Notes */}
245+
<div className="wewrite-card bg-muted/30">
246+
<h3 className="text-lg font-semibold mb-3">Technical Notes</h3>
247+
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
248+
<li>All OG images are generated using Next.js <code className="bg-muted px-1 rounded">ImageResponse</code> from <code className="bg-muted px-1 rounded">next/og</code></li>
249+
<li>Images are generated at the edge runtime for fast delivery</li>
250+
<li>Standard OG image size is 1200×630 pixels</li>
251+
<li>The <code className="bg-muted px-1 rounded">/api/og</code> route dynamically fetches page data if only an ID is provided</li>
252+
<li>Content is automatically stripped of HTML tags and truncated for display</li>
253+
</ul>
254+
</div>
255+
</div>
256+
</div>
257+
</div>
258+
);
259+
}

app/admin/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,27 @@ export default function AdminPage() {
453453
</div>
454454
</div>
455455

456+
{/* OpenGraph Images */}
457+
<div className="wewrite-card flex flex-col hover:bg-muted/50 transition-colors">
458+
<div className="flex items-center justify-between mb-2">
459+
<h3 className="font-medium">OpenGraph Images</h3>
460+
</div>
461+
<span className="text-sm text-muted-foreground mb-3">
462+
Preview all OG image designs used when WeWrite pages are shared on social media.
463+
</span>
464+
<div className="mt-2">
465+
<Button
466+
variant="secondary"
467+
size="sm"
468+
className="gap-2 w-full"
469+
onClick={() => router.push('/admin/opengraph-images')}
470+
>
471+
<ImageIcon className="h-4 w-4" />
472+
View OG Images
473+
</Button>
474+
</div>
475+
</div>
476+
456477
{/* Financial Test Harness */}
457478
<div className="wewrite-card flex flex-col hover:bg-muted/50 transition-colors">
458479
<div className="flex items-center justify-between mb-2">

app/api/admin/users/delete/route.ts

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { checkAdminPermissions } from '../../../admin-auth-helper';
33
import { getFirebaseAdmin } from '../../../../firebase/firebaseAdmin';
44
import { getCollectionName } from '../../../../utils/environmentConfig';
5+
import { deleteUserByUid } from '../../../../lib/firebase-rest';
56

6-
const FIREBASE_API_KEY = process.env.NEXT_PUBLIC_FIREBASE_API_KEY;
7-
const GOOGLE_SERVICE_ACCOUNT_EMAIL = process.env.FIREBASE_CLIENT_EMAIL || process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
87
const RESEND_API_KEY = process.env.RESEND_API_KEY;
98
const RESEND_API_URL = 'https://api.resend.com';
109

@@ -86,33 +85,6 @@ async function deleteFromAllResendAudiences(email: string): Promise<{ deleted: b
8685
return { deleted: anyDeleted, errors };
8786
}
8887

89-
/**
90-
* Delete a Firebase Auth user using the Identity Toolkit REST API
91-
* This avoids the jose dependency issues in Vercel production
92-
*/
93-
async function deleteAuthUserViaRestApi(uid: string): Promise<{ success: boolean; error?: string }> {
94-
try {
95-
// We need an access token to call the admin API
96-
// Since we can't use service account auth easily in REST, we'll try using
97-
// the Admin SDK for non-Auth operations and fall back to noting the limitation
98-
99-
// The Identity Toolkit API requires an OAuth2 access token, not an API key
100-
// For now, we'll document that this needs to be done via Firebase Console
101-
// or we need to implement proper OAuth2 service account flow
102-
103-
console.log('[ADMIN DELETE] Cannot delete Firebase Auth user via REST API without OAuth2 token');
104-
console.log('[ADMIN DELETE] User must be deleted manually via Firebase Console or implement OAuth2 flow');
105-
106-
return {
107-
success: false,
108-
error: 'Firebase Auth user deletion requires manual action in Firebase Console due to API limitations'
109-
};
110-
} catch (error: any) {
111-
console.error('[ADMIN DELETE] REST API deletion error:', error);
112-
return { success: false, error: error.message };
113-
}
114-
}
115-
11688
export async function POST(request: NextRequest) {
11789
try {
11890
const admin = getFirebaseAdmin();
@@ -149,17 +121,24 @@ export async function POST(request: NextRequest) {
149121
console.warn('[ADMIN DELETE] Could not fetch user doc:', err.message);
150122
}
151123

152-
// Step 1: Try to delete Firebase Auth user (may fail due to jose)
124+
// Step 1: Try to delete Firebase Auth user
125+
// First try Admin SDK, then fall back to REST API if jose fails
153126
try {
154127
await admin.auth().deleteUser(uid);
155128
deletionResults.authUser.deleted = true;
156-
console.log('[ADMIN DELETE] Firebase Auth user deleted successfully');
129+
console.log('[ADMIN DELETE] Firebase Auth user deleted via Admin SDK');
157130
} catch (authErr: any) {
158-
console.warn('[ADMIN DELETE] Firebase Auth deletion failed:', authErr.message);
159-
deletionResults.authUser.error = authErr.message;
131+
console.warn('[ADMIN DELETE] Admin SDK deletion failed, trying REST API:', authErr.message);
160132

161-
// Note: We cannot use REST API for admin deletion without OAuth2 service account flow
162-
// The user will need to be deleted via Firebase Console
133+
// Fallback to REST API which doesn't have jose dependency issues
134+
const restResult = await deleteUserByUid(uid);
135+
if (restResult.success) {
136+
deletionResults.authUser.deleted = true;
137+
console.log('[ADMIN DELETE] Firebase Auth user deleted via REST API');
138+
} else {
139+
deletionResults.authUser.error = `Admin SDK: ${authErr.message}; REST API: ${restResult.error}`;
140+
console.error('[ADMIN DELETE] Both deletion methods failed');
141+
}
163142
}
164143

165144
// Step 2: Delete Firestore user document
@@ -213,8 +192,9 @@ export async function POST(request: NextRequest) {
213192
message = 'User fully deleted';
214193
} else if (dataDeleted && !authDeleted) {
215194
message = 'User data deleted, but Firebase Auth user still exists';
216-
warnings.push('Firebase Auth user must be manually deleted via Firebase Console');
195+
warnings.push('Firebase Auth deletion failed - see deletionResults.authUser.error for details');
217196
warnings.push('The email address will still be "in use" until the Auth user is deleted');
197+
warnings.push('You may need to manually delete via Firebase Console');
218198
} else if (!dataDeleted && authDeleted) {
219199
message = 'Firebase Auth user deleted, but Firestore data may remain';
220200
} else {
@@ -229,11 +209,6 @@ export async function POST(request: NextRequest) {
229209
username: userData?.username || null,
230210
deletionResults,
231211
warnings: warnings.length > 0 ? warnings : undefined,
232-
manualActionRequired: !authDeleted ? {
233-
action: 'Delete user from Firebase Console',
234-
url: `https://console.firebase.google.com/project/${process.env.NEXT_PUBLIC_FIREBASE_PID}/authentication/users`,
235-
reason: 'Firebase Admin Auth operations fail in Vercel due to jose dependency issues'
236-
} : undefined
237212
});
238213
} catch (error: any) {
239214
console.error('[ADMIN DELETE] Error:', error);

0 commit comments

Comments
 (0)