feat: implement membership confirmation flow and update related functionalities

This commit is contained in:
Danel Kungla
2025-07-08 15:59:08 +03:00
parent 848dbb1618
commit 10580fa653
18 changed files with 188 additions and 179 deletions

View File

@@ -70,3 +70,12 @@ To update database types run:
```bash
npm run supabase:typegen:app
```
## Super admin
To access admin pages follow these steps:
- Register new user
- Go to Profile and add Multi-Factor Authentication
- Sign out and Sign in
- Authenticate with mfa (at current time profile page prompts it again)

View File

@@ -0,0 +1,11 @@
import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) {
return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
{props.children}
</div>
);
}
export default withI18n(SiteLayout);

View File

@@ -0,0 +1,46 @@
import { redirect } from 'next/navigation';
import pathsConfig from '@/config/paths.config';
import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from '@kit/notifications/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { withI18n } from '~/lib/i18n/with-i18n';
async function UpdateAccountSuccess() {
const { t } = useTranslation('account');
const client = getSupabaseServerClient();
const {
data: { user },
} = await client.auth.getUser();
if (!user?.id) {
redirect(pathsConfig.app.home);
}
const { data: accountData } = usePersonalAccountData(user.id);
if (!accountData?.id) {
redirect(pathsConfig.app.home);
}
return (
<SuccessNotification
showLogo={false}
title={t('account:membershipConfirmation:successTitle', {
firstName: accountData?.name,
lastName: accountData?.last_name,
})}
descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.selectPackage,
}}
/>
);
}
export default withI18n(UpdateAccountSuccess);

View File

@@ -1,17 +0,0 @@
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { UpdateAccountSuccessNotification } from '@kit/notifications/components';
import { withI18n } from '~/lib/i18n/with-i18n';
async function UpdateAccountSuccess() {
const client = getSupabaseServerClient();
const {
data: { user },
} = await client.auth.getUser();
return <UpdateAccountSuccessNotification userId={user?.id} />;
}
export default withI18n(UpdateAccountSuccess);

View File

