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,
+ };
+}