Skip to content

Commit 68de8ba

Browse files
committed
feat: admin view as a user
1 parent 174db24 commit 68de8ba

File tree

5 files changed

+158
-18
lines changed

5 files changed

+158
-18
lines changed

.env.example

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,21 @@
22
# with real values in your local `.env.local` or CI environment.
33

44
# secrets (server-only)
5-
QR_SECRET=xxxx
5+
CSV_EXPORT_KEY=xxxx
66
GOOGLE_CLIENT_SECRET=xxxx
7-
TURSO_AUTH_TOKEN=xxxx
87
NEXTAUTH_SECRET=xxxx
8+
QR_SECRET=xxxx
9+
TURSO_AUTH_TOKEN=xxxx
910

1011
# deployment
1112
ADMIN_EMAIL=[email protected]
12-
TURSO_DATABASE_URL=libsql://<your-db-url>
1313
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
1414
NEXTAUTH_URL=https://your-site.example
15-
16-
# analytics (posthog)
17-
POSTHOG_UI_HOST=https://eu.posthog.com
18-
POSTHOG_KEY=phc_xxxx
19-
20-
# CSV export key for unauthenticated CSV downloads via /api/events/[id]/export/key
21-
CSV_EXPORT_KEY=xxxx
15+
TURSO_DATABASE_URL=libsql://<your-db-url>
2216

2317
# branding
24-
NEXT_PUBLIC_SIGNIN_TITLE="Eloop"
25-
NEXT_PUBLIC_SIGNIN_SUBTITLE="Sign up to your event"
18+
NEXT_PUBLIC_POSTHOG_KEY=phc_xxxx
19+
NEXT_PUBLIC_POSTHOG_UI_HOST=https://eu.posthog.com
2620
NEXT_PUBLIC_SIGNIN_IMAGE="/signin-hero.svg"
21+
NEXT_PUBLIC_SIGNIN_SUBTITLE="Sign up to your event"
22+
NEXT_PUBLIC_SIGNIN_TITLE="Eloop"

package-lock.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/api/admin/view-as/route.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from 'next/server';
2+
import { auth } from '@/lib/auth';
3+
import { getUserById } from '@/lib/db/user';
4+
import { getUserRegistrations } from '@/lib/db/registration';
5+
import { getEventById } from '@/lib/db/event';
6+
7+
export async function GET(request: Request) {
8+
try {
9+
const session = await auth();
10+
if (!session?.user) {
11+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
12+
}
13+
14+
if (session.user.role !== 'admin') {
15+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
16+
}
17+
18+
const url = new URL(request.url);
19+
const userId = url.searchParams.get('userId');
20+
if (!userId) {
21+
return NextResponse.json({ error: 'userId is required' }, { status: 400 });
22+
}
23+
24+
const user = await getUserById(userId);
25+
if (!user) {
26+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
27+
}
28+
29+
const registrations = await getUserRegistrations(userId);
30+
const activeReg = registrations.find(r => r.status === 'approved' || r.status === 'checked-in')
31+
|| registrations.find(r => r.status === 'pending');
32+
33+
if (!activeReg) {
34+
return NextResponse.json({ hasRegistration: false, message: 'No active registration found', user });
35+
}
36+
37+
const event = await getEventById(activeReg.eventId);
38+
39+
return NextResponse.json({
40+
hasRegistration: true,
41+
registration: {
42+
id: activeReg.id,
43+
eventId: activeReg.eventId,
44+
eventName: event?.name || 'Unknown Event',
45+
eventDate: event?.date || new Date(),
46+
status: activeReg.status,
47+
qrCode: activeReg.qrCode,
48+
checkpointCheckIns: activeReg.checkpointCheckIns || [],
49+
createdAt: activeReg.createdAt
50+
},
51+
user: {
52+
id: user.id,
53+
name: user.name,
54+
email: user.email
55+
}
56+
});
57+
} catch (error) {
58+
console.error('Error in admin view-as:', error);
59+
return NextResponse.json({ error: 'Failed to fetch view-as data' }, { status: 500 });
60+
}
61+
}

