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

@@ -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 (
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
<div className={'hidden md:flex'}>
@@ -65,14 +68,17 @@ function AuthButtons() {
</If>
</div>
{config && (
<div className={'flex gap-x-2.5'}>
{(config.providers.password || config.providers.oAuth.length > 0) && (
<Button className={'block'} asChild variant={'ghost'}>
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
)}
{authConfig.providers.password && (
{config.providers.password && (
<Button asChild className="text-xs md:text-sm" variant={'default'}>
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} />
@@ -80,6 +86,7 @@ function AuthButtons() {
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -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({
<SignInMethodsContainer
inviteToken={inviteToken}
paths={paths}
providers={authConfig.providers}
providers={providers}
/>
<div className={'flex justify-center'}>

View File

@@ -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 <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
return (
<PasswordOption
inviteToken={inviteToken}
returnPath={returnPath}
providers={authConfig.providers}
/>
);
}
if (authConfig.providers.oAuth.includes('keycloak')) {
return <SignInPageClientRedirect />;
}
return null;
}
export default withI18n(SignInPage);

View File

@@ -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) {
<SignUpMethodsContainer
providers={authConfig.providers}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
inviteToken={inviteToken}
paths={paths}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
/>
<div className={'flex justify-center'}>

View File

@@ -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 (
<>
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
{cards({
gender: gender.label,
gender: gender?.label,
age,
height,
weight,

View File

@@ -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();

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