Skip to content

Commit 12cbe4a

Browse files
fix(auth): set app_metadata.tenant_id in create_school RPC + refresh session
The create_school RPC now writes app_metadata.tenant_id directly so the JWT hook has the correct tenant context on the next token refresh. Both create-school components call refreshSession() after the RPC, ensuring the browser gets a JWT with tenant_role before redirecting to the subdomain. This eliminates the dependency on the proxy's tenant sync for new schools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f324374 commit 12cbe4a

3 files changed

Lines changed: 100 additions & 2 deletions

File tree

components/tenant/create-school-flow.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function CreateSchoolFlow({ user }: CreateSchoolFlowProps) {
3535
const [step, setStep] = useState<'account' | 'school'>(user ? 'school' : 'account')
3636
const [loading, setLoading] = useState(false)
3737
const [error, setError] = useState<string | null>(null)
38+
const [emailConfirmationNeeded, setEmailConfirmationNeeded] = useState(false)
3839
const [signedInEmail, setSignedInEmail] = useState(user?.email || '')
3940

4041
// Account state
@@ -116,7 +117,7 @@ export function CreateSchoolFlow({ user }: CreateSchoolFlowProps) {
116117
})
117118

118119
if (signInError) {
119-
setError('Account created! Please check your email to confirm, then log in.')
120+
setEmailConfirmationNeeded(true)
120121
setLoading(false)
121122
return
122123
}
@@ -154,6 +155,10 @@ export function CreateSchoolFlow({ user }: CreateSchoolFlowProps) {
154155
// Update preferred tenant so the user lands on this school by default
155156
await supabase.auth.updateUser({ data: { preferred_tenant_id: tenantId } })
156157

158+
// Refresh session so the JWT picks up the new app_metadata.tenant_id
159+
// (set by the create_school RPC) and tenant_role from the hook.
160+
await supabase.auth.refreshSession()
161+
157162
toast.success('School created! Setting up your dashboard...')
158163

159164
// Redirect to subdomain onboarding
@@ -205,7 +210,7 @@ export function CreateSchoolFlow({ user }: CreateSchoolFlowProps) {
205210
)}
206211

207212
{/* Step 1: Account — sign up + sign in */}
208-
{step === 'account' && (
213+
{step === 'account' && !emailConfirmationNeeded && (
209214
<Card className="bg-zinc-900 border-zinc-800">
210215
<CardContent className="pt-6">
211216
<form onSubmit={handleSignUp} className="space-y-4">
@@ -332,6 +337,45 @@ export function CreateSchoolFlow({ user }: CreateSchoolFlowProps) {
332337
</Card>
333338
)}
334339

340+
{/* Email confirmation needed */}
341+
{step === 'account' && emailConfirmationNeeded && (
342+
<Card className="bg-zinc-900 border-zinc-800">
343+
<CardContent className="pt-6">
344+
<div className="flex flex-col items-center text-center space-y-4 py-4">
345+
<div className="w-14 h-14 bg-emerald-500/10 rounded-2xl flex items-center justify-center border border-emerald-500/20">
346+
<Mail className="w-7 h-7 text-emerald-400" />
347+
</div>
348+
<div className="space-y-2">
349+
<h2 className="text-xl font-semibold text-white">Check your email</h2>
350+
<p className="text-sm text-zinc-400">
351+
We sent a confirmation link to{' '}
352+
<span className="text-zinc-200 font-medium">{email}</span>.
353+
<br />
354+
Please verify your email, then log in to continue.
355+
</p>
356+
</div>
357+
<div className="w-full pt-2 space-y-3">
358+
<Link href="/auth/login?redirectTo=/create-school">
359+
<Button className="w-full bg-blue-600 hover:bg-blue-500 text-white">
360+
Go to Log in
361+
<ArrowRight className="ml-2 w-4 h-4" />
362+
</Button>
363+
</Link>
364+
<Button
365+
type="button"
366+
variant="ghost"
367+
onClick={() => { setEmailConfirmationNeeded(false); setError(null) }}
368+
className="w-full text-zinc-500 hover:text-zinc-300"
369+
>
370+
<ArrowLeft className="mr-2 w-4 h-4" />
371+
Back to sign up
372+
</Button>
373+
</div>
374+
</div>
375+
</CardContent>
376+
</Card>
377+
)}
378+
335379
{/* Step 2: School — name and URL */}
336380
{step === 'school' && (
337381
<Card className="bg-zinc-900 border-zinc-800">

components/tenant/create-school-form.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ export function CreateSchoolForm({ userId }: { userId: string }) {
5858
data: { preferred_tenant_id: tenantId }
5959
})
6060

61+
// Refresh session so the JWT picks up the new tenant_id/tenant_role
62+
await supabase.auth.refreshSession()
63+
6164
toast.success('School created successfully!')
6265

6366
// Redirect to subdomain
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
-- =============================================================================
2+
-- Fix create_school RPC to set app_metadata.tenant_id
3+
--
4+
-- The JWT hook reads tenant_id from app_metadata to inject tenant_role into
5+
-- the JWT. Without this, new school creators get stale JWTs (no tenant_id)
6+
-- and RLS blocks all queries until the proxy syncs it on a subsequent request.
7+
--
8+
-- By setting app_metadata.tenant_id in the RPC itself, the very next token
9+
-- refresh will produce a JWT with the correct tenant_id and tenant_role.
10+
-- =============================================================================
11+
12+
CREATE OR REPLACE FUNCTION public.create_school(_name text, _slug text)
13+
RETURNS uuid
14+
LANGUAGE plpgsql
15+
SECURITY DEFINER
16+
SET search_path TO ''
17+
AS $$
18+
DECLARE
19+
_tenant_id UUID;
20+
_user_id UUID;
21+
BEGIN
22+
_user_id := auth.uid();
23+
24+
IF _user_id IS NULL THEN
25+
RAISE EXCEPTION 'Not authenticated';
26+
END IF;
27+
28+
-- Create the tenant
29+
INSERT INTO public.tenants (name, slug, status)
30+
VALUES (_name, _slug, 'active')
31+
RETURNING id INTO _tenant_id;
32+
33+
-- Add creator as admin of the new tenant
34+
INSERT INTO public.tenant_users (tenant_id, user_id, role, status)
35+
VALUES (_tenant_id, _user_id, 'admin', 'active');
36+
37+
-- Set app_metadata.tenant_id so the JWT hook injects correct claims
38+
-- on the next token refresh (before the proxy even runs).
39+
UPDATE auth.users
40+
SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb)
41+
|| jsonb_build_object('tenant_id', _tenant_id::text)
42+
WHERE id = _user_id;
43+
44+
RETURN _tenant_id;
45+
END;
46+
$$;
47+
48+
-- Only authenticated users can call this
49+
REVOKE ALL ON FUNCTION public.create_school(text, text) FROM PUBLIC;
50+
REVOKE ALL ON FUNCTION public.create_school(text, text) FROM anon;
51+
GRANT EXECUTE ON FUNCTION public.create_school(text, text) TO authenticated;

0 commit comments

Comments
 (0)