Skip to content

Commit 1c983d5

Browse files
claudedan-lee
authored andcommitted
refactor: Convert Supabase Auth from library UI to custom components
Replace @supabase/auth-ui-react with custom UI components matching the Firebase Auth implementation. This provides: - Consistent UX across all auth providers using shared ZudokuAuthUi - Direct Supabase client API calls for all auth operations - Custom error message mapping for better user experience - Support for email/password sign in/up, OAuth, password reset, and email verification flows Changes: - Update supabase.tsx to use ZudokuSignInUi, ZudokuSignUpUi, ZudokuPasswordResetUi, and EmailVerificationUi components - Add getSupabaseErrorMessage function for user-friendly error messages - Remove SupabaseAuthUI.tsx and @supabase/auth-ui-* dependencies https://claude.ai/code/session_01Hq3vMiJDcKrU8NSHyvmdZS
1 parent f16a6f6 commit 1c983d5

File tree

4 files changed

+234
-111
lines changed

4 files changed

+234
-111
lines changed

packages/zudoku/package.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,6 @@
351351
"@azure/msal-browser": "^4.13.0",
352352
"@clerk/clerk-js": "^5.63.1",
353353
"@sentry/react": "^10.0.0",
354-
"@supabase/auth-ui-react": "^0.4.0",
355-
"@supabase/auth-ui-shared": "^0.1.0",
356354
"@supabase/supabase-js": "^2.49.4",
357355
"firebase": "^12.6.0",
358356
"mermaid": "^11.0.0",
@@ -379,12 +377,6 @@
379377
"@supabase/supabase-js": {
380378
"optional": true
381379
},
382-
"@supabase/auth-ui-react": {
383-
"optional": true
384-
},
385-
"@supabase/auth-ui-shared": {
386-
"optional": true
387-
},
388380
"firebase": {
389381
"optional": true
390382
},

packages/zudoku/src/lib/authentication/providers/supabase.tsx

