11import {
22 createClient ,
3+ type Provider ,
34 type Session ,
45 type SupabaseClient ,
56} from "@supabase/supabase-js" ;
67import type { SupabaseAuthenticationConfig } from "../../../config/config.js" ;
8+ import { ZudokuError } from "../../util/invariant.js" ;
79import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js" ;
810import type {
911 AuthActionContext ,
@@ -14,14 +16,21 @@ import type {
1416import { SignOut } from "../components/SignOut.js" ;
1517import { AuthorizationError } from "../errors.js" ;
1618import { 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
1926class 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
153327export 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+ } ;
0 commit comments