diff --git a/.env b/.env
index cfe6997..e58a457 100644
--- a/.env
+++ b/.env
@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH
-NEXT_PUBLIC_AUTH_PASSWORD=true
+NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
+
+# Configure Medusa password secret for Keycloak users
+MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
diff --git a/.env.development b/.env.development
index 962cb9d..c92a206 100644
--- a/.env.development
+++ b/.env.development
@@ -3,6 +3,7 @@
# SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000
+NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT
diff --git a/app/(marketing)/_components/site-header-account-section.tsx b/app/(marketing)/_components/site-header-account-section.tsx
index c4c388f..ff7ad03 100644
--- a/app/(marketing)/_components/site-header-account-section.tsx
+++ b/app/(marketing)/_components/site-header-account-section.tsx
@@ -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() {
-
+ {authConfig.providers.password && (
+
+ )}
);
diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
index 4f39285..1034428 100644
--- a/app/(marketing)/page.tsx
+++ b/app/(marketing)/page.tsx
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
+import { pathsConfig } from '@kit/shared/config';
import { ArrowRightIcon } from 'lucide-react';
import { CtaButton, Hero } from '@kit/ui/marketing';
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
return (
-
+
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
index 0786a08..b5ce0c0 100644
--- a/app/auth/callback/route.ts
+++ b/app/auth/callback/route.ts
@@ -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);
}
diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts
index db0ef3f..5586c79 100644
--- a/app/auth/confirm/route.ts
+++ b/app/auth/confirm/route.ts
@@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
-
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());
diff --git a/app/auth/sign-in/components/PasswordOption.tsx b/app/auth/sign-in/components/PasswordOption.tsx
new file mode 100644
index 0000000..5ef56a4
--- /dev/null
+++ b/app/auth/sign-in/components/PasswordOption.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/auth/sign-in/components/SignInPageClientRedirect.tsx b/app/auth/sign-in/components/SignInPageClientRedirect.tsx
new file mode 100644
index 0000000..2e79df4
--- /dev/null
+++ b/app/auth/sign-in/components/SignInPageClientRedirect.tsx
@@ -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 ;
+}
diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx
index 3728b38..a82d4a7 100644
--- a/app/auth/sign-in/page.tsx
+++ b/app/auth/sign-in/page.tsx
@@ -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 ;
+ }
- const paths = {
- callback: pathsConfig.auth.callback,
- returnPath: next ?? pathsConfig.app.home,
- joinTeam: pathsConfig.app.joinTeam,
- updateAccount: pathsConfig.auth.updateAccount,
- };
-
- return (
- <>
-
-
-
-
-
-
-
- >
- );
+ return ;
}
export default withI18n(SignInPage);
diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx
index 5c0a4e2..0394078 100644
--- a/app/auth/sign-up/page.tsx
+++ b/app/auth/sign-up/page.tsx
@@ -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 (
<>
diff --git a/app/auth/update-account/_components/update-account-form.tsx b/app/auth/update-account/_components/update-account-form.tsx
index 58887f0..bdda351 100644
--- a/app/auth/update-account/_components/update-account-form.tsx
+++ b/app/auth/update-account/_components/update-account-form.tsx
@@ -2,8 +2,6 @@
import Link from 'next/link';
-import { User } from '@supabase/supabase-js';
-
import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -23,31 +21,52 @@ import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account';
+import { z } from 'zod';
-export function UpdateAccountForm({ user }: { user: User }) {
+type UpdateAccountFormValues = z.infer
;
+
+export function UpdateAccountForm({
+ defaultValues,
+}: {
+ defaultValues: UpdateAccountFormValues,
+}) {
const form = useForm({
resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange',
- defaultValues: {
- firstName: '',
- lastName: '',
- personalCode: '',
- email: user.email,
- phone: '',
- city: '',
- weight: 0,
- height: 0,
- userConsent: false,
- },
+ defaultValues,
});
+
+ const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues;
+
+ const hasFirstName = !!firstName;
+ const hasLastName = !!lastName;
+ const hasPersonalCode = !!personalCode;
+ const hasEmail = !!email;
+ const hasWeight = !!weight;
+ const hasHeight = !!height;
+ const hasUserConsent = !!userConsent;
+
+ const onUpdateAccountOptions = async (values: UpdateAccountFormValues) =>
+ onUpdateAccount({
+ ...values,
+ ...(hasFirstName && { firstName }),
+ ...(hasLastName && { lastName }),
+ ...(hasPersonalCode && { personalCode }),
+ ...(hasEmail && { email }),
+ ...(hasWeight && { weight: values.weight ?? weight }),
+ ...(hasHeight && { height: values.height ?? height }),
+ ...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }),
+ });
+
return (
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
- {signedInAsLabel}
+ {fullNameLabel}
diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts
index 4c9e467..d1faaef 100644
--- a/packages/features/accounts/src/server/api.ts
+++ b/packages/features/accounts/src/server/api.ts
@@ -3,6 +3,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
+import PersonalCode from '~/lib/utils';
export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & {
@@ -48,6 +49,33 @@ class AccountsApi {
return data;
}
+ /**
+ * @name getPersonalAccountByUserId
+ * @description Get the personal account data for the given user ID.
+ * @param userId
+ */
+ async getPersonalAccountByUserId(userId: string): Promise {
+ 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: PersonalCode.getPersonalCode(personal_code),
+ };
+ }
+
/**
* @name getAccountWorkspace
* @description Get the account workspace data.
diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx
index 454b552..11dcc94 100644
--- a/packages/features/auth/src/components/oauth-providers.tsx
+++ b/packages/features/auth/src/components/oauth-providers.tsx
@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
* @see https://supabase.com/docs/guides/auth/social-login
*/
const OAUTH_SCOPES: Partial> = {
- 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;
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 83bda02..344c040 100644
--- a/packages/features/auth/src/components/sign-in-methods-container.tsx
+++ b/packages/features/auth/src/components/sign-in-methods-container.tsx
@@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: {
callback: props.paths.callback,
returnPath: props.paths.returnPath,
}}
+ queryParams={{
+ prompt: 'login',
+ }}
/>
>
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 c10054d..aadbfb5 100644
--- a/packages/features/auth/src/components/sign-up-methods-container.tsx
+++ b/packages/features/auth/src/components/sign-up-methods-container.tsx
@@ -44,7 +44,7 @@ export function SignUpMethodsContainer(props: {
emailRedirectTo={props.paths.callback}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
- onSignUp={() => redirect(redirectUrl)}
+ //onSignUp={() => redirect(redirectUrl)}
/>
@@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: {
callback: props.paths.callback,
returnPath: props.paths.appHome,
}}
+ queryParams={{
+ prompt: 'login',
+ }}
/>
>
diff --git a/packages/features/auth/src/server/api.ts b/packages/features/auth/src/server/api.ts
index 6f8ead2..223e82c 100644
--- a/packages/features/auth/src/server/api.ts
+++ b/packages/features/auth/src/server/api.ts
@@ -68,6 +68,7 @@ class AuthApi {
p_name: data.firstName,
p_last_name: data.lastName,
p_personal_code: data.personalCode,
+ p_email: data.email || '',
p_phone: data.phone || '',
p_city: data.city || '',
p_has_consent_personal_data: data.userConsent,
diff --git a/packages/features/medusa-storefront/src/lib/data/customer.ts b/packages/features/medusa-storefront/src/lib/data/customer.ts
index bf56d6e..a33a33a 100644
--- a/packages/features/medusa-storefront/src/lib/data/customer.ts
+++ b/packages/features/medusa-storefront/src/lib/data/customer.ts
@@ -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 {
+ // 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!`;
+}
diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts
index a8ea25d..4b1e250 100644
--- a/packages/features/medusa-storefront/src/lib/data/products.ts
+++ b/packages/features/medusa-storefront/src/lib/data/products.ts
@@ -14,7 +14,12 @@ export const listProducts = async ({
regionId,
}: {
pageParam?: number
- queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string }
+ queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & {
+ "type_id[0]"?: string;
+ id?: string[],
+ category_id?: string;
+ order?: 'title';
+ }
countryCode?: string
regionId?: string
}): Promise<{
diff --git a/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx b/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx
index 61dd0c2..338dd22 100644
--- a/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx
+++ b/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx
@@ -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 (
diff --git a/packages/shared/src/config/auth.config.ts b/packages/shared/src/config/auth.config.ts
index 9e73291..ab460ee 100644
--- a/packages/shared/src/config/auth.config.ts
+++ b/packages/shared/src/config/auth.config.ts
@@ -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);
diff --git a/packages/supabase/src/auth-callback.service.ts b/packages/supabase/src/auth-callback.service.ts
index 1190bc2..c1d8126 100644
--- a/packages/supabase/src/auth-callback.service.ts
+++ b/packages/supabase/src/auth-callback.service.ts
@@ -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 {
+ 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(),
};
}
diff --git a/packages/supabase/src/clients/browser-client.ts b/packages/supabase/src/clients/browser-client.ts
index 747945e..69bb463 100644
--- a/packages/supabase/src/clients/browser-client.ts
+++ b/packages/supabase/src/clients/browser-client.ts
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
export function getSupabaseBrowserClient() {
const keys = getSupabaseClientKeys();
- return createBrowserClient(keys.url, keys.anonKey);
+ return createBrowserClient(keys.url, keys.anonKey, {
+ auth: {
+ flowType: 'pkce',
+ autoRefreshToken: true,
+ persistSession: true,
+ },
+ });
}
diff --git a/packages/supabase/src/clients/middleware-client.ts b/packages/supabase/src/clients/middleware-client.ts
index 608dc3b..e9c0a31 100644
--- a/packages/supabase/src/clients/middleware-client.ts
+++ b/packages/supabase/src/clients/middleware-client.ts
@@ -20,6 +20,11 @@ export function createMiddlewareClient(
const keys = getSupabaseClientKeys();
return createServerClient(keys.url, keys.anonKey, {
+ auth: {
+ flowType: 'pkce',
+ autoRefreshToken: true,
+ persistSession: true,
+ },
cookies: {
getAll() {
return request.cookies.getAll();
diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts
index cd4c82c..c9b8d7e 100644
--- a/packages/supabase/src/clients/server-client.ts
+++ b/packages/supabase/src/clients/server-client.ts
@@ -15,6 +15,11 @@ export function getSupabaseServerClient() {
const keys = getSupabaseClientKeys();
return createServerClient(keys.url, keys.anonKey, {
+ auth: {
+ flowType: 'pkce',
+ autoRefreshToken: true,
+ persistSession: true,
+ },
cookies: {
async getAll() {
const cookieStore = await cookies();
diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts
index a4f8cc1..a09d6b8 100644
--- a/packages/supabase/src/database.types.ts
+++ b/packages/supabase/src/database.types.ts
@@ -1257,6 +1257,26 @@ export type Database = {
},
]
}
+ medipost_actions: {
+ Row: {
+ created_at: string
+ id: number
+ action: string
+ xml: string
+ has_analysis_results: boolean
+ medusa_order_id: string
+ response_xml: string
+ has_error: boolean
+ }
+ Insert: {
+ action: string
+ xml: string
+ has_analysis_results: boolean
+ medusa_order_id: string
+ response_xml: string
+ has_error: boolean
+ }
+ }
medreport_product_groups: {
Row: {
created_at: string
@@ -2053,6 +2073,7 @@ export type Database = {
p_personal_code: string
p_phone: string
p_uid: string
+ p_email: string
}
Returns: undefined
}
diff --git a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts
index 6ed91c9..549bc1b 100644
--- a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts
+++ b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts
@@ -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')
diff --git a/packages/supabase/src/hooks/use-sign-in-with-provider.ts b/packages/supabase/src/hooks/use-sign-in-with-provider.ts
index d68700b..7361549 100644
--- a/packages/supabase/src/hooks/use-sign-in-with-provider.ts
+++ b/packages/supabase/src/hooks/use-sign-in-with-provider.ts
@@ -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;
diff --git a/packages/supabase/src/hooks/use-sign-out.ts b/packages/supabase/src/hooks/use-sign-out.ts
index fbe65ee..c354cee 100644
--- a/packages/supabase/src/hooks/use-sign-out.ts
+++ b/packages/supabase/src/hooks/use-sign-out.ts
@@ -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;
+ }
},
});
}
diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts
index f6dc21f..59a864c 100644
--- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts
+++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts
@@ -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')
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c7b691e..b4634f0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,6 +128,9 @@ importers:
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
+ libphonenumber-js:
+ specifier: ^1.12.15
+ version: 1.12.15
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -475,10 +478,10 @@ importers:
dependencies:
'@keystatic/core':
specifier: 0.5.47
- version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystatic/next':
specifier: ^5.0.4
- version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc':
specifier: ^0.5.1
version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
@@ -1269,7 +1272,7 @@ importers:
dependencies:
'@sentry/nextjs':
specifier: ^9.19.0
- version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
+ version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
import-in-the-middle:
specifier: 1.13.2
version: 1.13.2
@@ -8174,6 +8177,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
+ libphonenumber-js@1.12.15:
+ resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
+
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -11455,7 +11461,7 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
- '@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@emotion/css': 11.13.5
@@ -11548,18 +11554,18 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
- next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- supports-color
- '@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@braintree/sanitize-url': 6.0.4
'@emotion/weak-memoize': 0.3.1
'@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@internationalized/string': 3.2.7
- '@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0)
'@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -11630,13 +11636,13 @@ snapshots:
- next
- supports-color
- '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
- '@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@types/react': 19.1.4
chokidar: 3.6.0
- next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
server-only: 0.0.1
@@ -17230,7 +17236,7 @@ snapshots:
'@sentry/core@9.46.0': {}
- '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
+ '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.34.0
@@ -17243,7 +17249,7 @@ snapshots:
'@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
'@sentry/webpack-plugin': 3.5.0(webpack@5.101.3)
chalk: 3.0.0
- next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
resolve: 1.22.8
rollup: 4.35.0
stacktrace-parser: 0.1.11
@@ -20630,6 +20636,8 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
+ libphonenumber-js@1.12.15: {}
+
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -21265,31 +21273,6 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
- next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
- dependencies:
- '@next/env': 15.5.2
- '@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001723
- postcss: 8.4.31
- react: 19.1.0
- react-dom: 19.1.0(react@19.1.0)
- styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0)
- optionalDependencies:
- '@next/swc-darwin-arm64': 15.5.2
- '@next/swc-darwin-x64': 15.5.2
- '@next/swc-linux-arm64-gnu': 15.5.2
- '@next/swc-linux-arm64-musl': 15.5.2
- '@next/swc-linux-x64-gnu': 15.5.2
- '@next/swc-linux-x64-musl': 15.5.2
- '@next/swc-win32-arm64-msvc': 15.5.2
- '@next/swc-win32-x64-msvc': 15.5.2
- '@opentelemetry/api': 1.9.0
- babel-plugin-react-compiler: 19.1.0-rc.2
- sharp: 0.34.3
- transitivePeerDependencies:
- - '@babel/core'
- - babel-plugin-macros
-
no-case@3.0.4:
dependencies:
lower-case: 2.0.2
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index ed8d175..b26211f 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -128,6 +128,9 @@
"amount": "Amount",
"selectDate": "Select date"
},
+ "formFieldError": {
+ "invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)"
+ },
"wallet": {
"balance": "Your MedReport account balance",
"expiredAt": "Valid until {{expiredAt}}"
diff --git a/public/locales/et/common.json b/public/locales/et/common.json
index 3a8f55c..792cc3a 100644
--- a/public/locales/et/common.json
+++ b/public/locales/et/common.json
@@ -128,6 +128,9 @@
"amount": "Summa",
"selectDate": "Vali kuupäev"
},
+ "formFieldError": {
+ "invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)"
+ },
"wallet": {
"balance": "Sinu MedReporti konto saldo",
"expiredAt": "Kehtiv kuni {{expiredAt}}"
diff --git a/supabase/migrations/20250907000001_update_keycloak_user_creation.sql b/supabase/migrations/20250907000001_update_keycloak_user_creation.sql
new file mode 100644
index 0000000..ccc7834
--- /dev/null
+++ b/supabase/migrations/20250907000001_update_keycloak_user_creation.sql
@@ -0,0 +1,94 @@
+-- Update the user creation trigger to properly handle Keycloak user metadata
+CREATE OR REPLACE FUNCTION kit.setup_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO ''
+AS $$
+DECLARE
+ user_name text;
+ picture_url text;
+ personal_code text;
+ full_name text;
+ given_name text;
+ family_name text;
+ preferred_username text;
+BEGIN
+ -- Extract data from Keycloak user metadata
+ -- Check raw_user_meta_data first (this is where Keycloak data is stored)
+ IF new.raw_user_meta_data IS NOT NULL THEN
+ -- Try full_name first, then name field
+ full_name := new.raw_user_meta_data ->> 'full_name';
+ IF full_name IS NULL THEN
+ full_name := new.raw_user_meta_data ->> 'name';
+ END IF;
+
+ -- Extract individual name components
+ given_name := new.raw_user_meta_data -> 'custom_claims' ->> 'given_name';
+ family_name := new.raw_user_meta_data -> 'custom_claims' ->> 'family_name';
+ preferred_username := new.raw_user_meta_data -> 'custom_claims' ->> 'preferred_username';
+
+ -- Use given_name (first name) for the name field
+ IF given_name IS NOT NULL THEN
+ user_name := given_name;
+ ELSIF full_name IS NOT NULL THEN
+ user_name := full_name;
+ ELSIF preferred_username IS NOT NULL THEN
+ user_name := preferred_username;
+ END IF;
+
+ -- Extract personal code from preferred_username (Keycloak provides Estonian personal codes here)
+ IF preferred_username IS NOT NULL THEN
+ personal_code := preferred_username;
+ END IF;
+
+ -- Also try personalCode field as fallback
+ IF personal_code IS NULL THEN
+ personal_code := new.raw_user_meta_data ->> 'personalCode';
+ END IF;
+ END IF;
+
+ -- Fall back to email if no name found
+ IF user_name IS NULL AND new.email IS NOT NULL THEN
+ user_name := split_part(new.email, '@', 1);
+ END IF;
+
+ -- Default empty string if still no name
+ IF user_name IS NULL THEN
+ user_name := '';
+ END IF;
+
+ -- Extract picture URL
+ IF new.raw_user_meta_data ->> 'avatar_url' IS NOT NULL THEN
+ picture_url := new.raw_user_meta_data ->> 'avatar_url';
+ ELSE
+ picture_url := null;
+ END IF;
+
+ -- Insert into medreport.accounts
+ INSERT INTO medreport.accounts (
+ id,
+ primary_owner_user_id,
+ name,
+ last_name,
+ is_personal_account,
+ picture_url,
+ email,
+ personal_code,
+ application_role
+ )
+ VALUES (
+ new.id,
+ new.id,
+ user_name,
+ family_name,
+ true,
+ picture_url,
+ NULL, -- Keycloak email !== customer personal email, they will set this later
+ personal_code,
+ 'user' -- Default role for new users
+ );
+
+ RETURN new;
+END;
+$$;
diff --git a/supabase/migrations/20250908145900_update_account_email_keycloak.sql b/supabase/migrations/20250908145900_update_account_email_keycloak.sql
new file mode 100644
index 0000000..9e44e06
--- /dev/null
+++ b/supabase/migrations/20250908145900_update_account_email_keycloak.sql
@@ -0,0 +1,28 @@
+CREATE OR REPLACE FUNCTION medreport.update_account(p_name character varying, p_last_name text, p_personal_code text, p_phone text, p_city text, p_has_consent_personal_data boolean, p_uid uuid, p_email character varying)
+ RETURNS void
+ LANGUAGE plpgsql
+AS $function$begin
+ update medreport.accounts
+ set name = coalesce(p_name, name),
+ last_name = coalesce(p_last_name, last_name),
+ personal_code = coalesce(p_personal_code, personal_code),
+ phone = coalesce(p_phone, phone),
+ city = coalesce(p_city, city),
+ has_consent_personal_data = coalesce(p_has_consent_personal_data,
+ has_consent_personal_data),
+ email = coalesce(p_email, email)
+ where id = p_uid;
+end;$function$
+;
+
+grant
+execute on function medreport.update_account(
+ p_name character varying,
+ p_last_name text,
+ p_personal_code text,
+ p_phone text,
+ p_city text,
+ p_has_consent_personal_data boolean,
+ p_uid uuid,
+ p_email character varying) to authenticated,
+service_role;
diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts
index 6cbbb3b..c505608 100644
--- a/utils/medusa-product.ts
+++ b/utils/medusa-product.ts
@@ -1,17 +1,27 @@
-export const getAnalysisElementMedusaProductIds = (products: ({
+import { StoreProduct } from "@medusajs/types";
+
+type Product = {
metadata?: {
analysisElementMedusaProductIds?: string;
} | null;
-} | null)[]) => {
+ variant?: {
+ metadata?: {
+ analysisElementMedusaProductIds?: string;
+ } | null;
+ } | null;
+} | null;
+
+export const getAnalysisElementMedusaProductIds = (products: Pick[]) => {
if (!products) {
return [];
}
const mapped = products
.flatMap((product) => {
- const value = product?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
+ const value = (product as Product)?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
+ const value_variant = (product as Product)?.variant?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
try {
- return JSON.parse(value as string);
+ return [...JSON.parse(value as string), ...JSON.parse(value_variant as string)];
} catch (e) {
console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e);
return [];