@@ -10,6 +10,7 @@ const PathsSchema = z.object({
passwordUpdate: z.string().min(1),
updateAccount: z.string().min(1),
updateAccountSuccess: z.string().min(1),
membershipConfirmation: z.string().min(1),
}),
app: z.object({
home: z.string().min(1),
@@ -42,6 +43,7 @@ const pathsConfig = PathsSchema.parse({
passwordUpdate: '/update-password',
updateAccount: '/auth/update-account',
updateAccountSuccess: '/auth/update-account/success',
membershipConfirmation: '/auth/membership-confirmation',
},
app: {
home: '/home',

View File

@@ -97,7 +97,7 @@
"pino-pretty": "^13.0.0",
"prettier": "^3.5.3",
"react-hook-form": "^7.57.0",
"supabase": "^2.26.9",
"supabase": "^2.30.4",
"tailwindcss": "4.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3",

View File

@@ -47,16 +47,13 @@ class AccountsApi {
}
/**
* @name loadUserAccounts
* Load only user-owned accounts (not just memberships).
*/
* @name loadUserAccounts
* Load only user-owned accounts (not just memberships).
*/
async loadUserAccounts() {
const authUser = await this.client.auth.getUser();
const {
data,
error: userError,
} = authUser
const { data, error: userError } = authUser;
if (userError) {
throw userError;
@@ -66,14 +63,16 @@ class AccountsApi {
const { data: accounts, error } = await this.client
.from('accounts_memberships')
.select(`
.select(
`
account_id,
user_accounts (
name,
slug,
picture_url
picture_url,
)
`,
)
`)
.eq('user_id', user.id)
.eq('account_role', 'owner');
@@ -88,7 +87,6 @@ class AccountsApi {
}));
}
async loadTempUserAccounts() {
const { data: accounts, error } = await this.client
.from('user_accounts')

View File

@@ -16,7 +16,8 @@
"./mfa": "./src/mfa.ts",
"./captcha/client": "./src/captcha/client/index.ts",
"./captcha/server": "./src/captcha/server/index.ts",
"./resend-email-link": "./src/components/resend-auth-link-form.tsx"
"./resend-email-link": "./src/components/resend-auth-link-form.tsx",
"./lib/utils/*": "./src/lib/utils/*.ts"
},
"devDependencies": {
"@hookform/resolvers": "^5.0.1",

View File

@@ -55,14 +55,14 @@ export function SignInMethodsContainer(props: {
}
try {
const { data: hasPersonalCode } = await client.rpc(
'has_personal_code',
const { data: hasConsentPersonalData } = await client.rpc(
'has_consent_personal_data',
{
account_id: userId,
},
);
if (hasPersonalCode) {
if (hasConsentPersonalData) {
router.replace(props.paths.returnPath);
} else {
router.replace(props.paths.updateAccount);

View File

@@ -23,7 +23,7 @@ export interface AccountSubmitData {
}
export const onUpdateAccount = enhanceAction(
async (params) => {
async (params: AccountSubmitData) => {
const client = getSupabaseServerClient();
const api = createAuthApi(client);
@@ -36,7 +36,14 @@ export const onUpdateAccount = enhanceAction(
}
console.warn('On update account error: ', err);
}
redirect(pathsConfig.auth.updateAccountSuccess);
const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation();
if (hasUnseenMembershipConfirmation) {
redirect(pathsConfig.auth.membershipConfirmation);
} else {
redirect(pathsConfig.app.selectPackage);
}
},
{
schema: UpdateAccountSchema,

View File

@@ -13,14 +13,24 @@ class AuthApi {
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name hasPersonalCode
* @description Check if given account ID has added personal code.
* @param id
* @name hasUnseenMembershipConfirmation
* @description Check if given user ID has any unseen membership confirmation.
*/
async hasPersonalCode(id: string) {
const { data, error } = await this.client.rpc('has_personal_code', {
account_id: id,
});
async hasUnseenMembershipConfirmation() {
const {
data: { user },
} = await this.client.auth.getUser();
if (!user) {
throw new Error('User not authenticated');
}
const { data, error } = await this.client.rpc(
'has_unseen_membership_confirmation',
{
p_user_id: user.id,
},
);
if (error) {
throw error;

View File

@@ -1,3 +1,2 @@
export * from './notifications-popover';
export * from './success-notification';
export * from './update-account-success-notification';

View File

@@ -1,39 +0,0 @@
'use client';
import { redirect } from 'next/navigation';
import pathsConfig from '@/config/paths.config';
import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from './success-notification';
export const UpdateAccountSuccessNotification = ({
userId,
}: {
userId?: string;
}) => {
const { t } = useTranslation('account');
if (!userId) {
redirect(pathsConfig.app.home);
}
const { data: accountData } = usePersonalAccountData(userId);
return (
<SuccessNotification
showLogo={false}
title={t('account:updateAccount:successTitle', {
firstName: accountData?.name,
lastName: accountData?.last_name,
})}
descriptionKey="account:updateAccount:successDescription"
buttonProps={{
buttonTitleKey: 'account:updateAccount:successButton',
href: pathsConfig.app.selectPackage,
}}
/>
);
};

View File

@@ -117,7 +117,10 @@ class AccountInvitationsService {
}
const isUserAlreadyMember = members.find((member) => {
return member.email === invitation.email || member.personal_code === invitation.personal_code;
return (
member.email === invitation.email ||
member.personal_code === invitation.personal_code
);
});
if (isUserAlreadyMember) {

View File

@@ -17,7 +17,7 @@ export type Database = {
changed_data: Json | null
id: number
operation: string
record_key: number | null
record_key: string | null
row_data: Json | null
schema_name: string
table_name: string
@@ -29,7 +29,7 @@ export type Database = {
changed_data?: Json | null
id?: number
operation: string
record_key?: number | null
record_key?: string | null
row_data?: Json | null
schema_name: string
table_name: string
@@ -41,7 +41,7 @@ export type Database = {
changed_data?: Json | null
id?: number
operation?: string
record_key?: number | null
record_key?: string | null
row_data?: Json | null
schema_name?: string
table_name?: string
@@ -252,6 +252,7 @@ export type Database = {
account_role: string
created_at: string
created_by: string | null
has_seen_confirmation: boolean
updated_at: string
updated_by: string | null
user_id: string
@@ -261,6 +262,7 @@ export type Database = {
account_role: string
created_at?: string
created_by?: string | null
has_seen_confirmation?: boolean
updated_at?: string
updated_by?: string | null
user_id: string
@@ -270,6 +272,7 @@ export type Database = {
account_role?: string
created_at?: string
created_by?: string | null
has_seen_confirmation?: boolean
updated_at?: string
updated_by?: string | null
user_id?: string
@@ -901,74 +904,21 @@ export type Database = {
},
]
}
medreport_product_groups: {
Row: {
created_at: string
id: number
name: string
updated_at: string | null
}
Insert: {
created_at?: string
id?: number
name: string
updated_at?: string | null
}
Update: {
created_at?: string
id?: number
name?: string
updated_at?: string | null
}
Relationships: []
}
medreport_products: {
Row: {
created_at: string
id: number
name: string
product_group_id: number | null
updated_at: string | null
}
Insert: {
created_at?: string
id?: number
name: string
product_group_id?: number | null
updated_at?: string | null
}
Update: {
created_at?: string
id?: number
name?: string
product_group_id?: number | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "medreport_products_product_groups_id_fkey"
columns: ["product_group_id"]
isOneToOne: false
referencedRelation: "medreport_product_groups"
referencedColumns: ["id"]
},
]
}
medreport_products_analyses_relations: {
medusa_products_analyses_relations: {
Row: {
analysis_element_id: number | null
analysis_id: number | null
product_id: number
medusa_product_id: number
}
Insert: {
analysis_element_id?: number | null
analysis_id?: number | null
product_id: number
medusa_product_id: number
}
Update: {
analysis_element_id?: number | null
analysis_id?: number | null
product_id?: number
medusa_product_id?: number
}
Relationships: [
{
@@ -985,27 +935,20 @@ export type Database = {
referencedRelation: "analyses"
referencedColumns: ["id"]
},
{
foreignKeyName: "medreport_products_analyses_product_id_fkey"
columns: ["product_id"]
isOneToOne: true
referencedRelation: "medreport_products"
referencedColumns: ["id"]
},
]
}
medreport_products_external_services_relations: {
medusa_products_external_services_relations: {
Row: {
connected_online_service_id: number
product_id: number
medusa_product_id: number
}
Insert: {
connected_online_service_id: number
product_id: number
medusa_product_id: number
}
Update: {
connected_online_service_id?: number
product_id?: number
medusa_product_id?: number
}
Relationships: [
{
@@ -1015,13 +958,6 @@ export type Database = {
referencedRelation: "connected_online_services"
referencedColumns: ["id"]
},
{
foreignKeyName: "medreport_products_connected_online_services_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "medreport_products"
referencedColumns: ["id"]
},
]
}
nonces: {
@@ -1464,10 +1400,6 @@ export type Database = {
Args: { target_team_account_id: string; target_user_id: string }
Returns: boolean
}
check_personal_code_exists: {
Args: { code: string }
Returns: boolean
}
create_invitation: {
Args: { account_id: string; email: string; role: string }
Returns: {
@@ -1574,6 +1506,10 @@ export type Database = {
Args: { target_account_id: string }
Returns: boolean
}
has_consent_personal_data: {
Args: { account_id: string }
Returns: boolean
}
has_more_elevated_role: {
Args: {
target_user_id: string
@@ -1590,10 +1526,6 @@ export type Database = {
}
Returns: boolean
}
has_personal_code: {
Args: { account_id: string }
Returns: boolean
}
has_role_on_account: {
Args: { account_id: string; account_role?: string }
Returns: boolean
@@ -1606,6 +1538,10 @@ export type Database = {
}
Returns: boolean
}
has_unseen_membership_confirmation: {
Args: { p_user_id?: string }
Returns: boolean
}
is_aal2: {
Args: Record<PropertyKey, never>
Returns: boolean

10
pnpm-lock.yaml generated
View File

@@ -196,8 +196,8 @@ importers:
specifier: ^3.5.3
version: 3.5.3
supabase:
specifier: ^2.26.9
version: 2.26.9
specifier: ^2.30.4
version: 2.30.4
tailwindcss:
specifier: 4.1.7
version: 4.1.7
@@ -7296,8 +7296,8 @@ packages:
stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
supabase@2.26.9:
resolution: {integrity: sha512-wHl7HtAD2iHMVXL8JZyfSjdI0WYM7EF0ydThp1tSvDANaD2JHCZc8GH1NdzglbwGqdHmjCYeSZ+H28fmucYl7Q==}
supabase@2.30.4:
resolution: {integrity: sha512-AOCyd2vmBBMTXbnahiCU0reRNxKS4n5CrPciUF2tcTrQ8dLzl1HwcLfe5DrG8E0QRcKHPDdzprmh/2+y4Ta5MA==}
engines: {npm: '>=8'}
hasBin: true
@@ -14753,7 +14753,7 @@ snapshots:
stylis@4.2.0: {}
supabase@2.26.9:
supabase@2.30.4:
dependencies:
bin-links: 5.0.0
https-proxy-agent: 7.0.6

View File

@@ -126,10 +126,7 @@
"description": "Jätkamiseks palun sisestage enda isikuandmed",
"button": "Jätka",
"userConsentLabel": "Nõustun isikuandmete kasutamisega platvormil",
"userConsentUrlTitle": "Vaata isikuandmete töötlemise põhimõtteid",
"successTitle": "Tere, {{firstName}} {{lastName}}",
"successDescription": "Teie tervisekonto on aktiveeritud ja kasutamiseks valmis!",
"successButton": "Jätka"
"userConsentUrlTitle": "Vaata isikuandmete töötlemise põhimõtteid"
},
"consentModal": {
"title": "Enne toimetama hakkamist",
@@ -143,5 +140,10 @@
"consentToAnonymizedCompanyData": {
"label": "Nõustun osalema tööandja statistikas",
"description": "Nõustun anonümiseeritud kujul terviseandmete kasutamisega tööandja statistikas"
},
"membershipConfirmation": {
"successTitle": "Tere, {{firstName}} {{lastName}}",
"successDescription": "Teie tervisekonto on aktiveeritud ja kasutamiseks valmis!",
"successButton": "Jätka"
}
}
}

View File

@@ -0,0 +1,41 @@
alter table "public"."accounts_memberships" add column "has_seen_confirmation" boolean not null default false;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.has_unseen_membership_confirmation(p_user_id uuid DEFAULT auth.uid())
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public', 'extensions'
AS $function$
select exists (
select 1
from public.accounts_memberships am
where am.user_id = p_user_id
and am.has_seen_confirmation = false
);
$function$
;
grant execute on function public.has_unseen_membership_confirmation(uuid)
to authenticated, anon;
drop function if exists "public"."has_personal_code"(account_id uuid);
CREATE OR REPLACE FUNCTION public.has_consent_personal_data(account_id uuid)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$BEGIN
RETURN EXISTS (
SELECT 1
FROM public.accounts
WHERE id = account_id
AND has_consent_personal_data IS TRUE
);
END;$function$
;
grant execute on function public.has_consent_personal_data(uuid)
to authenticated, anon;