Skip to content

Commit ea8fcc6

Browse files
authored
Fix: Allow challenge admins to access admin dashboard (#41)
1 parent e63aea6 commit ea8fcc6

5 files changed

Lines changed: 365 additions & 26 deletions

File tree

apps/web/app/challenges/[id]/admin/layout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ export default async function ChallengeAdminLayout({
3232
}
3333

3434
// Check if user can manage this challenge
35-
const canManage =
36-
user.role === "admin" || challenge.creatorId === user._id;
35+
const adminStatus = await convex.query(api.queries.participations.isUserChallengeAdmin, {
36+
challengeId: id as Id<"challenges">,
37+
});
3738

38-
if (!canManage) {
39+
if (!adminStatus.isAdmin) {
3940
redirect(`/challenges/${challenge._id}`);
4041
}
4142

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { notFound, redirect } from "next/navigation";
2+
import { getConvexClient } from "@/lib/convex-server";
3+
import { api } from "@repo/backend";
4+
import type { Id } from "@repo/backend/_generated/dataModel";
5+
6+
import { getCurrentUser } from "@/lib/auth";
7+
import { isAuthenticated } from "@/lib/server-auth";
8+
import { DashboardLayoutWrapper } from "../notifications/dashboard-layout-wrapper";
9+
import { SettingsContent } from "./settings-content";
10+
11+
interface SettingsPageProps {
12+
params: Promise<{ id: string }>;
13+
}
14+
15+
export default async function SettingsPage({ params }: SettingsPageProps) {
16+
const convex = getConvexClient();
17+
const [currentUser, { id }] = await Promise.all([
18+
getCurrentUser(),
19+
params,
20+
]);
21+
22+
if (!currentUser) {
23+
const authenticated = await isAuthenticated();
24+
if (authenticated) {
25+
redirect(`/challenges/${id}`);
26+
}
27+
redirect(`/sign-in?redirect_url=/challenges/${id}/settings`);
28+
}
29+
30+
const challengeId = id as Id<"challenges">;
31+
32+
const challenge = await convex.query(api.queries.challenges.getById, {
33+
challengeId,
34+
});
35+
36+
if (!challenge) {
37+
notFound();
38+
}
39+
40+
return (
41+
<DashboardLayoutWrapper
42+
challenge={{
43+
id: challenge._id,
44+
name: challenge.name,
45+
startDate: challenge.startDate,
46+
endDate: challenge.endDate,
47+
}}
48+
currentUserId={currentUser._id}
49+
currentUser={currentUser}
50+
hideRightSidebar
51+
>
52+
<div className="mx-auto max-w-2xl px-4 py-6">
53+
<SettingsContent
54+
currentUser={currentUser}
55+
currentChallengeId={challenge._id}
56+
/>
57+
</div>
58+
</DashboardLayoutWrapper>
59+
);
60+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import Link from "next/link";
5+
import { useRouter } from "next/navigation";
6+
import { useMutation, useQuery } from "convex/react";
7+
import { api } from "@repo/backend";
8+
import type { Id, Doc } from "@repo/backend/_generated/dataModel";
9+
import { Loader2, Settings, User, List, Check } from "lucide-react";
10+
11+
import { UserAvatar } from "@/components/user-avatar";
12+
import { Button } from "@/components/ui/button";
13+
import {
14+
Card,
15+
CardContent,
16+
CardDescription,
17+
CardHeader,
18+
CardTitle,
19+
} from "@/components/ui/card";
20+
import { Input } from "@/components/ui/input";
21+
import { Label } from "@/components/ui/label";
22+
23+
interface SettingsContentProps {
24+
currentUser: {
25+
_id: Id<"users">;
26+
username: string;
27+
name?: string;
28+
email: string;
29+
avatarUrl?: string;
30+
};
31+
currentChallengeId: Id<"challenges">;
32+
}
33+
34+
export function SettingsContent({
35+
currentUser,
36+
currentChallengeId,
37+
}: SettingsContentProps) {
38+
const router = useRouter();
39+
const [isUpdating, setIsUpdating] = useState(false);
40+
const [updateSuccess, setUpdateSuccess] = useState(false);
41+
const [updateError, setUpdateError] = useState<string | null>(null);
42+
43+
// Form state
44+
const [name, setName] = useState(currentUser.name ?? "");
45+
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl ?? "");
46+
47+
// Fetch user's challenges
48+
const userChallenges = useQuery(api.queries.participations.getUserChallenges, {
49+
userId: currentUser._id,
50+
});
51+
52+
const updateUser = useMutation(api.mutations.users.updateUser);
53+
54+
const handleSaveProfile = async () => {
55+
if (isUpdating) return;
56+
setIsUpdating(true);
57+
setUpdateSuccess(false);
58+
setUpdateError(null);
59+
60+
try {
61+
await updateUser({
62+
userId: currentUser._id,
63+
name: name.trim() || undefined,
64+
avatarUrl: avatarUrl.trim() || undefined,
65+
});
66+
67+
setUpdateSuccess(true);
68+
// Refresh the page to show updated data
69+
router.refresh();
70+
} catch (error) {
71+
console.error("Failed to update profile:", error);
72+
setUpdateError("Failed to update your profile. Please try again.");
73+
} finally {
74+
setIsUpdating(false);
75+
}
76+
};
77+
78+
return (
79+
<div className="space-y-6">
80+
{/* Header */}
81+
<div>
82+
<h1 className="text-3xl font-bold flex items-center gap-2">
83+
<Settings className="h-8 w-8" />
84+
Settings
85+
</h1>
86+
<p className="text-muted-foreground mt-1">
87+
Manage your profile and preferences
88+
</p>
89+
</div>
90+
91+
{/* Profile Settings */}
92+
<Card>
93+
<CardHeader>
94+
<CardTitle className="flex items-center gap-2">
95+
<User className="h-5 w-5" />
96+
Profile Information
97+
</CardTitle>
98+
<CardDescription>
99+
Update your profile details
100+
</CardDescription>
101+
</CardHeader>
102+
<CardContent className="space-y-4">
103+
{/* Avatar Preview */}
104+
<div className="flex items-center gap-4">
105+
<UserAvatar
106+
user={{
107+
id: currentUser._id,
108+
username: currentUser.username,
109+
name: name || currentUser.name || null,
110+
avatarUrl: avatarUrl || currentUser.avatarUrl || null,
111+
}}
112+
size="xl"
113+
/>
114+
<div className="text-sm text-muted-foreground">
115+
<p className="font-medium">@{currentUser.username}</p>
116+
<p>{currentUser.email}</p>
117+
</div>
118+
</div>
119+
120+
{/* Name Field */}
121+
<div className="space-y-2">
122+
<Label htmlFor="name">Display Name</Label>
123+
<Input
124+
id="name"
125+
type="text"
126+
value={name}
127+
onChange={(e) => setName(e.target.value)}
128+
placeholder="Your name"
129+
/>
130+
</div>
131+
132+
{/* Avatar URL Field */}
133+
<div className="space-y-2">
134+
<Label htmlFor="avatarUrl">Avatar URL</Label>
135+
<Input
136+
id="avatarUrl"
137+
type="url"
138+
value={avatarUrl}
139+
onChange={(e) => setAvatarUrl(e.target.value)}
140+
placeholder="https://example.com/avatar.jpg"
141+
/>
142+
<p className="text-xs text-muted-foreground">
143+
Enter a direct link to an image
144+
</p>
145+
</div>
146+
147+
{/* Save Button */}
148+
<div className="space-y-2">
149+
<Button
150+
onClick={handleSaveProfile}
151+
disabled={isUpdating}
152+
className="w-full sm:w-auto"
153+
>
154+
{isUpdating ? (
155+
<>
156+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
157+
Saving...
158+
</>
159+
) : (
160+
"Save Changes"
161+
)}
162+
</Button>
163+
{updateSuccess && (
164+
<p className="text-sm text-green-600">Profile updated successfully!</p>
165+
)}
166+
{updateError && (
167+
<p className="text-sm text-red-600">{updateError}</p>
168+
)}
169+
</div>
170+
</CardContent>
171+
</Card>
172+
173+
{/* Challenge Switcher */}
174+
<Card>
175+
<CardHeader>
176+
<CardTitle className="flex items-center gap-2">
177+
<List className="h-5 w-5" />
178+
Your Challenges
179+
</CardTitle>
180+
<CardDescription>
181+
Switch between challenges you&apos;re participating in
182+
</CardDescription>
183+
</CardHeader>
184+
<CardContent>
185+
{userChallenges === undefined ? (
186+
<div className="flex items-center justify-center py-8">
187+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
188+
</div>
189+
) : userChallenges && userChallenges.length > 0 ? (
190+
<div className="space-y-2">
191+
{userChallenges.map((challenge: Doc<"challenges">) => {
192+
const isCurrent = challenge._id === currentChallengeId;
193+
return (
194+
<Link
195+
key={challenge._id}
196+
href={`/challenges/${challenge._id}/dashboard`}
197+
className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
198+
isCurrent
199+
? "border-primary bg-primary/5"
200+
: "hover:bg-muted/50"
201+
}`}
202+
>
203+
<div>
204+
<p className="font-medium">{challenge.name}</p>
205+
<p className="text-sm text-muted-foreground">
206+
{new Date(challenge.startDate).toLocaleDateString()} -{" "}
207+
{new Date(challenge.endDate).toLocaleDateString()}
208+
</p>
209+
</div>
210+
{isCurrent && (
211+
<Check className="h-5 w-5 text-primary" />
212+
)}
213+
</Link>
214+
);
215+
})}
216+
</div>
217+
) : (
218+
<p className="py-8 text-center text-sm text-muted-foreground">
219+
You&apos;re not participating in any challenges yet.
220+
</p>
221+
)}
222+
223+
<div className="mt-4">
224+
<Button variant="outline" asChild className="w-full">
225+
<Link href="/challenges">Browse all challenges</Link>
226+
</Button>
227+
</div>
228+
</CardContent>
229+
</Card>
230+
</div>
231+
);
232+
}

apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Flame,
1313
Loader2,
1414
Medal,
15+
Settings,
1516
Trophy,
1617
UserMinus,
1718
UserPlus,
@@ -129,29 +130,43 @@ export function UserProfileContent({
129130
<p className="text-muted-foreground">@{user.username}</p>
130131
</div>
131132

132-
{/* Follow Button */}
133-
{followData && !followData.isOwnProfile && (
134-
<Button
135-
variant={followData.isFollowing ? "outline" : "default"}
136-
size="sm"
137-
onClick={handleToggleFollow}
138-
disabled={isTogglingFollow}
139-
className="min-w-[100px]"
140-
>
141-
{isTogglingFollow ? (
142-
<Loader2 className="h-4 w-4 animate-spin" />
143-
) : followData.isFollowing ? (
144-
<>
145-
<UserMinus className="mr-2 h-4 w-4" />
146-
Unfollow
147-
</>
148-
) : (
149-
<>
150-
<UserPlus className="mr-2 h-4 w-4" />
151-
Follow
152-
</>
153-
)}
154-
</Button>
133+
{/* Settings Button (own profile) or Follow Button */}
134+
{followData && (
135+
followData.isOwnProfile ? (
136+
<Button
137+
variant="outline"
138+
size="sm"
139+
asChild
140+
className="min-w-[100px]"
141+
>
142+
<Link href={`/challenges/${challengeId}/settings`}>
143+
<Settings className="mr-2 h-4 w-4" />
144+
Settings
145+
</Link>
146+
</Button>
147+
) : (
148+
<Button
149+
variant={followData.isFollowing ? "outline" : "default"}
150+
size="sm"
151+
onClick={handleToggleFollow}
152+
disabled={isTogglingFollow}
153+
className="min-w-[100px]"
154+
>
155+
{isTogglingFollow ? (
156+
<Loader2 className="h-4 w-4 animate-spin" />
157+
) : followData.isFollowing ? (
158+
<>
159+
<UserMinus className="mr-2 h-4 w-4" />
160+
Unfollow
161+
</>
162+
) : (
163+
<>
164+
<UserPlus className="mr-2 h-4 w-4" />
165+
Follow
166+
</>
167+
)}
168+
</Button>
169+
)
155170
)}
156171
</div>
157172

0 commit comments

Comments
 (0)