B2B-87: add company statistics consent (#29)

* B2B-87: add company statistics consent

* add toggle for company statistics consent under profile

* add toggle for company statistics consent under profile

* add audit logging to accounts

* change policy

* add audit logging to accounts

* remove full account data query and just query the entire account every time

* add comment about consent toggle

* make constants hardcoded, as dynamic ones do not work

* add back pending check

---------

Co-authored-by: Helena <helena@Helenas-MacBook-Pro.local>
This commit is contained in:
Helena
2025-07-03 17:55:23 +03:00
committed by GitHub
parent 517dce3146
commit ad08155063
18 changed files with 298 additions and 36 deletions

View File

@@ -13,10 +13,10 @@ import { personalAccountNavigationConfig } from '~/config/personal-account-navig
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
// home imports // home imports
import { HomeMenuNavigation } from './_components/home-menu-navigation'; import { HomeMenuNavigation } from '../_components/home-menu-navigation';
import { HomeMobileNavigation } from './_components/home-mobile-navigation'; import { HomeMobileNavigation } from '../_components/home-mobile-navigation';
import { HomeSidebar } from './_components/home-sidebar'; import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from './_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) { function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState()); const state = use(getLayoutState());
@@ -58,8 +58,8 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
return ( return (
<UserWorkspaceContextProvider value={workspace}> <UserWorkspaceContextProvider value={workspace}>
<Page style={'header'} > <Page style={'header'}>
<PageNavigation > <PageNavigation>
<HomeMenuNavigation workspace={workspace} /> <HomeMenuNavigation workspace={workspace} />
</PageNavigation> </PageNavigation>

View File

@@ -3,11 +3,11 @@ import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import Dashboard from './_components/dashboard'; import Dashboard from '../_components/dashboard';
// local imports // local imports
import { HomeLayoutPageHeader } from './_components/home-page-header'; import { HomeLayoutPageHeader } from '../_components/home-page-header';
import { use } from 'react'; import { use } from 'react';
import { loadUserWorkspace } from './_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
export const generateMetadata = async () => { export const generateMetadata = async () => {

View File

@@ -0,0 +1,73 @@
'use client';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { useUpdateAccountData } from '@kit/accounts/hooks/use-update-account';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
export default function ConsentDialog({ userId }: { userId: string }) {
const router = useRouter();
const updateAccountMutation = useUpdateAccountData(userId);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const updateConsent = (consentToCompanyStatistics: boolean) => {
const promise = updateAccountMutation
.mutateAsync({
has_consent_anonymized_company_statistics: consentToCompanyStatistics,
})
.then(() => {
revalidateUserDataQuery(userId);
});
toast.promise(() => promise, {
success: <Trans i18nKey={'account:updateConsentSuccess'} />,
error: <Trans i18nKey={'account:updateConsentError'} />,
loading: <Trans i18nKey={'account:updateConsentLoading'} />,
});
return router.refresh();
};
return (
<Dialog defaultOpen>
<DialogContent
className="flex max-w-[436px] flex-col items-center gap-4 space-y-4"
disableClose
>
<DialogHeader className="items-center text-center">
<Image alt="Toggle" src="/assets/toggle.png" width={96} height={96} />
<DialogTitle>
<Trans i18nKey="account:consentModal.title" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="account:consentModal.description" />
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={() => updateConsent(false)}>
<Trans i18nKey="account:consentModal.reject" />
</Button>
<Button onClick={() => updateConsent(true)}>
<Trans i18nKey="account:consentModal.accept" />
<CaretRightIcon />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,7 +5,7 @@ import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import { Search } from '~/components/ui/search'; import { Search } from '~/components/ui/search';
import { SIDEBAR_WIDTH } from '../../../../packages/ui/src/shadcn/constants'; import { SIDEBAR_WIDTH_PROPERTY } from '../../../../packages/ui/src/shadcn/constants';
// home imports // home imports
import { UserNotifications } from '../_components/user-notifications'; import { UserNotifications } from '../_components/user-notifications';
import { type UserWorkspace } from '../_lib/server/load-user-workspace'; import { type UserWorkspace } from '../_lib/server/load-user-workspace';
@@ -17,7 +17,7 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
return ( return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}> <div className={'flex w-full flex-1 items-center justify-between gap-3'}>
<div className={`flex items-center w-[${SIDEBAR_WIDTH}]`}> <div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo /> <AppLogo />
</div> </div>

View File

@@ -0,0 +1,21 @@
import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type UserAccount = Awaited<ReturnType<typeof loadUserAccount>>;
/**
* @name loadUserAccount
* @description
* Load the user account. It's a cached per-request function that fetches the user workspace data.
* It can be used across the server components to load the user workspace data.
*/
export const loadUserAccount = cache(accountLoader);
async function accountLoader(accountId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
return api.getAccount(accountId);
}

24
app/home/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { requireUserInServerComponent } from '../../lib/server/require-user-in-server-component';
import ConsentDialog from './(user)/_components/consent-dialog';
import { loadUserAccount } from './(user)/_lib/server/load-user-account';
export default async function HomeLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await requireUserInServerComponent();
const account = user?.identities?.[0]?.id
? await loadUserAccount(user?.identities?.[0]?.id)
: null;
if (account && account?.has_consent_anonymized_company_statistics === null) {
return (
<div className="container">
<ConsentDialog userId={user.id} />
</div>
);
}
return <>{children}</>;
}

View File

@@ -16,6 +16,7 @@ import { Trans } from '@kit/ui/trans';
import { usePersonalAccountData } from '../../hooks/use-personal-account-data'; import { usePersonalAccountData } from '../../hooks/use-personal-account-data';
import { AccountDangerZone } from './account-danger-zone'; import { AccountDangerZone } from './account-danger-zone';
import ConsentToggle from './consent/consent-toggle';
import { UpdateEmailFormContainer } from './email/update-email-form-container'; import { UpdateEmailFormContainer } from './email/update-email-form-container';
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list'; import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
import { UpdatePasswordFormContainer } from './password/update-password-container'; import { UpdatePasswordFormContainer } from './password/update-password-container';
@@ -150,6 +151,32 @@ export function PersonalAccountSettingsContainer(
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<div className="flex justify-between">
<div>
<p className="font-medium">
<Trans
i18nKey={'account:consentToAnonymizedCompanyData.label'}
/>
</p>
<CardDescription>
<Trans
i18nKey={'account:consentToAnonymizedCompanyData.description'}
/>
</CardDescription>
</div>
<ConsentToggle
userId={props.userId}
initialState={
!!user.data.has_consent_anonymized_company_statistics
}
/>
</div>
</CardHeader>
</Card>
<If condition={props.features.enableAccountDeletion}> <If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive'}> <Card className={'border-destructive'}>
<CardHeader> <CardHeader>

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { Switch } from '@kit/ui/switch';
import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../../hooks/use-personal-account-data';
import { useUpdateAccountData } from '../../../hooks/use-update-account';
// This is temporary. When the profile views are ready, all account values included in the form will be updated together on form submit.
export default function ConsentToggle({
userId,
initialState,
}: {
userId: string;
initialState: boolean;
}) {
const [isConsent, setIsConsent] = useState(initialState);
const updateAccountMutation = useUpdateAccountData(userId);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const updateConsent = (consent: boolean) => {
const promise = updateAccountMutation
.mutateAsync({
has_consent_anonymized_company_statistics: consent,
})
.then(() => {
revalidateUserDataQuery(userId);
});
return toast.promise(() => promise, {
success: <Trans i18nKey={'account:updateConsentSuccess'} />,
error: <Trans i18nKey={'account:updateConsentError'} />,
loading: <Trans i18nKey={'account:updateConsentLoading'} />,
});
};
return (
<Switch
checked={isConsent}
onCheckedChange={setIsConsent}
onClick={() => updateConsent(!isConsent)}
disabled={updateAccountMutation.isPending}
/>
);
}

View File

@@ -22,14 +22,7 @@ export function usePersonalAccountData(
const response = await client const response = await client
.from('accounts') .from('accounts')
.select( .select()
`
id,
name,
picture_url,
last_name
`,
)
.eq('primary_owner_user_id', userId) .eq('primary_owner_user_id', userId)
.eq('is_personal_account', true) .eq('is_personal_account', true)
.single(); .single();

View File

@@ -189,6 +189,7 @@ export type Database = {
created_at: string | null created_at: string | null
created_by: string | null created_by: string | null
email: string | null email: string | null
has_consent_anonymized_company_statistics: boolean | null
has_consent_personal_data: boolean | null has_consent_personal_data: boolean | null
id: string id: string
is_personal_account: boolean is_personal_account: boolean
@@ -208,6 +209,7 @@ export type Database = {
created_at?: string | null created_at?: string | null
created_by?: string | null created_by?: string | null
email?: string | null email?: string | null
has_consent_anonymized_company_statistics?: boolean | null
has_consent_personal_data?: boolean | null has_consent_personal_data?: boolean | null
id?: string id?: string
is_personal_account?: boolean is_personal_account?: boolean
@@ -227,6 +229,7 @@ export type Database = {
created_at?: string | null created_at?: string | null
created_by?: string | null created_by?: string | null
email?: string | null email?: string | null
has_consent_anonymized_company_statistics?: boolean | null
has_consent_personal_data?: boolean | null has_consent_personal_data?: boolean | null
id?: string id?: string
is_personal_account?: boolean is_personal_account?: boolean
@@ -1498,6 +1501,7 @@ export type Database = {
created_at: string | null created_at: string | null
created_by: string | null created_by: string | null
email: string | null email: string | null
has_consent_anonymized_company_statistics: boolean | null
has_consent_personal_data: boolean | null has_consent_personal_data: boolean | null
id: string id: string
is_personal_account: boolean is_personal_account: boolean

View File

@@ -1,3 +1,7 @@
export const SIDEBAR_WIDTH = '16rem'; export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem'; export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '4rem'; export const SIDEBAR_WIDTH_ICON = '4rem';
export const SIDEBAR_WIDTH_PROPERTY = 'w-[16rem]';
export const SIDEBAR_WIDTH_MOBILE_PROPERTY = 'w-[18rem]';
export const SIDEBAR_WIDTH_ICON_PROPERTY = 'w-[4rem]';

View File

@@ -32,8 +32,16 @@ const DialogContent: React.FC<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
customClose?: React.JSX.Element; customClose?: React.JSX.Element;
preventAutoFocus?: boolean; preventAutoFocus?: boolean;
disableClose?: boolean;
} }
> = ({ className, children, customClose, preventAutoFocus, ...props }) => ( > = ({
className,
children,
customClose,
disableClose,
preventAutoFocus,
...props
}) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -47,16 +55,26 @@ const DialogContent: React.FC<
onCloseAutoFocus={ onCloseAutoFocus={
preventAutoFocus ? (e) => e.preventDefault() : props.onOpenAutoFocus preventAutoFocus ? (e) => e.preventDefault() : props.onOpenAutoFocus
} }
onInteractOutside={
disableClose ? (e) => e.preventDefault() : props.onInteractOutside
}
onPointerDownOutside={
disableClose ? (e) => e.preventDefault() : props.onPointerDownOutside
}
onEscapeKeyDown={
disableClose ? (e) => e.preventDefault() : props.onEscapeKeyDown
}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs transition-opacity hover:opacity-70 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs transition-opacity hover:opacity-70 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
{customClose || ( {!disableClose &&
<> (customClose || (
<Cross2Icon className="h-4 w-4" /> <>
<span className="sr-only">Close</span> <Cross2Icon className="h-4 w-4" />
</> <span className="sr-only">Close</span>
)} </>
))}
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
@@ -114,13 +132,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}; };

BIN
public/assets/toggle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

View File

@@ -115,5 +115,12 @@
"createCompanyAccount": "Create Company Account", "createCompanyAccount": "Create Company Account",
"requestCompanyAccount": { "requestCompanyAccount": {
"title": "Company details" "title": "Company details"
},
"updateConsentSuccess": "Consent successfully updated",
"updateConsentError": "Encountered an error. Please try again",
"updateConsentLoading": "Updating consent...",
"consentToAnonymizedCompanyData": {
"label": "Consent to be included in employer statistics",
"description": "Consent to be included in anonymized company statistics"
} }
} }

View File

@@ -130,5 +130,18 @@
"successTitle": "Tere, {{firstName}} {{lastName}}", "successTitle": "Tere, {{firstName}} {{lastName}}",
"successDescription": "Teie tervisekonto on aktiveeritud ja kasutamiseks valmis!", "successDescription": "Teie tervisekonto on aktiveeritud ja kasutamiseks valmis!",
"successButton": "Jätka" "successButton": "Jätka"
},
"consentModal": {
"title": "Enne toimetama hakkamist",
"description": "Kas annad nõusoleku, et sinu terviseandmeid kasutatakse anonüümselt tööandja statistikas? Andmed jäävad isikustamata ja aitavad ettevõttel töötajate tervist paremini toetada.",
"reject": "Ei anna nõusolekut",
"accept": "Annan nõusoleku"
},
"updateConsentSuccess": "Nõusolekud uuendatud",
"updateConsentError": "Midagi läks valesti. Palun proovi uuesti",
"updateConsentLoading": "Nõusolekuid uuendatakse...",
"consentToAnonymizedCompanyData": {
"label": "Nõustun osalema tööandja statistikas",
"description": "Nõustun anonümiseeritud kujul terviseandmete kasutamisega tööandja statistikas"
} }
} }

View File

@@ -112,5 +112,12 @@
"noTeamsYet": "You don't have any teams yet.", "noTeamsYet": "You don't have any teams yet.",
"createTeam": "Create a team to get started.", "createTeam": "Create a team to get started.",
"createTeamButtonLabel": "Create a Team", "createTeamButtonLabel": "Create a Team",
"createCompanyAccount": "Create Company Account" "createCompanyAccount": "Create Company Account",
"updateConsentSuccess": "Consent successfully updated",
"updateConsentError": "Encountered an error. Please try again",
"updateConsentLoading": "Updating consent...",
"consentToAnonymizedCompanyData": {
"label": "Consent to be included in employer statistics",
"description": "Consent to be included in anonymized company statistics"
}
} }

View File

@@ -1,3 +1,5 @@
ALTER TABLE "public"."invitations" ADD COLUMN IF NOT EXISTS personal_code text;
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (

View File

@@ -0,0 +1,23 @@
alter table "public"."accounts" add column "has_consent_anonymized_company_statistics" boolean;
alter table "audit"."log_entries" alter column "record_key" set data type text using "record_key"::text;
create policy "insert_own"
on "audit"."log_entries"
as permissive
for insert
to authenticated
with check ((( SELECT auth.uid() AS uid) = changed_by));
drop policy "service_role_all" on "audit"."sync_entries";
create policy "service_role_all"
on "audit"."sync_entries"
as permissive
for all
to service_role
using (true);
CREATE TRIGGER log_account_change AFTER DELETE OR UPDATE ON public.accounts FOR EACH ROW EXECUTE FUNCTION audit.log_audit_changes();
GRANT USAGE ON SCHEMA audit TO authenticated;
grant insert on table audit.log_entries to authenticated;