Lines changed: 234 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
22
createClient,
3+
type Provider,
34
type Session,
45
type SupabaseClient,
56
} from "@supabase/supabase-js";
67
import type { SupabaseAuthenticationConfig } from "../../../config/config.js";
8+
import { ZudokuError } from "../../util/invariant.js";
79
import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
810
import type {
911
AuthActionContext,
@@ -14,14 +16,21 @@ import type {
1416
import { SignOut } from "../components/SignOut.js";
1517
import { AuthorizationError } from "../errors.js";
1618
import { type UserProfile, useAuthState } from "../state.js";
17-
import { SupabaseAuthUI } from "./supabase/SupabaseAuthUI.js";
19+
import { EmailVerificationUi } from "../ui/EmailVerificationUi.js";
20+
import {
21+
ZudokuPasswordResetUi,
22+
ZudokuSignInUi,
23+
ZudokuSignUpUi,
24+
} from "../ui/ZudokuAuthUi.js";
1825

1926
class SupabaseAuthenticationProvider
2027
extends CoreAuthenticationPlugin
2128
implements AuthenticationPlugin
2229
{
2330
private readonly client: SupabaseClient;
2431
private readonly config: SupabaseAuthenticationConfig;
32+
private readonly providers: string[];
33+
private readonly enableUsernamePassword: boolean;
2534

2635
constructor(config: SupabaseAuthenticationConfig) {
2736
const { supabaseUrl, supabaseKey } = config;
@@ -35,6 +44,13 @@ class SupabaseAuthenticationProvider
3544
});
3645
this.config = config;
3746

47+
// Support both 'provider' (deprecated) and 'providers' config
48+
const configuredProviders = config.provider
49+
? [config.provider]
50+
: (config.providers ?? []);
51+
this.providers = configuredProviders;
52+
this.enableUsernamePassword = !config.onlyThirdPartyProviders;
53+
3854
this.client.auth.onAuthStateChange(async (event, session) => {
3955
if (session && (event === "SIGNED_IN" || event === "TOKEN_REFRESHED")) {
4056
await this.updateUserState(session);
@@ -99,25 +115,183 @@ class SupabaseAuthenticationProvider
99115
);
100116
};
101117

118+
requestEmailVerification = async (
119+
{ navigate }: AuthActionContext,
120+
{ redirectTo }: AuthActionOptions,
121+
) => {
122+
const {
123+
data: { user },
124+
} = await this.client.auth.getUser();
125+
if (!user || !user.email) {
126+
throw new ZudokuError("User is not authenticated", {
127+
title: "User not authenticated",
128+
});
129+
}
130+
131+
const { error } = await this.client.auth.resend({
132+
type: "signup",
133+
email: user.email,
134+
});
135+
if (error) {
136+
throw Error(getSupabaseErrorMessage(error), { cause: error });
137+
}
138+
139+
void navigate(
140+
redirectTo
141+
? `/verify-email?redirectTo=${encodeURIComponent(redirectTo)}`
142+
: `/verify-email`,
143+
);
144+
};
145+
146+
private onUsernamePasswordSignIn = async (
147+
email: string,
148+
password: string,
149+
) => {
150+
useAuthState.setState({ isPending: true });
151+
const { error } = await this.client.auth.signInWithPassword({
152+
email,
153+
password,
154+
});
155+
useAuthState.setState({ isPending: false });
156+
if (error) {
157+
throw Error(getSupabaseErrorMessage(error), { cause: error });
158+
}
159+
};
160+
161+
private onUsernamePasswordSignUp = async (
162+
email: string,
163+
password: string,
164+
) => {
165+
useAuthState.setState({ isPending: true });
166+
const { data, error } = await this.client.auth.signUp({
167+
email,
168+
password,
169+
options: {
170+
emailRedirectTo: `${window.location.origin}${this.config.basePath ?? ""}/verify-email`,
171+
},
172+
});
173+
useAuthState.setState({ isPending: false });
174+
if (error) {
175+
throw Error(getSupabaseErrorMessage(error), { cause: error });
176+
}
177+
178+
// If user exists and is confirmed, update state
179+
if (data.user) {
180+
const profile: UserProfile = {
181+
sub: data.user.id,
182+
email: data.user.email,
183+
name: data.user.user_metadata.full_name || data.user.user_metadata.name,
184+
emailVerified: data.user.email_confirmed_at != null,
185+
pictureUrl: data.user.user_metadata.avatar_url,
186+
};
187+
188+
useAuthState.getState().setLoggedIn({
189+
profile,
190+
providerData: { session: data.session },
191+
});
192+
}
193+
};
194+
195+
private onOAuthSignIn = async (providerId: string) => {
196+
useAuthState.setState({ isPending: true });
197+
const { error } = await this.client.auth.signInWithOAuth({
198+
provider: providerId as Provider,
199+
options: {
200+
redirectTo:
201+
this.config.redirectToAfterSignIn ??
202+
`${window.location.origin}${this.config.basePath ?? ""}`,
203+
},
204+
});
205+
if (error) {
206+
useAuthState.setState({ isPending: false });
207+
throw new AuthorizationError(error.message);
208+
}
209+
// Note: OAuth sign-in redirects the page, so isPending stays true
210+
};
211+
212+
private onPasswordReset = async (email: string) => {
213+
const { error } = await this.client.auth.resetPasswordForEmail(email, {
214+
redirectTo: `${window.location.origin}${this.config.basePath ?? ""}/reset-password`,
215+
});
216+
if (error) {
217+
throw Error(getSupabaseErrorMessage(error), { cause: error });
218+
}
219+
};
220+
221+
private onResendVerification = async () => {
222+
const {
223+
data: { user },
224+
} = await this.client.auth.getUser();
225+
if (!user || !user.email) {
226+
throw new ZudokuError("User is not authenticated", {
227+
title: "User not authenticated",
228+
});
229+
}
230+
const { error } = await this.client.auth.resend({
231+
type: "signup",
232+
email: user.email,
233+
});
234+
if (error) {
235+
throw Error(getSupabaseErrorMessage(error), { cause: error });
236+
}
237+
};
238+
239+
private onCheckVerification = async (): Promise<boolean> => {
240+
const { data, error } = await this.client.auth.getUser();
241+
if (error || !data.user) {
242+
return false;
243+
}
244+
245+
const isVerified = data.user.email_confirmed_at != null;
246+
247+
if (isVerified) {
248+
// Refresh the session to get updated token with verified email
249+
await this.client.auth.refreshSession();
250+
const { data: sessionData } = await this.client.auth.getSession();
251+
if (sessionData.session) {
252+
await this.updateUserState(sessionData.session);
253+
}
254+
}
255+
256+
return isVerified;
257+
};
258+
102259
getRoutes = () => {
103260
return [
261+
{
262+
path: "/verify-email",
263+
element: (
264+
<EmailVerificationUi
265+
onResendVerification={this.onResendVerification}
266+
onCheckVerification={this.onCheckVerification}
267+
/>
268+
),
269+
},
270+
{
271+
path: "/reset-password",
272+
element: (
273+
<ZudokuPasswordResetUi onPasswordReset={this.onPasswordReset} />
274+
),
275+
},
104276
{
105277
path: "/signin",
106278
element: (
107-
<SupabaseAuthUI
108-
view="sign_in"
109-
client={this.client}
110-
config={this.config}
279+
<ZudokuSignInUi
280+
providers={this.providers}
281+
enableUsernamePassword={this.enableUsernamePassword}
282+
onOAuthSignIn={this.onOAuthSignIn}
283+
onUsernamePasswordSignIn={this.onUsernamePasswordSignIn}
111284
/>
112285
),
113286
},
114287
{
115288
path: "/signup",
116289
element: (
117-
<SupabaseAuthUI
118-
view="sign_up"
119-
client={this.client}
120-
config={this.config}
290+
<ZudokuSignUpUi
291+
providers={this.providers}
292+
enableUsernamePassword={this.enableUsernamePassword}
293+
onOAuthSignUp={this.onOAuthSignIn}
294+
onUsernamePasswordSignUp={this.onUsernamePasswordSignUp}
121295
/>
122296
),
123297
},
@@ -151,3 +325,54 @@ const supabaseAuth: AuthenticationProviderInitializer<
151325
> = (options) => new SupabaseAuthenticationProvider(options);
152326

153327
export default supabaseAuth;
328+
329+
const getSupabaseErrorMessage = (error: unknown): string => {
330+
if (!(error instanceof Error)) {
331+
return "An unexpected error occurred. Please try again.";
332+
}
333+
334+
const errorMessage = error.message;
335+
336+
// Map common Supabase error messages to user-friendly messages
337+
if (errorMessage.includes("Invalid login credentials")) {
338+
return "The email and password you entered don't match.";
339+
}
340+
if (errorMessage.includes("Email not confirmed")) {
341+
return "Please verify your email address before signing in.";
342+
}
343+
if (errorMessage.includes("User already registered")) {
344+
return "The email address is already used by another account.";
345+
}
346+
if (
347+
errorMessage.includes("Password should be at least") ||
348+
errorMessage.includes("Password must be at least")
349+
) {
350+
return "The password must be at least 6 characters long.";
351+
}
352+
if (errorMessage.includes("Invalid email")) {
353+
return "That email address isn't correct.";
354+
}
355+
if (errorMessage.includes("Email rate limit exceeded")) {
356+
return "Too many requests. Please wait a moment and try again.";
357+
}
358+
if (errorMessage.includes("For security purposes")) {
359+
return "For security purposes, please wait a moment before trying again.";
360+
}
361+
if (errorMessage.includes("Unable to validate email address")) {
362+
return "Unable to validate email address. Please check and try again.";
363+
}
364+
if (errorMessage.includes("Signups not allowed")) {
365+
return "Sign ups are not allowed at this time.";
366+
}
367+
if (errorMessage.includes("User not found")) {
368+
return "That email address doesn't match an existing account.";
369+
}
370+
if (errorMessage.includes("New password should be different")) {
371+
return "Your new password must be different from your current password.";
372+
}
373+
374+
// Return the original message if no mapping found
375+
return (
376+
errorMessage || "An error occurred during authentication. Please try again."
377+
);
378+
};

packages/zudoku/src/lib/authentication/providers/supabase/SupabaseAuthUI.tsx

Lines changed: 0 additions & 75 deletions
This file was deleted.

0 commit comments

Comments
 (0)