Skip to content

Commit 3f3ff8b

Browse files
fix(auth): fix JWT cookie propagation + missing profile trigger (Fixes LMS-FRONT-7H)
proxy.ts used headers.get('set-cookie') on protected routes, which only returns the first cookie — dropping the refreshed JWT tokens after tenant sync. Fixed to use getSetCookie() + append() (matching the public routes path). This caused fresh accounts to have stale JWT claims, making RLS block course reads after creation. Also adds the missing on_auth_user_created trigger (was never in migrations) with profile backfill, and a safety-net profile upsert in createCourse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 48f0a08 commit 3f3ff8b

5 files changed

Lines changed: 62 additions & 11 deletions

File tree

app/[locale]/dashboard/teacher/courses/[courseId]/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ export default async function CourseManagementPage({ params }: PageProps) {
4242
redirect('/auth/login')
4343
}
4444

45-
// Use admin client — RLS get_tenant_id() reads JWT claims which may not match
46-
// the actual subdomain tenant. We validate tenant ownership manually.
4745
const { data: course, error: courseError } = await supabase
4846
.from('courses')
4947
.select('*')

app/actions/teacher/courses.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ export async function createCourse(courseData: CourseFormData) {
132132
// The user's JWT may have stale tenant_role claims that don't match the
133133
// RLS policy on courses (requires tenant_role = teacher|admin in JWT).
134134
const adminClient = createAdminClient()
135+
136+
// Ensure profile exists (FK courses_author_profile_fkey requires it).
137+
// The on_auth_user_created trigger should create profiles, but as a safety
138+
// net for accounts created before the trigger was added, upsert here.
139+
await adminClient
140+
.from('profiles')
141+
.upsert(
142+
{ id: user.id, full_name: user.user_metadata?.full_name || null },
143+
{ onConflict: 'id', ignoreDuplicates: true }
144+
)
145+
135146
const { data: course, error } = await adminClient
136147
.from('courses')
137148
.insert({
@@ -148,7 +159,7 @@ export async function createCourse(courseData: CourseFormData) {
148159

149160
if (error) {
150161
console.error('Failed to create course:', error)
151-
throw new Error('Failed to create course')
162+
throw new Error(`Failed to create course: ${error.message}`)
152163
}
153164

154165
revalidatePath('/dashboard/teacher/courses')
@@ -204,7 +215,7 @@ export async function updateCourse(courseId: number, courseData: CourseFormData)
204215

205216
if (error) {
206217
console.error('Failed to update course:', error)
207-
throw new Error('Failed to update course')
218+
throw new Error(`Failed to update course: ${error.message}`)
208219
}
209220

210221
revalidatePath('/dashboard/teacher/courses')

components/teacher/course-form.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,12 @@ export function CourseForm({ categories, initialData }: CourseFormProps) {
160160
value={formData.title}
161161
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
162162
placeholder={t('titlePlaceholder')}
163+
maxLength={100}
163164
required
164165
/>
166+
{formData.title.length > 80 && (
167+
<p className="text-xs text-muted-foreground">{formData.title.length}/100</p>
168+
)}
165169
</div>
166170

167171
<div className="space-y-2">

proxy.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,8 @@ export default async function proxy(request: NextRequest) {
394394
}
395395
// Allow super admin through — bypass tenant membership checks
396396
const finalPlatformResponse = intlResponse
397-
const supabasePlatformCookies = supabaseResponse.headers.get('set-cookie')
398-
if (supabasePlatformCookies) {
399-
finalPlatformResponse.headers.set('set-cookie', supabasePlatformCookies)
397+
for (const cookie of supabaseResponse.headers.getSetCookie()) {
398+
finalPlatformResponse.headers.append('set-cookie', cookie)
400399
}
401400
finalPlatformResponse.headers.set('x-tenant-id', tenantId)
402401
return finalPlatformResponse
@@ -416,11 +415,11 @@ export default async function proxy(request: NextRequest) {
416415
return NextResponse.redirect(new URL(`/${locale}/dashboard/${userRole}`, request.url))
417416
}
418417

419-
// Allow access
418+
// Allow access — copy ALL Set-Cookie headers (not just the first one)
419+
// so refreshed JWT tokens from tenant sync are fully propagated.
420420
const finalResponse = intlResponse
421-
const supabaseCookies = supabaseResponse.headers.get('set-cookie')
422-
if (supabaseCookies) {
423-
finalResponse.headers.set('set-cookie', supabaseCookies)
421+
for (const cookie of supabaseResponse.headers.getSetCookie()) {
422+
finalResponse.headers.append('set-cookie', cookie)
424423
}
425424

426425
return finalResponse
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- =============================================================================
2+
-- Fix missing profile trigger + backfill profiles for existing users
3+
--
4+
-- Root cause of LMS-FRONT-7H ("Failed to create course"):
5+
-- The `handle_new_user()` function exists but the trigger
6+
-- `on_auth_user_created` was never created via migrations.
7+
-- Combined with the new FK `courses_author_profile_fkey`
8+
-- (courses.author_id → profiles.id), any user without a profile
9+
-- row cannot create courses.
10+
--
11+
-- Fixes:
12+
-- 1. Backfill missing profiles for all auth.users
13+
-- 2. Create the trigger (idempotent) so new signups always get a profile
14+
-- =============================================================================
15+
16+
17+
-- ---------------------------------------------------------------------------
18+
-- 1. Backfill: insert profiles for any auth.users that don't have one
19+
-- ---------------------------------------------------------------------------
20+
21+
INSERT INTO public.profiles (id, full_name, avatar_url)
22+
SELECT
23+
u.id,
24+
u.raw_user_meta_data->>'full_name',
25+
u.raw_user_meta_data->>'avatar_url'
26+
FROM auth.users u
27+
LEFT JOIN public.profiles p ON p.id = u.id
28+
WHERE p.id IS NULL;
29+
30+
31+
-- ---------------------------------------------------------------------------
32+
-- 2. Ensure the trigger exists (DROP + CREATE for idempotency)
33+
-- ---------------------------------------------------------------------------
34+
35+
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
36+
37+
CREATE TRIGGER on_auth_user_created
38+
AFTER INSERT ON auth.users
39+
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

0 commit comments

Comments
 (0)