update keycloak signup / login
This commit is contained in:
@@ -13,10 +13,7 @@ import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { featureFlagsConfig } from '@kit/shared/config';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||
|
||||
const ModeToggle = dynamic(() =>
|
||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||
@@ -75,11 +72,13 @@ function AuthButtons() {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
{authConfig.providers.password && (
|
||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,62 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
|
||||
|
||||
const ERROR_PATH = '/auth/callback/error';
|
||||
|
||||
const redirectOnError = (searchParams?: string) => {
|
||||
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const error = searchParams.get('error');
|
||||
if (error) {
|
||||
const { searchParams } = getErrorURLParameters({ error });
|
||||
return redirectOnError(searchParams);
|
||||
}
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
if (!authCode) {
|
||||
return redirectOnError();
|
||||
}
|
||||
|
||||
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
if (inviteToken) {
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
email: searchParams.get('email') ?? '',
|
||||
});
|
||||
|
||||
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||
const oauthResult = await service.exchangeCodeForSession(authCode);
|
||||
if (!("isSuccess" in oauthResult)) {
|
||||
return redirectOnError(oauthResult.searchParams);
|
||||
}
|
||||
|
||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||
joinTeamPath: pathsConfig.app.joinTeam,
|
||||
redirectPath: pathsConfig.app.home,
|
||||
});
|
||||
const api = createAccountsApi(getSupabaseServerClient());
|
||||
|
||||
return redirect(nextPath);
|
||||
const account = await api.getPersonalAccountByUserId(
|
||||
oauthResult.user.id,
|
||||
);
|
||||
|
||||
if (!account.email || !account.name || !account.last_name) {
|
||||
return redirect(pathsConfig.auth.updateAccount);
|
||||
}
|
||||
|
||||
return redirect(redirectPath);
|
||||
}
|
||||
|
||||
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function PasswordOption({
|
||||
inviteToken,
|
||||
returnPath,
|
||||
}: {
|
||||
inviteToken?: string;
|
||||
returnPath?: string;
|
||||
}) {
|
||||
const signUpPath =
|
||||
pathsConfig.auth.signUp +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback,
|
||||
returnPath: returnPath ?? pathsConfig.app.home,
|
||||
joinTeam: pathsConfig.app.joinTeam,
|
||||
updateAccount: pathsConfig.auth.updateAccount,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth:signInHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth:signInSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignInMethodsContainer
|
||||
inviteToken={inviteToken}
|
||||
paths={paths}
|
||||
providers={authConfig.providers}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button asChild variant={'link'} size={'sm'}>
|
||||
<Link href={signUpPath} prefetch={true}>
|
||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import Loading from '@/app/home/loading';
|
||||
import { useEffect } from 'react';
|
||||
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function SignInPageClientRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function signIn() {
|
||||
const { data, error } = await getSupabaseBrowserClient()
|
||||
.auth
|
||||
.signInWithOAuth({
|
||||
provider: 'keycloak',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
queryParams: {
|
||||
prompt: 'login',
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('OAuth error', error);
|
||||
router.push('/');
|
||||
} else if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
}
|
||||
|
||||
signIn();
|
||||
}, [router]);
|
||||
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { pathsConfig, authConfig } from '@kit/shared/config';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
||||
import PasswordOption from './components/PasswordOption';
|
||||
|
||||
interface SignInPageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -26,47 +21,14 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
||||
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
||||
await searchParams;
|
||||
|
||||
const signUpPath =
|
||||
pathsConfig.auth.signUp +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
if (authConfig.providers.password) {
|
||||
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
|
||||
}
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback,
|
||||
returnPath: next ?? pathsConfig.app.home,
|
||||
joinTeam: pathsConfig.app.joinTeam,
|
||||
updateAccount: pathsConfig.auth.updateAccount,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth:signInHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth:signInSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignInMethodsContainer
|
||||
inviteToken={inviteToken}
|
||||
paths={paths}
|
||||
providers={authConfig.providers}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button asChild variant={'link'} size={'sm'}>
|
||||
<Link href={signUpPath} prefetch={true}>
|
||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return <SignInPageClientRedirect />;
|
||||
}
|
||||
|
||||
export default withI18n(SignInPage);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||
@@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) {
|
||||
pathsConfig.auth.signIn +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
|
||||
if (!authConfig.providers.password) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
|
||||
@@ -28,11 +28,15 @@ export const onUpdateAccount = enhanceAction(
|
||||
console.warn('On update account error: ', err);
|
||||
}
|
||||
|
||||
await updateCustomer({
|
||||
first_name: params.firstName,
|
||||
last_name: params.lastName,
|
||||
phone: params.phone,
|
||||
});
|
||||
try {
|
||||
await updateCustomer({
|
||||
first_name: params.firstName,
|
||||
last_name: params.lastName,
|
||||
phone: params.phone,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update Medusa customer", e);
|
||||
}
|
||||
|
||||
const hasUnseenMembershipConfirmation =
|
||||
await api.hasUnseenMembershipConfirmation();
|
||||
|
||||
@@ -16,14 +16,14 @@ export const loadUserAccount = cache(accountLoader);
|
||||
|
||||
export async function loadCurrentUserAccount() {
|
||||
const user = await requireUserInServerComponent();
|
||||
return user?.identities?.[0]?.id
|
||||
? await loadUserAccount(user?.identities?.[0]?.id)
|
||||
return user?.id
|
||||
? await loadUserAccount(user.id)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function accountLoader(accountId: string) {
|
||||
async function accountLoader(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
return api.getAccount(accountId);
|
||||
return api.getPersonalAccountByUserId(userId);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,26 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { medusaLogout } from '@lib/data/customer';
|
||||
|
||||
export const signOutAction = async () => {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
const client = await createClient();
|
||||
|
||||
try {
|
||||
try {
|
||||
await medusaLogout();
|
||||
} catch (medusaError) {
|
||||
console.warn('Medusa logout failed or not available:', medusaError);
|
||||
}
|
||||
|
||||
const { error } = await client.auth.signOut();
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return redirect('/');
|
||||
};
|
||||
|
||||
@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
|
||||
}) {
|
||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||
|
||||
const signedInAsLabel = useMemo(() => {
|
||||
const email = user?.email ?? undefined;
|
||||
const phone = user?.phone ?? undefined;
|
||||
|
||||
return email ?? phone;
|
||||
}, [user]);
|
||||
|
||||
const displayName =
|
||||
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
|
||||
const { name, last_name } = personalAccountData ?? {};
|
||||
const firstNameLabel = toTitleCase(name) ?? '-';
|
||||
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
|
||||
|
||||
const hasTotpFactor = useMemo(() => {
|
||||
const factors = user?.factors ?? [];
|
||||
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
|
||||
<ProfileAvatar
|
||||
className={'rounded-md'}
|
||||
fallbackClassName={'rounded-md border'}
|
||||
displayName={displayName ?? user?.email ?? ''}
|
||||
displayName={firstNameLabel}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
/>
|
||||
|
||||
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
|
||||
data-test={'account-dropdown-display-name'}
|
||||
className={'truncate text-sm'}
|
||||
>
|
||||
{toTitleCase(displayName)}
|
||||
{firstNameLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
||||
<span className={'block truncate'}>{fullNameLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -48,6 +48,41 @@ class AccountsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getPersonalAccountByUserId
|
||||
* @description Get the personal account data for the given user ID.
|
||||
* @param userId
|
||||
*/
|
||||
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
|
||||
const { data, error } = await this.client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select(
|
||||
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
|
||||
)
|
||||
.eq('primary_owner_user_id', userId)
|
||||
.eq('is_personal_account', true)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { personal_code, ...rest } = data;
|
||||
return {
|
||||
...rest,
|
||||
personal_code: (() => {
|
||||
if (!personal_code) {
|
||||
return null;
|
||||
}
|
||||
if (personal_code.toLowerCase().startsWith('ee')) {
|
||||
return personal_code.substring(2);
|
||||
}
|
||||
return personal_code;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getAccountWorkspace
|
||||
* @description Get the account workspace data.
|
||||
|
||||
@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
|
||||
* @see https://supabase.com/docs/guides/auth/social-login
|
||||
*/
|
||||
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
||||
azure: 'email',
|
||||
keycloak: 'openid',
|
||||
// azure: 'email',
|
||||
// keycloak: 'openid',
|
||||
// add your OAuth providers here
|
||||
};
|
||||
|
||||
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
|
||||
queryParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.paths.callback,
|
||||
queryParams.toString(),
|
||||
].join('?');
|
||||
// signicat/keycloak will not allow redirect-uri with changing query params
|
||||
const INCLUDE_QUERY_PARAMS = false as boolean;
|
||||
|
||||
const redirectPath = INCLUDE_QUERY_PARAMS
|
||||
? [props.paths.callback, queryParams.toString()].join('?')
|
||||
: props.paths.callback;
|
||||
|
||||
const redirectTo = [origin, redirectPath].join('');
|
||||
const scopes = OAUTH_SCOPES[provider] ?? undefined;
|
||||
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
|
||||
redirectTo,
|
||||
queryParams: props.queryParams,
|
||||
scopes,
|
||||
// skipBrowserRedirect: false,
|
||||
},
|
||||
} satisfies SignInWithOAuthCredentials;
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: {
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.returnPath,
|
||||
}}
|
||||
queryParams={{
|
||||
prompt: 'login',
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: {
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.appHome,
|
||||
}}
|
||||
queryParams={{
|
||||
prompt: 'login',
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
|
||||
import medusaError from "@lib/util/medusa-error"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { revalidateTag } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import {
|
||||
getAuthHeaders,
|
||||
getCacheOptions,
|
||||
@@ -127,7 +126,7 @@ export async function login(_currentState: unknown, formData: FormData) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function signout(countryCode?: string, shouldRedirect = true) {
|
||||
export async function medusaLogout(countryCode = 'ee') {
|
||||
await sdk.auth.logout()
|
||||
|
||||
await removeAuthToken()
|
||||
@@ -139,10 +138,6 @@ export async function signout(countryCode?: string, shouldRedirect = true) {
|
||||
|
||||
const cartCacheTag = await getCacheTag("carts")
|
||||
revalidateTag(cartCacheTag)
|
||||
|
||||
if (shouldRedirect) {
|
||||
redirect(`/${countryCode!}/account`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferCart() {
|
||||
@@ -262,72 +257,110 @@ export const updateCustomerAddress = async (
|
||||
})
|
||||
}
|
||||
|
||||
export async function medusaLoginOrRegister(credentials: {
|
||||
email: string
|
||||
password?: string
|
||||
}) {
|
||||
const { email, password } = credentials;
|
||||
async function medusaLogin(email: string, password: string) {
|
||||
const token = await sdk.auth.login("customer", "emailpass", { email, password });
|
||||
await setAuthToken(token as string);
|
||||
|
||||
try {
|
||||
const token = await sdk.auth.login("customer", "emailpass", {
|
||||
email,
|
||||
password,
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
}
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found for active session");
|
||||
}
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async function medusaRegister({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
lastName,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string | undefined;
|
||||
lastName: string | undefined;
|
||||
}) {
|
||||
console.info(`Creating new Medusa account for Keycloak user with email=${email}`);
|
||||
|
||||
const registerToken = await sdk.auth.register("customer", "emailpass", { email, password });
|
||||
await setAuthToken(registerToken);
|
||||
|
||||
console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`);
|
||||
await sdk.store.customer.create(
|
||||
{ email, first_name: name, last_name: lastName },
|
||||
{},
|
||||
{
|
||||
...(await getAuthHeaders()),
|
||||
});
|
||||
await setAuthToken(token as string);
|
||||
}
|
||||
|
||||
try {
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
export async function medusaLoginOrRegister(credentials: {
|
||||
email: string
|
||||
supabaseUserId?: string
|
||||
name?: string,
|
||||
lastName?: string,
|
||||
} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
|
||||
const { email, supabaseUserId, name, lastName } = credentials;
|
||||
|
||||
|
||||
const password = await (async () => {
|
||||
if (credentials.isDevPasswordLogin) {
|
||||
return credentials.password;
|
||||
}
|
||||
|
||||
const customerCacheTag = await getCacheTag("customers");
|
||||
revalidateTag(customerCacheTag);
|
||||
return generateDeterministicPassword(email, supabaseUserId);
|
||||
})();
|
||||
|
||||
try {
|
||||
return await medusaLogin(email, password);
|
||||
} catch (loginError) {
|
||||
console.error("Failed to login customer, attempting to register", loginError);
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found");
|
||||
}
|
||||
return customer.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to login customer, attempting to register", error);
|
||||
try {
|
||||
const registerToken = await sdk.auth.register("customer", "emailpass", {
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
await setAuthToken(registerToken as string);
|
||||
|
||||
const headers = {
|
||||
...(await getAuthHeaders()),
|
||||
};
|
||||
|
||||
await sdk.store.customer.create({ email }, {}, headers);
|
||||
|
||||
const loginToken = await sdk.auth.login("customer", "emailpass", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
await setAuthToken(loginToken as string);
|
||||
|
||||
const customerCacheTag = await getCacheTag("customers");
|
||||
revalidateTag(customerCacheTag);
|
||||
|
||||
try {
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
}
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found");
|
||||
}
|
||||
return customer.id;
|
||||
await medusaRegister({ email, password, name, lastName });
|
||||
return await medusaLogin(email, password);
|
||||
} catch (registerError) {
|
||||
console.error("Failed to create Medusa account for user with email=${email}", registerError);
|
||||
throw medusaError(registerError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic password based on user identifier
|
||||
* This ensures the same user always gets the same password for Medusa
|
||||
*/
|
||||
async function generateDeterministicPassword(email: string, userId?: string): Promise<string> {
|
||||
// Use the user ID or email as the base for deterministic generation
|
||||
const baseString = userId || email;
|
||||
const secret = process.env.MEDUSA_PASSWORD_SECRET!;
|
||||
|
||||
// Create a deterministic password using HMAC
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(baseString);
|
||||
|
||||
// Import key for HMAC
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
// Generate HMAC
|
||||
const signature = await crypto.subtle.sign('HMAC', key, messageData);
|
||||
// Convert to base64 and make it a valid password
|
||||
const hashArray = Array.from(new Uint8Array(signature));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// Take first 24 characters and add some complexity
|
||||
const basePassword = hashHex.substring(0, 24);
|
||||
// Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols)
|
||||
return `Mk${basePassword}9!`;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
|
||||
import Package from "@modules/common/icons/package"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { signout } from "@lib/data/customer"
|
||||
import { medusaLogout } from "@lib/data/customer"
|
||||
|
||||
const AccountNav = ({
|
||||
customer,
|
||||
@@ -21,7 +21,7 @@ const AccountNav = ({
|
||||
const { countryCode } = useParams() as { countryCode: string }
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signout(countryCode)
|
||||
await medusaLogout(countryCode)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
|
||||
providers: {
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
oAuth: ['google'],
|
||||
oAuth: ['keycloak'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AuthError,
|
||||
type EmailOtpType,
|
||||
SupabaseClient,
|
||||
User,
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
|
||||
* @description Service for handling auth callbacks in Supabase
|
||||
*/
|
||||
class AuthCallbackService {
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
constructor(private readonly client: SupabaseClient) { }
|
||||
|
||||
/**
|
||||
* @name verifyTokenHash
|
||||
@@ -128,89 +129,117 @@ class AuthCallbackService {
|
||||
/**
|
||||
* @name exchangeCodeForSession
|
||||
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
|
||||
* @param request
|
||||
* @param params
|
||||
* @param authCode
|
||||
*/
|
||||
async exchangeCodeForSession(
|
||||
request: Request,
|
||||
params: {
|
||||
joinTeamPath: string;
|
||||
redirectPath: string;
|
||||
errorPath?: string;
|
||||
},
|
||||
): Promise<{
|
||||
nextPath: string;
|
||||
}> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const searchParams = requestUrl.searchParams;
|
||||
async exchangeCodeForSession(authCode: string): Promise<{
|
||||
isSuccess: boolean;
|
||||
user: User;
|
||||
} | ErrorURLParameters> {
|
||||
let user: User;
|
||||
try {
|
||||
const { data, error } =
|
||||
await this.client.auth.exchangeCodeForSession(authCode);
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const nextUrlPathFromParams = searchParams.get('next');
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
|
||||
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
if (inviteToken) {
|
||||
const emailParam = searchParams.get('email');
|
||||
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
email: emailParam ?? '',
|
||||
});
|
||||
|
||||
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
if (authCode) {
|
||||
try {
|
||||
const { error } =
|
||||
await this.client.auth.exchangeCodeForSession(authCode);
|
||||
|
||||
// if we have an error, we redirect to the error page
|
||||
if (error) {
|
||||
return onError({
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
path: errorPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
{
|
||||
error,
|
||||
name: `auth.callback`,
|
||||
},
|
||||
`An error occurred while exchanging code for session`,
|
||||
);
|
||||
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return onError({
|
||||
code: (error as AuthError)?.code,
|
||||
error: message as string,
|
||||
path: errorPath,
|
||||
// if we have an error, we redirect to the error page
|
||||
if (error) {
|
||||
return getErrorURLParameters({
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onError({
|
||||
error,
|
||||
path: errorPath,
|
||||
// Handle Keycloak users - set up Medusa integration
|
||||
if (data?.user && this.isKeycloakUser(data.user)) {
|
||||
await this.setupMedusaUserForKeycloak(data.user);
|
||||
}
|
||||
|
||||
user = data.user;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
{
|
||||
error,
|
||||
name: `auth.callback`,
|
||||
},
|
||||
`An error occurred while exchanging code for session`,
|
||||
);
|
||||
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return getErrorURLParameters({
|
||||
code: (error as AuthError)?.code,
|
||||
error: message as string,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nextPath: nextUrl,
|
||||
isSuccess: true,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is from Keycloak provider
|
||||
*/
|
||||
private isKeycloakUser(user: any): boolean {
|
||||
return user?.app_metadata?.provider === 'keycloak' ||
|
||||
user?.app_metadata?.providers?.includes('keycloak');
|
||||
}
|
||||
|
||||
private async setupMedusaUserForKeycloak(user: any): Promise<void> {
|
||||
if (!user.email) {
|
||||
console.warn('Keycloak user has no email, skipping Medusa setup');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user already has medusa_account_id
|
||||
const { data: accountData, error: fetchError } = await this.client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select('medusa_account_id, name, last_name')
|
||||
.eq('primary_owner_user_id', user.id)
|
||||
.eq('is_personal_account', true)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
console.error('Error fetching account data for Keycloak user:', fetchError);
|
||||
return;
|
||||
}
|
||||
|
||||
// If user already has Medusa account, we're done
|
||||
if (accountData?.medusa_account_id) {
|
||||
console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer');
|
||||
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: user.email,
|
||||
supabaseUserId: user.id,
|
||||
name: accountData?.name ?? '-',
|
||||
lastName: accountData?.last_name ?? '-',
|
||||
});
|
||||
|
||||
// Update the account with the Medusa account ID
|
||||
const { error: updateError } = await this.client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.update({ medusa_account_id: medusaAccountId })
|
||||
.eq('primary_owner_user_id', user.id)
|
||||
.eq('is_personal_account', true);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating account with Medusa ID:', updateError);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId);
|
||||
} catch (error) {
|
||||
console.error('Error setting up Medusa account for Keycloak user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||
url.host = host as string;
|
||||
@@ -231,15 +260,19 @@ class AuthCallbackService {
|
||||
}
|
||||
}
|
||||
|
||||
function onError({
|
||||
interface ErrorURLParameters {
|
||||
error: string;
|
||||
code?: string;
|
||||
searchParams: string;
|
||||
}
|
||||
|
||||
export function getErrorURLParameters({
|
||||
error,
|
||||
path,
|
||||
code,
|
||||
}: {
|
||||
error: string;
|
||||
path: string;
|
||||
code?: string;
|
||||
}) {
|
||||
}): ErrorURLParameters {
|
||||
const errorMessage = getAuthErrorMessage({ error, code });
|
||||
|
||||
console.error(
|
||||
@@ -255,10 +288,10 @@ function onError({
|
||||
code: code ?? '',
|
||||
});
|
||||
|
||||
const nextPath = `${path}?${searchParams.toString()}`;
|
||||
|
||||
return {
|
||||
nextPath,
|
||||
error: errorMessage,
|
||||
code: code ?? '',
|
||||
searchParams: searchParams.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ export function createMiddlewareClient<GenericSchema = Database>(
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
|
||||
@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
async getAll() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
isDevPasswordLogin: true,
|
||||
});
|
||||
await client
|
||||
.schema('medreport').from('accounts')
|
||||
|
||||
@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
|
||||
const mutationKey = ['auth', 'sign-in-with-provider'];
|
||||
|
||||
const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
|
||||
const response = await client.auth.signInWithOAuth(credentials);
|
||||
const response = await client.auth.signInWithOAuth({
|
||||
...credentials,
|
||||
options: {
|
||||
...credentials.options,
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error.message;
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
import { signout } from '../../../features/medusa-storefront/src/lib/data/customer';
|
||||
|
||||
export function useSignOut() {
|
||||
const client = useSupabase();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await signout(undefined, false);
|
||||
return client.auth.signOut();
|
||||
try {
|
||||
try {
|
||||
const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
|
||||
await medusaLogout();
|
||||
} catch (medusaError) {
|
||||
console.warn('Medusa logout failed or not available:', medusaError);
|
||||
}
|
||||
|
||||
const { error } = await client.auth.signOut();
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
isDevPasswordLogin: true,
|
||||
});
|
||||
await client
|
||||
.schema('medreport').from('accounts')
|
||||
|
||||
Reference in New Issue
Block a user