prefer using providers conf from supabase instead of env

This commit is contained in:
2025-09-10 06:31:23 +03:00
parent 9f9508233d
commit 95452de88b
13 changed files with 382 additions and 33 deletions

View File

@@ -15,6 +15,12 @@ import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { PasswordSignInContainer } from './password-sign-in-container';
export type Providers = {
password: boolean;
magicLink: boolean;
oAuth: Provider[];
};
export function SignInMethodsContainer(props: {
inviteToken?: string;
@@ -25,11 +31,7 @@ export function SignInMethodsContainer(props: {
updateAccount: string;
};
providers: {
password: boolean;
magicLink: boolean;
oAuth: Provider[];
};
providers: Providers;
}) {
const client = useSupabase();
const router = useRouter();

View File

@@ -1,7 +1,5 @@
'use client';
import { redirect } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
import { isBrowser } from '@kit/shared/utils';

View File

@@ -0,0 +1,137 @@
import type { Provider } from '@supabase/supabase-js';
import authConfig from './auth.config';
type SupabaseExternalProvider = Provider | 'email';
interface SupabaseAuthSettings {
external: Record<SupabaseExternalProvider, boolean>;
disable_signup: boolean;
}
export class AuthProvidersService {
private supabaseUrl: string;
private cache: Map<string, { data: SupabaseAuthSettings; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
constructor(supabaseUrl: string) {
this.supabaseUrl = supabaseUrl;
}
async fetchAuthSettings(): Promise<SupabaseAuthSettings | null> {
try {
const cacheKey = 'auth-settings';
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.data;
}
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!anonKey) {
throw new Error('NEXT_PUBLIC_SUPABASE_ANON_KEY is required');
}
const response = await fetch(`${this.supabaseUrl}/auth/v1/settings?apikey=${anonKey}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.warn('Failed to fetch auth settings from Supabase:', response.status);
return null;
}
const settings: SupabaseAuthSettings = await response.json();
this.cache.set(cacheKey, { data: settings, timestamp: Date.now() });
return settings;
} catch (error) {
console.warn('Error fetching auth settings from Supabase:', error);
return null;
}
}
isPasswordEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean {
if (settings) {
return settings.external.email === true && !settings.disable_signup;
}
return process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true';
}
isMagicLinkEnabled(): boolean {
return process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true';
}
isOAuthProviderEnabled({
provider,
settings,
}: {
provider: SupabaseExternalProvider;
settings: SupabaseAuthSettings | null;
}): boolean {
if (settings && settings.external) {
return settings.external[provider] === true;
}
return false;
}
getEnabledOAuthProviders({ settings }: { settings: SupabaseAuthSettings | null }): SupabaseExternalProvider[] {
const enabledProviders: SupabaseExternalProvider[] = [];
if (settings && settings.external) {
for (const [providerName, isEnabled] of Object.entries(settings.external)) {
if (isEnabled && providerName !== 'email') {
enabledProviders.push(providerName as SupabaseExternalProvider);
}
}
return enabledProviders;
}
const potentialProviders: SupabaseExternalProvider[] = ['keycloak'];
const enabledFallback: SupabaseExternalProvider[] = [];
for (const provider of potentialProviders) {
if (provider !== 'email' && this.isOAuthProviderEnabled({ provider, settings })) {
enabledFallback.push(provider);
}
}
return enabledFallback;
}
async getAuthConfig() {
const settings = await this.fetchAuthSettings();
const [passwordEnabled, magicLinkEnabled, oAuthProviders] = await Promise.all([
this.isPasswordEnabled({ settings }),
this.isMagicLinkEnabled(),
this.getEnabledOAuthProviders({ settings }),
]);
return {
providers: {
password: passwordEnabled,
magicLink: magicLinkEnabled,
oAuth: oAuthProviders,
},
displayTermsCheckbox: authConfig.displayTermsCheckbox,
};
}
clearCache(): void {
this.cache.clear();
}
}
export function createAuthProvidersService(): AuthProvidersService {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
if (!supabaseUrl) {
throw new Error('NEXT_PUBLIC_SUPABASE_URL is required');
}
return new AuthProvidersService(supabaseUrl);
}

View File

@@ -0,0 +1,102 @@
import type { Provider } from '@supabase/supabase-js';
import { z } from 'zod';
import { createAuthProvidersService } from './auth-providers.service';
const providers: z.ZodType<Provider> = getProviders();
const DynamicAuthConfigSchema = z.object({
providers: z.object({
password: z.boolean().describe('Enable password authentication.'),
magicLink: z.boolean().describe('Enable magic link authentication.'),
oAuth: providers.array(),
}),
displayTermsCheckbox: z.boolean().describe('Whether to display the terms checkbox during sign-up.'),
});
export async function getDynamicAuthConfig() {
const authService = createAuthProvidersService();
const dynamicProviders = await authService.getAuthConfig();
const config = {
providers: dynamicProviders.providers,
displayTermsCheckbox: dynamicProviders.displayTermsCheckbox,
};
return DynamicAuthConfigSchema.parse(config);
}
export async function getCachedAuthConfig() {
if (typeof window !== 'undefined') {
const cached = sessionStorage.getItem('auth-config');
if (cached) {
try {
const { data, timestamp } = JSON.parse(cached);
// Cache for 5 minutes
if (Date.now() - timestamp < 5 * 60 * 1000) {
return data;
}
} catch (error) {
console.warn('Invalid auth config cache:', error);
}
}
}
const config = await getDynamicAuthConfig();
if (typeof window !== 'undefined') {
try {
sessionStorage.setItem('auth-config', JSON.stringify({
data: config,
timestamp: Date.now(),
}));
} catch (error) {
console.warn('Failed to cache auth config:', error);
}
}
return config;
}
export async function getServerAuthConfig() {
return getDynamicAuthConfig();
}
export async function isProviderEnabled(provider: 'password' | 'magicLink' | Provider): Promise<boolean> {
const authService = createAuthProvidersService();
const settings = await authService.fetchAuthSettings();
switch (provider) {
case 'password':
return authService.isPasswordEnabled({ settings });
case 'magicLink':
return authService.isMagicLinkEnabled();
default:
return authService.isOAuthProviderEnabled({ provider, settings });
}
}
function getProviders() {
return z.enum([
'apple',
'azure',
'bitbucket',
'discord',
'facebook',
'figma',
'github',
'gitlab',
'google',
'kakao',
'keycloak',
'linkedin',
'linkedin_oidc',
'notion',
'slack',
'spotify',
'twitch',
'twitter',
'workos',
'zoom',
'fly',
]);
}

View File

@@ -8,6 +8,7 @@ import {
createPath,
getTeamAccountSidebarConfig,
} from './team-account-navigation.config';
import { getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config';
export {
appConfig,
@@ -18,4 +19,6 @@ export {
getTeamAccountSidebarConfig,
pathsConfig,
personalAccountNavigationConfig,
getCachedAuthConfig,
getServerAuthConfig,
};

View File

@@ -1,2 +1,3 @@
export * from './use-csrf-token';
export * from './use-current-locale-language-names';
export * from './use-auth-config';

View File

@@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import type { Provider } from '@supabase/supabase-js';
import { getCachedAuthConfig } from '../config/dynamic-auth.config';
import { authConfig } from '../config';
interface AuthConfig {
providers: {
password: boolean;
magicLink: boolean;
oAuth: Provider[];
};
}
interface UseAuthConfigResult {
config: AuthConfig | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
export function useAuthConfig(): UseAuthConfigResult {
const [config, setConfig] = useState<AuthConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchConfig = async () => {
try {
setLoading(true);
setError(null);
const authConfig = await getCachedAuthConfig();
setConfig(authConfig);
} catch (err) {
console.error('Failed to fetch auth config', err);
setError(err instanceof Error ? err : new Error('Failed to fetch auth config'));
setConfig(authConfig);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchConfig();
}, []);
return {
config,
loading,
error,
refetch: fetchConfig,
};
}
export function useProviderEnabled(provider: 'password' | 'magicLink' | Provider) {
const { config, loading, error } = useAuthConfig();
const isEnabled = (() => {
if (!config) return false;
switch (provider) {
case 'password':
return config.providers.password;
case 'magicLink':
return config.providers.magicLink;
default:
return config.providers.oAuth.includes(provider);
}
})();
return {
enabled: isEnabled,
loading,
error,
};
}