-
Notifications
You must be signed in to change notification settings - Fork 66
refactor: Convert Supabase Auth from library UI to custom components #1969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| import { | ||
| createClient, | ||
| type Provider, | ||
| type Session, | ||
| type SupabaseClient, | ||
| } from "@supabase/supabase-js"; | ||
| import type { SupabaseAuthenticationConfig } from "../../../config/config.js"; | ||
| import { ZudokuError } from "../../util/invariant.js"; | ||
| import { joinUrl } from "../../util/joinUrl.js"; | ||
| import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js"; | ||
| import type { | ||
| AuthActionContext, | ||
|
|
@@ -14,14 +17,22 @@ import type { | |
| import { SignOut } from "../components/SignOut.js"; | ||
| import { AuthorizationError } from "../errors.js"; | ||
| import { type UserProfile, useAuthState } from "../state.js"; | ||
| import { SupabaseAuthUI } from "./supabase/SupabaseAuthUI.js"; | ||
| import { EmailVerificationUi } from "../ui/EmailVerificationUi.js"; | ||
| import { | ||
| ZudokuPasswordResetUi, | ||
| ZudokuPasswordUpdateUi, | ||
| ZudokuSignInUi, | ||
| ZudokuSignUpUi, | ||
| } from "../ui/ZudokuAuthUi.js"; | ||
|
|
||
| class SupabaseAuthenticationProvider | ||
| extends CoreAuthenticationPlugin | ||
| implements AuthenticationPlugin | ||
| { | ||
| private readonly client: SupabaseClient; | ||
| private readonly config: SupabaseAuthenticationConfig; | ||
| private readonly providers: string[]; | ||
| private readonly enableUsernamePassword: boolean; | ||
|
|
||
| constructor(config: SupabaseAuthenticationConfig) { | ||
| const { supabaseUrl, supabaseKey } = config; | ||
|
|
@@ -35,6 +46,13 @@ class SupabaseAuthenticationProvider | |
| }); | ||
| this.config = config; | ||
|
|
||
| // Support both 'provider' (deprecated) and 'providers' config | ||
| const configuredProviders = config.provider | ||
| ? [config.provider] | ||
| : (config.providers ?? []); | ||
| this.providers = configuredProviders; | ||
| this.enableUsernamePassword = !config.onlyThirdPartyProviders; | ||
|
|
||
| this.client.auth.onAuthStateChange(async (event, session) => { | ||
| if (session && (event === "SIGNED_IN" || event === "TOKEN_REFRESHED")) { | ||
| await this.updateUserState(session); | ||
|
|
@@ -99,25 +117,204 @@ class SupabaseAuthenticationProvider | |
| ); | ||
| }; | ||
|
|
||
| requestEmailVerification = async ( | ||
| { navigate }: AuthActionContext, | ||
| { redirectTo }: AuthActionOptions, | ||
| ) => { | ||
| const { | ||
| data: { user }, | ||
| } = await this.client.auth.getUser(); | ||
| if (!user || !user.email) { | ||
| throw new ZudokuError("User is not authenticated", { | ||
| title: "User not authenticated", | ||
| }); | ||
| } | ||
|
|
||
| const { error } = await this.client.auth.resend({ | ||
| type: "signup", | ||
| email: user.email, | ||
| }); | ||
| if (error) { | ||
| throw Error(getSupabaseErrorMessage(error), { cause: error }); | ||
| } | ||
|
|
||
| void navigate( | ||
| redirectTo | ||
| ? `/verify-email?redirectTo=${encodeURIComponent(redirectTo)}` | ||
| : `/verify-email`, | ||
| ); | ||
| }; | ||
|
|
||
| private onUsernamePasswordSignIn = async ( | ||
| email: string, | ||
| password: string, | ||
| ) => { | ||
| useAuthState.setState({ isPending: true }); | ||
| const { error } = await this.client.auth.signInWithPassword({ | ||
| email, | ||
| password, | ||
| }); | ||
| useAuthState.setState({ isPending: false }); | ||
| if (error) { | ||
| throw Error(getSupabaseErrorMessage(error), { cause: error }); | ||
| } | ||
| }; | ||
|
|
||
| private onUsernamePasswordSignUp = async ( | ||
| email: string, | ||
| password: string, | ||
| ) => { | ||
| useAuthState.setState({ isPending: true }); | ||
| const { data, error } = await this.client.auth.signUp({ | ||
| email, | ||
| password, | ||
| options: { | ||
| emailRedirectTo: joinUrl( | ||
| window.location.origin, | ||
| this.config.basePath, | ||
| "/verify-email", | ||
| ), | ||
| }, | ||
| }); | ||
| useAuthState.setState({ isPending: false }); | ||
| if (error) { | ||
| throw Error(getSupabaseErrorMessage(error), { cause: error }); | ||
| } | ||
|
|
||
| // If user exists and is confirmed, update state | ||
| if (data.user) { | ||
| const profile: UserProfile = { | ||
| sub: data.user.id, | ||
| email: data.user.email, | ||
| name: data.user.user_metadata.full_name || data.user.user_metadata.name, | ||
| emailVerified: data.user.email_confirmed_at != null, | ||
| pictureUrl: data.user.user_metadata.avatar_url, | ||
| }; | ||
|
|
||
| useAuthState.getState().setLoggedIn({ | ||
| profile, | ||
| providerData: { session: data.session }, | ||
| }); | ||
| } | ||
|
Comment on lines
+184
to
+198
|
||
| }; | ||
|
|
||
| private onOAuthSignIn = async (providerId: string) => { | ||
| useAuthState.setState({ isPending: true }); | ||
| const { error } = await this.client.auth.signInWithOAuth({ | ||
| provider: providerId as Provider, | ||
| options: { | ||
| redirectTo: | ||
| this.config.redirectToAfterSignIn ?? | ||
| joinUrl(window.location.origin, this.config.basePath), | ||
| }, | ||
| }); | ||
| if (error) { | ||
| useAuthState.setState({ isPending: false }); | ||
| throw new AuthorizationError(error.message); | ||
| } | ||
| // Note: OAuth sign-in redirects the page, so isPending stays true | ||
| }; | ||
|
|
||
| private onPasswordReset = async (email: string) => { | ||
| const { error } = await this.client.auth.resetPasswordForEmail(email, { | ||
| redirectTo: joinUrl( | ||
| window.location.origin, | ||
| this.config.basePath, | ||
| "/update-password", | ||
| ), | ||
| }); | ||
|
Comment on lines
171
to
225
|
||
| if (error) { | ||
| throw Error(getSupabaseErrorMessage(error), { cause: error }); | ||
| } | ||
|
Comment on lines
218
to
228
|
||
| }; | ||
|
|
||
| private onPasswordUpdate = async (password: string) => { | ||
| const { error } = await this.client.auth.updateUser({ password }); | ||
| if (error) { | ||
| throw Error(getSupabaseErrorMessage(error), { cause: error }); | ||
| } | ||
| }; | ||
|
|
||
| private onResendVerification = async () => { | ||
| const { | ||
| data: { user }, | ||
| } = await this.client.auth.getUser(); | ||
| if (!user || !user.email) { | ||
| throw new ZudokuError("User is not authenticated", { | ||
| title: "User not authenticated", | ||
| }); | ||
| } | ||
| const { error } = await this.client.auth.resend({ | ||
| type: "signup", | ||
| email: user.email, | ||
| }); | ||
| if (error) { | ||
| throw Error(getSupabaseErrorMessage(error), { cause: error }); | ||
| } | ||
| }; | ||
|
|
||
| private onCheckVerification = async (): Promise<boolean> => { | ||
| const { data, error } = await this.client.auth.getUser(); | ||
| if (error || !data.user) { | ||
| return false; | ||
| } | ||
|
|
||
| const isVerified = data.user.email_confirmed_at != null; | ||
|
|
||
| if (isVerified) { | ||
| // Refresh the session to get updated token with verified email | ||
| await this.client.auth.refreshSession(); | ||
| const { data: sessionData } = await this.client.auth.getSession(); | ||
| if (sessionData.session) { | ||
| await this.updateUserState(sessionData.session); | ||
| } | ||
| } | ||
|
|
||
| return isVerified; | ||
| }; | ||
|
|
||
| getRoutes = () => { | ||
| return [ | ||
| { | ||
| path: "/verify-email", | ||
| element: ( | ||
| <EmailVerificationUi | ||
| onResendVerification={this.onResendVerification} | ||
| onCheckVerification={this.onCheckVerification} | ||
| /> | ||
| ), | ||
| }, | ||
| { | ||
| path: "/reset-password", | ||
| element: ( | ||
| <ZudokuPasswordResetUi onPasswordReset={this.onPasswordReset} /> | ||
| ), | ||
| }, | ||
| { | ||
| path: "/update-password", | ||
| element: ( | ||
| <ZudokuPasswordUpdateUi onPasswordUpdate={this.onPasswordUpdate} /> | ||
| ), | ||
| }, | ||
| { | ||
| path: "/signin", | ||
| element: ( | ||
| <SupabaseAuthUI | ||
| view="sign_in" | ||
| client={this.client} | ||
| config={this.config} | ||
| <ZudokuSignInUi | ||
| providers={this.providers} | ||
| enableUsernamePassword={this.enableUsernamePassword} | ||
| onOAuthSignIn={this.onOAuthSignIn} | ||
| onUsernamePasswordSignIn={this.onUsernamePasswordSignIn} | ||
| /> | ||
| ), | ||
| }, | ||
| { | ||
| path: "/signup", | ||
| element: ( | ||
| <SupabaseAuthUI | ||
| view="sign_up" | ||
| client={this.client} | ||
| config={this.config} | ||
| <ZudokuSignUpUi | ||
| providers={this.providers} | ||
| enableUsernamePassword={this.enableUsernamePassword} | ||
| onOAuthSignUp={this.onOAuthSignIn} | ||
| onUsernamePasswordSignUp={this.onUsernamePasswordSignUp} | ||
| /> | ||
| ), | ||
| }, | ||
|
|
@@ -151,3 +348,54 @@ const supabaseAuth: AuthenticationProviderInitializer< | |
| > = (options) => new SupabaseAuthenticationProvider(options); | ||
|
|
||
| export default supabaseAuth; | ||
|
|
||
| const getSupabaseErrorMessage = (error: unknown): string => { | ||
| if (!(error instanceof Error)) { | ||
| return "An unexpected error occurred. Please try again."; | ||
| } | ||
|
|
||
| const errorMessage = error.message; | ||
|
|
||
| // Map common Supabase error messages to user-friendly messages | ||
| if (errorMessage.includes("Invalid login credentials")) { | ||
| return "The email and password you entered don't match."; | ||
| } | ||
| if (errorMessage.includes("Email not confirmed")) { | ||
| return "Please verify your email address before signing in."; | ||
| } | ||
| if (errorMessage.includes("User already registered")) { | ||
| return "The email address is already used by another account."; | ||
| } | ||
| if ( | ||
| errorMessage.includes("Password should be at least") || | ||
| errorMessage.includes("Password must be at least") | ||
| ) { | ||
| return "The password must be at least 6 characters long."; | ||
| } | ||
| if (errorMessage.includes("Invalid email")) { | ||
| return "That email address isn't correct."; | ||
| } | ||
| if (errorMessage.includes("Email rate limit exceeded")) { | ||
| return "Too many requests. Please wait a moment and try again."; | ||
| } | ||
| if (errorMessage.includes("For security purposes")) { | ||
| return "For security purposes, please wait a moment before trying again."; | ||
| } | ||
| if (errorMessage.includes("Unable to validate email address")) { | ||
| return "Unable to validate email address. Please check and try again."; | ||
| } | ||
| if (errorMessage.includes("Signups not allowed")) { | ||
| return "Sign ups are not allowed at this time."; | ||
| } | ||
| if (errorMessage.includes("User not found")) { | ||
| return "That email address doesn't match an existing account."; | ||
| } | ||
| if (errorMessage.includes("New password should be different")) { | ||
| return "Your new password must be different from your current password."; | ||
| } | ||
|
|
||
| // Return the original message if no mapping found | ||
| return ( | ||
| errorMessage || "An error occurred during authentication. Please try again." | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
config.providersfor Supabase is currently an unvalidatedstring[], butZudokuSignInUi/ZudokuSignUpUiwill throw at runtime for any provider ID not in its supported list (e.g., Supabase docs commonly useazure,discord, etc.). To avoid breaking existing Supabase configurations, either validate/map the configured providers to what the UI supports (with a clear error message), or extend the shared auth UI to render unsupported provider IDs generically instead of throwing.