src/app/dashboard/view-as/page.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"use client";
2+
3+
import { useState, useEffect } from 'react';
4+
import { useSession } from 'next-auth/react';
5+
import UserRegistrationsSection from '@/components/dashboard/UserRegistrationsSection';
6+
7+
export default function AdminViewAsPage() {
8+
const { data: session } = useSession();
9+
const [users, setUsers] = useState<Array<{ id: string; name: string; email: string }>>([]);
10+
const [selectedUser, setSelectedUser] = useState<string | null>(null);
11+
12+
useEffect(() => {
13+
const fetchUsers = async () => {
14+
try {
15+
const res = await fetch('/api/users');
16+
if (!res.ok) throw new Error('Failed to fetch users');
17+
const data = await res.json();
18+
setUsers(data.users || []);
19+
} catch (err) {
20+
console.error('Failed to load users for view-as:', err);
21+
}
22+
};
23+
24+
if (session?.user?.role === 'admin') fetchUsers();
25+
}, [session]);
26+
27+
if (!session?.user || session.user.role !== 'admin') {
28+
return <div className="p-6">Unauthorized</div>;
29+
}
30+
31+
return (
32+
<div className="p-6">
33+
<h1 className="text-2xl font-bold mb-4">Admin: View As User</h1>
34+
35+
<div className="mb-4">
36+
<label className="block text-sm font-medium text-gray-700 mb-2">Select user</label>
37+
<select value={selectedUser ?? ''} onChange={e => setSelectedUser(e.target.value || null)} className="border rounded p-2">
38+
<option value="">-- pick user --</option>
39+
{users.map(u => (
40+
<option key={u.id} value={u.id}>{u.name}{u.email}</option>
41+
))}
42+
</select>
43+
</div>
44+
45+
{selectedUser ? (
46+
<div>
47+
<h2 className="text-lg font-semibold mb-3">Preview as selected user</h2>
48+
<UserRegistrationsSection viewAsUserId={selectedUser} />
49+
</div>
50+
) : (
51+
<div className="text-gray-600">Choose a user to preview their participant view.</div>
52+
)}
53+
</div>
54+
);
55+
}

src/components/dashboard/UserRegistrationsSection.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ interface StatusData {
3232
};
3333
}
3434

35-
export default function UserRegistrationsSection() {
35+
export default function UserRegistrationsSection({ viewAsUserId }: { viewAsUserId?: string }) {
3636
const { data: session } = useSession();
3737
const searchParams = useSearchParams();
3838
const [statusData, setStatusData] = useState<StatusData | null>(null);
@@ -42,7 +42,8 @@ export default function UserRegistrationsSection() {
4242
useEffect(() => {
4343
const fetchStatus = async () => {
4444
try {
45-
const response = await fetch('/api/users/me/status');
45+
const endpoint = viewAsUserId ? `/api/admin/view-as?userId=${encodeURIComponent(viewAsUserId)}` : '/api/users/me/status';
46+
const response = await fetch(endpoint);
4647

4748
if (!response.ok) {
4849
const errorData = await response.json().catch(() => ({}));
@@ -61,10 +62,11 @@ export default function UserRegistrationsSection() {
6162
}
6263
};
6364

64-
if (session?.user) {
65+
// If viewing as another user, allow admins to fetch; otherwise only fetch when session exists
66+
if ((viewAsUserId && session?.user?.role === 'admin') || (!viewAsUserId && session?.user)) {
6567
fetchStatus();
6668
}
67-
}, [session, searchParams]);
69+
}, [session, searchParams, viewAsUserId]);
6870

6971
// Auto-logout applicants who have been approved (so they can login as participant)
7072
useEffect(() => {
@@ -139,8 +141,8 @@ export default function UserRegistrationsSection() {
139141
</p>
140142
</div>
141143

142-
{/* QR Code Display - Only show for participants */}
143-
{session.user.role === 'participant' && (
144+
{/* QR Code Display - show for participants or when admin is viewing as a user */}
145+
{(session.user.role === 'participant' || viewAsUserId) && (
144146
<div className="mb-6 flex justify-center">
145147
<div className="max-w-sm w-full">
146148
<GenericQRDisplay

0 commit comments

Comments
 (0)