Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions packages/zudoku/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,6 @@
"@azure/msal-browser": "^4.13.0",
"@clerk/clerk-js": "^5.63.1",
"@sentry/react": "^10.0.0",
"@supabase/auth-ui-react": "^0.4.0",
"@supabase/auth-ui-shared": "^0.1.0",
"@supabase/supabase-js": "^2.49.4",
"firebase": "^12.6.0",
"mermaid": "^11.0.0",
Expand All @@ -379,12 +377,6 @@
"@supabase/supabase-js": {
"optional": true
},
"@supabase/auth-ui-react": {
"optional": true
},
"@supabase/auth-ui-shared": {
"optional": true
},
"firebase": {
"optional": true
},
Expand Down
266 changes: 257 additions & 9 deletions packages/zudoku/src/lib/authentication/providers/supabase.tsx
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,
Expand All @@ -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;
Expand All @@ -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;
Comment on lines +49 to +54
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.providers for Supabase is currently an unvalidated string[], but ZudokuSignInUi/ZudokuSignUpUi will throw at runtime for any provider ID not in its supported list (e.g., Supabase docs commonly use azure, 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.

Copilot uses AI. Check for mistakes.

this.client.auth.onAuthStateChange(async (event, session) => {
if (session && (event === "SIGNED_IN" || event === "TOKEN_REFRESHED")) {
await this.updateUserState(session);
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onUsernamePasswordSignUp calls setLoggedIn whenever data.user exists, but Supabase often returns data.user with data.session === null when email confirmation is required. That would mark the user as authenticated in Zudoku while they have no session/access token, causing protected-route flows and signRequest to fail. Only set logged-in state when a valid session exists (or after confirmation), and otherwise route the user into the email verification flow.

Copilot uses AI. Check for mistakes.
};

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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building redirect URLs via string concatenation with this.config.basePath (e.g. ${window.location.origin}${this.config.basePath ?? ""}/verify-email) is brittle if basePath is configured without a leading slash or with a trailing slash. Prefer using the existing joinUrl helper (used by other auth providers) to compose these URLs safely and consistently.

Copilot uses AI. Check for mistakes.
if (error) {
throw Error(getSupabaseErrorMessage(error), { cause: error });
}
Comment on lines 218 to 228
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resetPasswordForEmail is configured to redirect users back to /reset-password after they click the email link, but the /reset-password route renders ZudokuPasswordResetUi, which only requests a reset email and doesn't handle the recovery callback / setting a new password (e.g. via auth.updateUser({ password })). This makes the Supabase password reset flow incomplete; add a dedicated recovery/new-password UI for the redirect or enhance this route to handle the recovery session/code.

Copilot uses AI. Check for mistakes.
};

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}
/>
),
},
Expand Down Expand Up @@ -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."
);
};
Loading