From 95452de88b6656df589e70866ab7076e580fbf5c Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:31:23 +0300 Subject: [PATCH] prefer using providers conf from supabase instead of env --- .../site-header-account-section.tsx | 37 +++-- .../sign-in/components/PasswordOption.tsx | 8 +- app/auth/sign-in/page.tsx | 18 ++- app/auth/sign-up/page.tsx | 6 +- app/home/(user)/_components/dashboard.tsx | 11 +- lib/utils.ts | 2 +- .../components/sign-in-methods-container.tsx | 12 +- .../components/sign-up-methods-container.tsx | 2 - .../src/config/auth-providers.service.ts | 137 ++++++++++++++++++ .../shared/src/config/dynamic-auth.config.ts | 102 +++++++++++++ packages/shared/src/config/index.ts | 3 + packages/shared/src/hooks/index.ts | 1 + packages/shared/src/hooks/use-auth-config.ts | 76 ++++++++++ 13 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 packages/shared/src/config/auth-providers.service.ts create mode 100644 packages/shared/src/config/dynamic-auth.config.ts create mode 100644 packages/shared/src/hooks/use-auth-config.ts diff --git a/app/(marketing)/_components/site-header-account-section.tsx b/app/(marketing)/_components/site-header-account-section.tsx index ff7ad03..dd62722 100644 --- a/app/(marketing)/_components/site-header-account-section.tsx +++ b/app/(marketing)/_components/site-header-account-section.tsx @@ -13,7 +13,8 @@ import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; -import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config'; +import { featureFlagsConfig, pathsConfig } from '@kit/shared/config'; +import { useAuthConfig } from '@kit/shared/hooks'; const ModeToggle = dynamic(() => import('@kit/ui/mode-toggle').then((mod) => ({ @@ -57,6 +58,8 @@ export function SiteHeaderAccountSection({ } function AuthButtons() { + const { config } = useAuthConfig(); + return (
@@ -65,21 +68,25 @@ function AuthButtons() {
-
- + {config && ( +
+ {(config.providers.password || config.providers.oAuth.length > 0) && ( + + )} - {authConfig.providers.password && ( - - )} -
+ {config.providers.password && ( + + )} +
+ )}
); } diff --git a/app/auth/sign-in/components/PasswordOption.tsx b/app/auth/sign-in/components/PasswordOption.tsx index 5ef56a4..4f0b59d 100644 --- a/app/auth/sign-in/components/PasswordOption.tsx +++ b/app/auth/sign-in/components/PasswordOption.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { SignInMethodsContainer } from '@kit/auth/sign-in'; -import { authConfig, pathsConfig } from '@kit/shared/config'; +import { Providers, SignInMethodsContainer } from '@kit/auth/sign-in'; +import { pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; @@ -9,9 +9,11 @@ import { Trans } from '@kit/ui/trans'; export default function PasswordOption({ inviteToken, returnPath, + providers, }: { inviteToken?: string; returnPath?: string; + providers: Providers; }) { const signUpPath = pathsConfig.auth.signUp + @@ -39,7 +41,7 @@ export default function PasswordOption({
diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index a82d4a7..4799f7d 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -1,4 +1,4 @@ -import { pathsConfig, authConfig } from '@kit/shared/config'; +import { getServerAuthConfig, pathsConfig } from '@kit/shared/config'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; @@ -24,11 +24,23 @@ async function SignInPage({ searchParams }: SignInPageProps) { const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } = await searchParams; + const authConfig = await getServerAuthConfig(); + if (authConfig.providers.password) { - return ; + return ( + + ); } - return ; + if (authConfig.providers.oAuth.includes('keycloak')) { + return ; + } + + return null; } export default withI18n(SignInPage); diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 0394078..52d77e9 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { SignUpMethodsContainer } from '@kit/auth/sign-up'; -import { authConfig, pathsConfig } from '@kit/shared/config'; +import { getServerAuthConfig, pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; @@ -38,6 +38,8 @@ async function SignUpPage({ searchParams }: Props) { pathsConfig.auth.signIn + (inviteToken ? `?invite_token=${inviteToken}` : ''); + const authConfig = await getServerAuthConfig(); + if (!authConfig.providers.password) { return redirect('/'); } @@ -56,9 +58,9 @@ async function SignUpPage({ searchParams }: Props) {
diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index d2f8007..f8b2e79 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -146,14 +146,21 @@ export default function Dashboard({ }) { const height = account.accountParams?.height || 0; const weight = account.accountParams?.weight || 0; - const { age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!); + + let age: number = 0; + let gender: { label: string; value: string } | null = null; + try { + ({ age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!)); + } catch (e) { + console.error("Failed to parse personal code", e); + } const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight }); return ( <>
{cards({ - gender: gender.label, + gender: gender?.label, age, height, weight, diff --git a/lib/utils.ts b/lib/utils.ts index d8fa393..233042a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -126,7 +126,7 @@ export default class PersonalCode { if (age >= 60) { return '60'; } - throw new Error('Age range not supported'); + throw new Error('Age range not supported, age=' + age); })(); const gender = (() => { const gender = parsed.getGender(); diff --git a/packages/features/auth/src/components/sign-in-methods-container.tsx b/packages/features/auth/src/components/sign-in-methods-container.tsx index 344c040..6b1e8c7 100644 --- a/packages/features/auth/src/components/sign-in-methods-container.tsx +++ b/packages/features/auth/src/components/sign-in-methods-container.tsx @@ -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(); diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index aadbfb5..8759e0c 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -1,7 +1,5 @@ 'use client'; -import { redirect } from 'next/navigation'; - import type { Provider } from '@supabase/supabase-js'; import { isBrowser } from '@kit/shared/utils'; diff --git a/packages/shared/src/config/auth-providers.service.ts b/packages/shared/src/config/auth-providers.service.ts new file mode 100644 index 0000000..5cdb599 --- /dev/null +++ b/packages/shared/src/config/auth-providers.service.ts @@ -0,0 +1,137 @@ +import type { Provider } from '@supabase/supabase-js'; +import authConfig from './auth.config'; + +type SupabaseExternalProvider = Provider | 'email'; +interface SupabaseAuthSettings { + external: Record; + disable_signup: boolean; +} + +export class AuthProvidersService { + private supabaseUrl: string; + private cache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + constructor(supabaseUrl: string) { + this.supabaseUrl = supabaseUrl; + } + + async fetchAuthSettings(): Promise { + 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); +} diff --git a/packages/shared/src/config/dynamic-auth.config.ts b/packages/shared/src/config/dynamic-auth.config.ts new file mode 100644 index 0000000..428be33 --- /dev/null +++ b/packages/shared/src/config/dynamic-auth.config.ts @@ -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 = 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 { + 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', + ]); +} diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 516ecc7..5669259 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -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, }; diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 95e4bfd..b551263 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-csrf-token'; export * from './use-current-locale-language-names'; +export * from './use-auth-config'; diff --git a/packages/shared/src/hooks/use-auth-config.ts b/packages/shared/src/hooks/use-auth-config.ts new file mode 100644 index 0000000..5282554 --- /dev/null +++ b/packages/shared/src/hooks/use-auth-config.ts @@ -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; +} + +export function useAuthConfig(): UseAuthConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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, + }; +}