Skip to content

Commit 64999a1

Browse files
authored
Fix challenge-scoped admin auth across admin screens and actions (#42)
1 parent ea8fcc6 commit 64999a1

7 files changed

Lines changed: 77 additions & 80 deletions

File tree

.claude/napkin.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,7 @@
6464
- Root layout auth preloads can execute during static prerender (`/_not-found`); guard token preload and fail open when auth env vars are absent at build time.
6565
- Vercel can ignore root `vercel.json` when project Root Directory is `apps/web`; keep an `apps/web/vercel.json` with the Convex deploy build command to avoid accidental `pnpm build` fallback.
6666
- For CLI work in this repo, prefer Bun runtime (`bun ...`) when feasible.
67+
| 2026-02-17 | self | Ran `ls` before reading napkin at session start | Always read `.claude/napkin.md` as the first command in every session |
68+
| 2026-02-17 | self | Ran `sed` on bracketed Next route paths without quoting, causing zsh `no matches found` | Always single-quote paths containing `[id]` before `sed`/`cat`/`rg` |
69+
| 2026-02-17 | self | Queried prod with `queries/users:getByEmailPublic` and failed because deployed function name was `queries/users:getByEmail` | When checking prod Convex, list available functions from error output and call the deployed name instead of assuming local function names |
70+
| 2026-02-17 | self | Missed a closing quote in a `sed` command for a bracketed path and got `unmatched '` | Double-check shell quoting when commands include `[id]` paths before execution |

apps/web/app/challenges/[id]/admin/activity-types/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { getConvexClient } from "@/lib/convex-server";
22
import { api } from "@repo/backend";
33
import type { Id } from "@repo/backend/_generated/dataModel";
44

5-
import { requireAuth } from "@/lib/auth";
65
import { getChallengeOrThrow } from "@/lib/challenge-helpers";
76
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
87
import { AdminActivityTypesTable } from "@/components/admin/admin-activity-types-table";
@@ -15,11 +14,14 @@ export default async function ActivityTypesAdminPage({
1514
params,
1615
}: ActivityTypesAdminPageProps) {
1716
const convex = getConvexClient();
18-
const user = await requireAuth();
1917
const { id } = await params;
2018
const challenge = await getChallengeOrThrow(id);
2119

22-
if (challenge.creatorId !== user._id && user.role !== "admin") {
20+
const adminStatus = await convex.query(api.queries.participations.isUserChallengeAdmin, {
21+
challengeId: challenge.id as Id<"challenges">,
22+
});
23+
24+
if (!adminStatus.isAdmin) {
2325
return null;
2426
}
2527

apps/web/app/challenges/[id]/admin/flagged-activities/[activityId]/page.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { getConvexClient } from "@/lib/convex-server";
44
import { api } from "@repo/backend";
55
import type { Id } from "@repo/backend/_generated/dataModel";
66

7-
import { requireAuth } from "@/lib/auth";
8-
import { getChallengeOrThrow } from "@/lib/challenge-helpers";
97
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
108
import { Badge } from "@/components/ui/badge";
119
import { FlaggedActivityActions } from "@/components/admin/flagged-activity-actions";
@@ -34,7 +32,6 @@ export default async function FlaggedActivityDetailPage({
3432
params,
3533
}: FlaggedActivityDetailPageProps) {
3634
const convex = getConvexClient();
37-
const user = await requireAuth();
3835
const { activityId } = await params;
3936

4037
const detail = await convex.query(api.queries.admin.getFlaggedActivityDetail, {
@@ -45,9 +42,11 @@ export default async function FlaggedActivityDetailPage({
4542
notFound();
4643
}
4744

48-
const challenge = await getChallengeOrThrow(detail.activity.challengeId as string);
45+
const adminStatus = await convex.query(api.queries.participations.isUserChallengeAdmin, {
46+
challengeId: detail.activity.challengeId as Id<"challenges">,
47+
});
4948

50-
if (challenge.creatorId !== user._id && user.role !== "admin") {
49+
if (!adminStatus.isAdmin) {
5150
notFound();
5251
}
5352

apps/web/app/challenges/[id]/admin/flagged-activities/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { getConvexClient } from "@/lib/convex-server";
44
import { api } from "@repo/backend";
55
import type { Id } from "@repo/backend/_generated/dataModel";
66

7-
import { requireAuth } from "@/lib/auth";
87
import { getChallengeOrThrow } from "@/lib/challenge-helpers";
98
import { flaggedActivitiesQuerySchema } from "@/lib/validations";
109
import { Badge } from "@/components/ui/badge";
@@ -39,12 +38,15 @@ export default async function FlaggedActivitiesPage({
3938
searchParams,
4039
}: FlaggedActivitiesPageProps) {
4140
const convex = getConvexClient();
42-
const user = await requireAuth();
4341
const { id } = await params;
4442
const searchParamsResolved = await searchParams;
4543
const challenge = await getChallengeOrThrow(id);
4644

47-
if (challenge.creatorId !== user._id && user.role !== "admin") {
45+
const adminStatus = await convex.query(api.queries.participations.isUserChallengeAdmin, {
46+
challengeId: challenge.id as Id<"challenges">,
47+
});
48+
49+
if (!adminStatus.isAdmin) {
4850
return null;
4951
}
5052

apps/web/app/challenges/[id]/admin/integrations/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { getConvexClient } from "@/lib/convex-server";
22
import { api } from "@repo/backend";
33
import type { Id } from "@repo/backend/_generated/dataModel";
44

5-
import { requireAuth } from "@/lib/auth";
65
import { getChallengeOrThrow } from "@/lib/challenge-helpers";
76
import { IntegrationsTabs } from "./integrations-tabs";
87

@@ -14,11 +13,14 @@ export default async function IntegrationsAdminPage({
1413
params,
1514
}: IntegrationsAdminPageProps) {
1615
const convex = getConvexClient();
17-
const user = await requireAuth();
1816
const { id } = await params;
1917
const challenge = await getChallengeOrThrow(id);
2018

21-
if (challenge.creatorId !== user._id && user.role !== "admin") {
19+
const adminStatus = await convex.query(api.queries.participations.isUserChallengeAdmin, {
20+
challengeId: challenge.id as Id<"challenges">,
21+
});
22+
23+
if (!adminStatus.isAdmin) {
2224
return null;
2325
}
2426

packages/backend/mutations/admin.ts

Lines changed: 46 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11
import { internalMutation, mutation } from "../_generated/server";
22
import { v } from "convex/values";
33
import { getCurrentUser } from "../lib/ids";
4+
import type { Id } from "../_generated/dataModel";
5+
6+
async function requireChallengeAdminForActivity(
7+
ctx: { db: any; auth: any },
8+
activityId: Id<"activities">,
9+
) {
10+
const user = await getCurrentUser(ctx as any);
11+
if (!user) {
12+
throw new Error("Not authenticated");
13+
}
14+
15+
const activity = await ctx.db.get(activityId);
16+
if (!activity || activity.deletedAt) {
17+
throw new Error("Activity not found");
18+
}
19+
20+
const challenge = await ctx.db.get(activity.challengeId);
21+
if (!challenge) {
22+
throw new Error("Challenge not found");
23+
}
24+
25+
const isGlobalAdmin = user.role === "admin";
26+
const isCreator = challenge.creatorId === user._id;
27+
const participation = await ctx.db
28+
.query("userChallenges")
29+
.withIndex("userChallengeUnique", (q: any) =>
30+
q.eq("userId", user._id).eq("challengeId", activity.challengeId),
31+
)
32+
.first();
33+
const isChallengeAdmin = participation?.role === "admin";
34+
35+
if (!isGlobalAdmin && !isCreator && !isChallengeAdmin) {
36+
throw new Error("Not authorized - challenge admin required");
37+
}
38+
39+
return { user, activity };
40+
}
441

542
// Internal mutation to delete a challenge and all related data (for scripts/migrations)
643
export const deleteChallenge = internalMutation({
@@ -84,28 +121,7 @@ export const updateFlagResolution = mutation({
84121
notes: v.optional(v.string()),
85122
},
86123
handler: async (ctx, args) => {
87-
const user = await getCurrentUser(ctx);
88-
if (!user || user.role !== "admin") {
89-
throw new Error("Not authorized - admin only");
90-
}
91-
92-
const activity = await ctx.db.get(args.activityId);
93-
if (!activity || activity.deletedAt) {
94-
throw new Error("Activity not found");
95-
}
96-
97-
// Check if user can manage this challenge
98-
const challenge = await ctx.db.get(activity.challengeId);
99-
if (!challenge) {
100-
throw new Error("Challenge not found");
101-
}
102-
103-
const canManage =
104-
user.role === "admin" || challenge.creatorId === user._id;
105-
106-
if (!canManage) {
107-
throw new Error("Not authorized to manage this challenge");
108-
}
124+
const { user } = await requireChallengeAdminForActivity(ctx, args.activityId);
109125

110126
const now = Date.now();
111127

@@ -143,28 +159,10 @@ export const addAdminComment = mutation({
143159
visibility: v.union(v.literal("internal"), v.literal("participant")),
144160
},
145161
handler: async (ctx, args) => {
146-
const user = await getCurrentUser(ctx);
147-
if (!user || user.role !== "admin") {
148-
throw new Error("Not authorized - admin only");
149-
}
150-
151-
const activity = await ctx.db.get(args.activityId);
152-
if (!activity || activity.deletedAt) {
153-
throw new Error("Activity not found");
154-
}
155-
156-
// Check if user can manage this challenge
157-
const challenge = await ctx.db.get(activity.challengeId);
158-
if (!challenge) {
159-
throw new Error("Challenge not found");
160-
}
161-
162-
const canManage =
163-
user.role === "admin" || challenge.creatorId === user._id;
164-
165-
if (!canManage) {
166-
throw new Error("Not authorized to manage this challenge");
167-
}
162+
const { user, activity } = await requireChallengeAdminForActivity(
163+
ctx,
164+
args.activityId,
165+
);
168166

169167
const now = Date.now();
170168

@@ -216,28 +214,10 @@ export const adminEditActivity = mutation({
216214
metrics: v.optional(v.any()),
217215
},
218216
handler: async (ctx, args) => {
219-
const user = await getCurrentUser(ctx);
220-
if (!user || user.role !== "admin") {
221-
throw new Error("Not authorized - admin only");
222-
}
223-
224-
const activity = await ctx.db.get(args.activityId);
225-
if (!activity || activity.deletedAt) {
226-
throw new Error("Activity not found");
227-
}
228-
229-
// Check if user can manage this challenge
230-
const challenge = await ctx.db.get(activity.challengeId);
231-
if (!challenge) {
232-
throw new Error("Challenge not found");
233-
}
234-
235-
const canManage =
236-
user.role === "admin" || challenge.creatorId === user._id;
237-
238-
if (!canManage) {
239-
throw new Error("Not authorized to manage this challenge");
240-
}
217+
const { user, activity } = await requireChallengeAdminForActivity(
218+
ctx,
219+
args.activityId,
220+
);
241221

242222
const now = Date.now();
243223
const updates: Record<string, unknown> = {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# 2026-02-17 Admin Scope Prod Verification
2+
3+
- [x] Identify where admin access is scoped in code (backend + web checks)
4+
- [x] Verify production data for `prazgaitis@gmail.com` (user + admin role bindings)
5+
- [x] Confirm prod environment configuration used by admin checks
6+
- [x] Pinpoint mismatch between code and data causing missing admin access
7+
- [x] Implement/fix code if needed and validate
8+
- [x] Summarize findings and exact remediation steps

0 commit comments

Comments
 (0)