feat: implement membership confirmation flow and update related functionalities
This commit is contained in:
@@ -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)
|
||||
|
||||
11
app/auth/membership-confirmation/layout.tsx
Normal file
11
app/auth/membership-confirmation/layout.tsx
Normal 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);
|
||||
46
app/auth/membership-confirmation/page.tsx
Normal file
46
app/auth/membership-confirmation/page.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './notifications-popover';
|
||||
export * from './success-notification';
|
||||
export * from './update-account-success-notification';
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user