Update theme toggle functionality and UI components

Implemented a new ModeToggle feature for theme switching in personal account dropdown. The changes also made adjustments to several UI components, such as transforming Dialog to AlertDialog in transfer-ownership-dialog, and introducing invitation-submit-button in team-accounts. Some minor amendments include text changes and styling modifications.
This commit is contained in:
giancarlo
2024-03-28 20:29:54 +08:00
parent caca7c12f6
commit f6d1b500da
30 changed files with 1318 additions and 810 deletions

View File

@@ -17,7 +17,7 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { ProfileDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown'; import { ProfileAccountDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
@@ -89,7 +89,7 @@ function SidebarContainer(props: {
<div className={'absolute bottom-4 left-0 w-full'}> <div className={'absolute bottom-4 left-0 w-full'}>
<SidebarContent> <SidebarContent>
<ProfileDropdownContainer <ProfileAccountDropdownContainer
session={props.session} session={props.session}
collapsed={props.collapsed} collapsed={props.collapsed}
/> />

View File

@@ -5,7 +5,7 @@ import { cookies } from 'next/headers';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar'; import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/_components/home-sidebar-account-selector'; import { HomeSidebarAccountSelector } from '~/(dashboard)/home/_components/home-sidebar-account-selector';
import { ProfileDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown'; import { ProfileAccountDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown';
import { loadUserWorkspace } from '~/(dashboard)/home/_lib/load-user-workspace'; import { loadUserWorkspace } from '~/(dashboard)/home/_lib/load-user-workspace';
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config'; import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
@@ -25,7 +25,10 @@ export function HomeSidebar() {
<div className={'absolute bottom-4 left-0 w-full'}> <div className={'absolute bottom-4 left-0 w-full'}>
<SidebarContent> <SidebarContent>
<ProfileDropdownContainer session={session} collapsed={collapsed} /> <ProfileAccountDropdownContainer
session={session}
collapsed={collapsed}
/>
</SidebarContent> </SidebarContent>
</div> </div>
</Sidebar> </Sidebar>

View File

@@ -5,9 +5,10 @@ import type { Session } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
export function ProfileDropdownContainer(props: { export function ProfileAccountDropdownContainer(props: {
collapsed: boolean; collapsed: boolean;
session: Session | null; session: Session | null;
}) { }) {
@@ -19,6 +20,9 @@ export function ProfileDropdownContainer(props: {
paths={{ paths={{
home: pathsConfig.app.home, home: pathsConfig.app.home,
}} }}
features={{
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
}}
className={'w-full'} className={'w-full'}
showProfileName={!props.collapsed} showProfileName={!props.collapsed}
session={props.session} session={props.session}

View File

@@ -13,6 +13,7 @@ import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
export function SiteHeaderAccountSection( export function SiteHeaderAccountSection(
@@ -38,6 +39,9 @@ function SuspendedPersonalAccountDropdown(props: { session: Session | null }) {
paths={{ paths={{
home: pathsConfig.app.home, home: pathsConfig.app.home,
}} }}
features={{
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
}}
session={session} session={session}
signOutRequested={() => signOut.mutateAsync()} signOutRequested={() => signOut.mutateAsync()}
/> />

View File

@@ -1,5 +1,7 @@
import type { Session } from '@supabase/supabase-js'; import type { Session } from '@supabase/supabase-js';
import { ModeToggle } from '@kit/ui/mode-toggle';
import { SiteHeaderAccountSection } from '~/(marketing)/_components/site-header-account-section'; import { SiteHeaderAccountSection } from '~/(marketing)/_components/site-header-account-section';
import { SiteNavigation } from '~/(marketing)/_components/site-navigation'; import { SiteNavigation } from '~/(marketing)/_components/site-navigation';
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
@@ -17,13 +19,11 @@ export function SiteHeader(props: { session?: Session | null }) {
</div> </div>
<div className={'flex flex-1 items-center justify-end space-x-4'}> <div className={'flex flex-1 items-center justify-end space-x-4'}>
<div className={'flex items-center'}></div> <div className={'flex items-center'}>
<ModeToggle />
</div>
<SiteHeaderAccountSection session={props.session ?? null} /> <SiteHeaderAccountSection session={props.session ?? null} />
<div className={'flex lg:hidden'}>
<SiteNavigation />
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,11 @@
import { notFound } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { Logger } from '@kit/shared/logger';
import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { AcceptInvitationContainer } from '@kit/team-accounts/components';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
interface Context { interface Context {
@@ -10,23 +14,98 @@ interface Context {
}; };
} }
export const metadata = { export const generateMetadata = () => {
title: `Join Organization`, return {
title: 'Join Team Account',
};
}; };
async function JoinTeamAccountPage({ searchParams }: Context) { async function JoinTeamAccountPage({ searchParams }: Context) {
const token = searchParams.invite_token; const token = searchParams.invite_token;
const data = await getInviteDataFromInviteToken(token);
if (!data) { // no token, redirect to 404
if (!token) {
notFound(); notFound();
} }
return <></>; const client = getSupabaseServerComponentClient();
const session = await requireAuth(client);
// if the user is not logged in or there is an error
// redirect to the sign up page with the invite token
// so that they will get back to this page after signing up
if (session.error ?? !session.data) {
redirect(pathsConfig.auth.signUp + '?invite_token=' + token);
}
// the user is logged in, we can now check if the token is valid
const invitation = await getInviteDataFromInviteToken(token);
if (!invitation) {
notFound();
}
// we need to verify the user isn't already in the account
const isInAccount = await isCurrentUserAlreadyInAccount(
invitation.account.id,
);
if (isInAccount) {
Logger.warn(
{
name: 'join-team-account',
accountId: invitation.account.id,
userId: session.data.user.id,
},
'User is already in the account. Redirecting to account page.',
);
// if the user is already in the account redirect to the home page
redirect(pathsConfig.app.home);
}
// if the user decides to sign in with a different account
// we redirect them to the sign in page with the invite token
const signOutNext = pathsConfig.auth.signIn + '?invite_token=' + token;
// once the user accepts the invitation, we redirect them to the account home page
const accountHome = pathsConfig.app.accountHome.replace(
'[account]',
invitation.account.slug,
);
return (
<AcceptInvitationContainer
inviteToken={token}
invitation={invitation}
paths={{
signOutNext,
accountHome,
}}
/>
);
} }
export default withI18n(JoinTeamAccountPage); export default withI18n(JoinTeamAccountPage);
/**
* Verifies that the current user is not already in the account by
* reading the document from the `accounts` table. If the user can read it
* it means they are already in the account.
* @param accountId
*/
async function isCurrentUserAlreadyInAccount(accountId: string) {
const client = getSupabaseServerComponentClient();
const { data } = await client
.from('accounts')
.select('id')
.eq('id', accountId)
.maybeSingle();
return !!data?.id;
}
async function getInviteDataFromInviteToken(token: string) { async function getInviteDataFromInviteToken(token: string) {
// we use an admin client to be able to read the pending membership // we use an admin client to be able to read the pending membership
// without having to be logged in // without having to be logged in
@@ -34,7 +113,18 @@ async function getInviteDataFromInviteToken(token: string) {
const { data: invitation, error } = await adminClient const { data: invitation, error } = await adminClient
.from('invitations') .from('invitations')
.select() .select<
string,
{
id: string;
account: {
id: string;
name: string;
slug: string;
picture_url: string;
};
}
>('id, account: account_id !inner (id, name, slug, picture_url)')
.eq('invite_token', token) .eq('invite_token', token)
.single(); .single();

View File

@@ -40,7 +40,7 @@ function getClassName() {
const theme = themeCookie ?? appConfig.theme; const theme = themeCookie ?? appConfig.theme;
const dark = theme === 'dark'; const dark = theme === 'dark';
return cn('antialiased', { return cn('min-h-screen bg-background antialiased', {
dark, dark,
[sans.className]: true, [sans.className]: true,
}); });

View File

@@ -2,10 +2,12 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'; import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
import { ThemeProvider } from 'next-themes';
import { I18nProvider } from '@kit/i18n/provider'; import { I18nProvider } from '@kit/i18n/provider';
import { AuthChangeListener } from '@kit/supabase/components/auth-change-listener'; import { AuthChangeListener } from '@kit/supabase/components/auth-change-listener';
import appConfig from '~/config/app.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { i18nResolver } from '~/lib/i18n/i18n.resolver'; import { i18nResolver } from '~/lib/i18n/i18n.resolver';
@@ -22,7 +24,14 @@ export function RootProviders({
<ReactQueryStreamedHydration> <ReactQueryStreamedHydration>
<AuthChangeListener appHomePath={pathsConfig.app.home}> <AuthChangeListener appHomePath={pathsConfig.app.home}>
<I18nProvider lang={lang} resolver={i18nResolver}> <I18nProvider lang={lang} resolver={i18nResolver}>
{children} <ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={appConfig.theme}
>
{children}
</ThemeProvider>
</I18nProvider> </I18nProvider>
</AuthChangeListener> </AuthChangeListener>
</ReactQueryStreamedHydration> </ReactQueryStreamedHydration>

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
const FeatureFlagsSchema = z.object({ const FeatureFlagsSchema = z.object({
enableThemeSwitcher: z.boolean(), enableThemeToggle: z.boolean(),
enableAccountDeletion: z.boolean(), enableAccountDeletion: z.boolean(),
enableTeamDeletion: z.boolean(), enableTeamDeletion: z.boolean(),
enableTeamAccounts: z.boolean(), enableTeamAccounts: z.boolean(),
@@ -11,7 +11,7 @@ const FeatureFlagsSchema = z.object({
}); });
const featuresFlagConfig = FeatureFlagsSchema.parse({ const featuresFlagConfig = FeatureFlagsSchema.parse({
enableThemeSwitcher: true, enableThemeToggle: true,
enableAccountDeletion: getBoolean( enableAccountDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_ACCOUNT_DELETION, process.env.NEXT_PUBLIC_ENABLE_ACCOUNT_DELETION,
false, false,

View File

@@ -121,5 +121,12 @@
"updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.", "updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.",
"searchInvitations": "Search Invitations", "searchInvitations": "Search Invitations",
"updateInvitation": "Update Invitation", "updateInvitation": "Update Invitation",
"removeInvitation": "Remove Invitation" "removeInvitation": "Remove Invitation",
"acceptInvitation": "Accept Invitation",
"signInWithDifferentAccount": "Sign in with a different account",
"signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.",
"acceptInvitationHeading": "Accept Invitation to join {{accountName}}",
"acceptInvitationDescription": "You have been invited to join the team {{accountName}}. If you wish to accept the invitation, please click the button below.",
"joinTeam": "Join {{accountName}}",
"joiningTeam": "Joining team..."
} }

View File

@@ -29,6 +29,7 @@
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@tanstack/react-query": "5.28.6", "@tanstack/react-query": "5.28.6",
"lucide-react": "^0.363.0", "lucide-react": "^0.363.0",
"next-themes": "0.3.0",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.51.2",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"sonner": "^1.4.41", "sonner": "^1.4.41",

View File

@@ -22,6 +22,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { SubMenuModeToggle } from '@kit/ui/mode-toggle';
import { ProfileAvatar } from '@kit/ui/profile-avatar'; import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
@@ -34,6 +35,7 @@ export function PersonalAccountDropdown({
signOutRequested, signOutRequested,
showProfileName, showProfileName,
paths, paths,
features,
}: { }: {
className?: string; className?: string;
session: Session | null; session: Session | null;
@@ -42,6 +44,9 @@ export function PersonalAccountDropdown({
paths: { paths: {
home: string; home: string;
}; };
features: {
enableThemeToggle: boolean;
};
}) { }) {
const { data: personalAccountData } = usePersonalAccountData(); const { data: personalAccountData } = usePersonalAccountData();
const authUser = session?.user; const authUser = session?.user;
@@ -156,6 +161,12 @@ export function PersonalAccountDropdown({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<If condition={features.enableThemeToggle}>
<SubMenuModeToggle />
</If>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
role={'button'} role={'button'}
className={'cursor-pointer'} className={'cursor-pointer'}

View File

@@ -5,8 +5,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js'; import type { Provider } from '@supabase/supabase-js';
import { isBrowser } from '@kit/shared/utils'; import { isBrowser } from '@kit/shared/utils';
import { Divider } from '@kit/ui/divider';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers'; import { OauthProviders } from './oauth-providers';
@@ -46,7 +46,7 @@ export function SignInMethodsContainer(props: {
</If> </If>
<If condition={props.providers.oAuth.length}> <If condition={props.providers.oAuth.length}>
<Divider /> <Separator />
<OauthProviders <OauthProviders
enabledProviders={props.providers.oAuth} enabledProviders={props.providers.oAuth}

View File

@@ -3,8 +3,8 @@
import type { Provider } from '@supabase/supabase-js'; import type { Provider } from '@supabase/supabase-js';
import { isBrowser } from '@kit/shared/utils'; import { isBrowser } from '@kit/shared/utils';
import { Divider } from '@kit/ui/divider';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers'; import { OauthProviders } from './oauth-providers';
@@ -24,9 +24,7 @@ export function SignUpMethodsContainer(props: {
inviteToken?: string; inviteToken?: string;
}) { }) {
const redirectUrl = isBrowser() const redirectUrl = getCallbackUrl(props);
? new URL(props.paths.callback, window?.location.origin).toString()
: '';
return ( return (
<> <>
@@ -42,7 +40,7 @@ export function SignUpMethodsContainer(props: {
</If> </If>
<If condition={props.providers.oAuth.length}> <If condition={props.providers.oAuth.length}>
<Divider /> <Separator />
<OauthProviders <OauthProviders
enabledProviders={props.providers.oAuth} enabledProviders={props.providers.oAuth}
@@ -56,3 +54,26 @@ export function SignUpMethodsContainer(props: {
</> </>
); );
} }
function getCallbackUrl(props: {
paths: {
callback: string;
appHome: string;
};
inviteToken?: string;
}) {
if (!isBrowser()) {
return '';
}
const redirectPath = props.paths.callback;
const origin = window.location.origin;
const url = new URL(redirectPath, origin);
if (props.inviteToken) {
url.searchParams.set('invite_token', props.inviteToken);
}
return url.href;
}

View File

@@ -3,3 +3,4 @@ export * from './members/invite-members-dialog-container';
export * from './settings/team-account-danger-zone'; export * from './settings/team-account-danger-zone';
export * from './invitations/account-invitations-table'; export * from './invitations/account-invitations-table';
export * from './settings/team-account-settings-container'; export * from './settings/team-account-settings-container';
export * from './invitations/accept-invitation-container';

View File

@@ -0,0 +1,85 @@
import Image from 'next/image';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { acceptInvitationAction } from '../../server/actions/team-invitations-server-actions';
import { InvitationSubmitButton } from './invitation-submit-button';
import { SignOutInvitationButton } from './sign-out-invitation-button';
export function AcceptInvitationContainer(props: {
inviteToken: string;
invitation: {
id: string;
account: {
name: string;
id: string;
picture_url: string | null;
};
};
paths: {
signOutNext: string;
accountHome: string;
};
}) {
return (
<div className={'flex flex-col items-center space-y-8'}>
<Heading className={'text-center'} level={5}>
<Trans
i18nKey={'teams:acceptInvitationHeading'}
values={{
accountName: props.invitation.account.name,
}}
/>
</Heading>
<If condition={props.invitation.account.picture_url}>
{(url) => (
<Image
alt={`Logo`}
src={url}
width={64}
height={64}
className={'object-cover'}
/>
)}
</If>
<div className={'text-muted-foreground text-center text-sm'}>
<Trans
i18nKey={'teams:acceptInvitationDescription'}
values={{
accountName: props.invitation.account.name,
}}
/>
</div>
<div className={'flex flex-col space-y-2.5'}>
<form className={'w-full'} action={acceptInvitationAction}>
<input type="hidden" name={'inviteToken'} value={props.inviteToken} />
<input
type={'hidden'}
name={'nextPath'}
value={props.paths.accountHome}
/>
<InvitationSubmitButton accountName={props.invitation.account.name} />
</form>
<Separator />
<SignOutInvitationButton nextPath={props.paths.signOutNext} />
<span className={'text-muted-foreground text-center text-xs'}>
<Trans i18nKey={'teams:signInWithDifferentAccountDescription'} />
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { useFormStatus } from 'react-dom';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
export function InvitationSubmitButton(props: { accountName: string }) {
const { pending } = useFormStatus();
return (
<Button className={'w-full'} disabled={pending}>
<Trans
i18nKey={pending ? 'teams:joiningTeam' : 'teams:joinTeam'}
values={{
accountName: props.accountName,
}}
/>
</Button>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
export function SignOutInvitationButton(
props: React.PropsWithChildren<{
nextPath: string;
}>,
) {
const signOut = useSignOut();
return (
<Button
variant={'ghost'}
onClick={async () => {
await signOut.mutateAsync();
window.location.assign(props.nextPath);
}}
>
<Trans i18nKey={'teams:signInWithDifferentAccount'} />
</Button>
);
}

View File

@@ -9,8 +9,9 @@ type Role = Database['public']['Enums']['account_role'];
const roleClassNameBuilder = cva('font-medium capitalize', { const roleClassNameBuilder = cva('font-medium capitalize', {
variants: { variants: {
role: { role: {
owner: 'bg-primary', owner: '',
member: 'bg-blue-50 text-blue-500 dark:bg-blue-500/10', member:
'bg-blue-50 hover:bg-blue-50 text-blue-500 dark:bg-blue-500/10 dark:hover:bg-blue-500/10',
}, },
}, },
}); });

View File

@@ -6,14 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { import {
Dialog, AlertDialog,
DialogContent, AlertDialogCancel,
DialogDescription, AlertDialogContent,
DialogHeader, AlertDialogDescription,
DialogTitle, AlertDialogFooter,
} from '@kit/ui/dialog'; AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { import {
Form, Form,
FormControl, FormControl,
@@ -38,17 +40,17 @@ export const TransferOwnershipDialog: React.FC<{
targetDisplayName: string; targetDisplayName: string;
}> = ({ isOpen, setIsOpen, targetDisplayName, accountId, userId }) => { }> = ({ isOpen, setIsOpen, targetDisplayName, accountId, userId }) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent> <AlertDialogContent>
<DialogHeader> <AlertDialogHeader>
<DialogTitle> <AlertDialogTitle>
<Trans i18nKey="team:transferOwnership" /> <Trans i18nKey="team:transferOwnership" />
</DialogTitle> </AlertDialogTitle>
<DialogDescription> <AlertDialogDescription>
<Trans i18nKey="team:transferOwnershipDescription" /> <Trans i18nKey="team:transferOwnershipDescription" />
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<TransferOrganizationOwnershipForm <TransferOrganizationOwnershipForm
accountId={accountId} accountId={accountId}
@@ -56,8 +58,8 @@ export const TransferOwnershipDialog: React.FC<{
targetDisplayName={targetDisplayName} targetDisplayName={targetDisplayName}
setIsOpen={setIsOpen} setIsOpen={setIsOpen}
/> />
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
); );
}; };
@@ -100,7 +102,7 @@ function TransferOrganizationOwnershipForm({
return ( return (
<Form {...form}> <Form {...form}>
<form <form
className={'flex flex-col space-y-2 text-sm'} className={'flex flex-col space-y-4 text-sm'}
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
> >
<If condition={error}> <If condition={error}>
@@ -117,10 +119,6 @@ function TransferOrganizationOwnershipForm({
/> />
</p> </p>
<p>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</p>
<FormField <FormField
name={'confirmation'} name={'confirmation'}
render={({ field }) => { render={({ field }) => {
@@ -144,19 +142,31 @@ function TransferOrganizationOwnershipForm({
}} }}
/> />
<Button <div>
type={'submit'} <p className={'text-muted-foreground'}>
data-test={'confirm-transfer-ownership-button'} <Trans i18nKey={'common:modalConfirmationQuestion'} />
variant={'destructive'} </p>
disabled={pending} </div>
>
<If <AlertDialogFooter>
condition={pending} <AlertDialogCancel>
fallback={<Trans i18nKey={'teams:transferOwnership'} />} <Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
data-test={'confirm-transfer-ownership-button'}
variant={'destructive'}
disabled={pending}
> >
<Trans i18nKey={'teams:transferringOwnership'} /> <If
</If> condition={pending}
</Button> fallback={<Trans i18nKey={'teams:transferOwnership'} />}
>
<Trans i18nKey={'teams:transferringOwnership'} />
</If>
</Button>
</AlertDialogFooter>
</form> </form>
</Form> </Form>
); );

View File

@@ -124,7 +124,7 @@ function UpdateMemberForm({
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>{t('memberRole')}</FormLabel> <FormLabel>{t('roleLabel')}</FormLabel>
<FormControl> <FormControl>
<MembershipRoleSelector <MembershipRoleSelector

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const AcceptInvitationSchema = z.object({
inviteToken: z.string().uuid(),
nextPath: z.string().min(1),
});

View File

@@ -1,14 +1,17 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod'; import { z } from 'zod';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema'; import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation-schema'; import { UpdateInvitationSchema } from '../../schema/update-invitation-schema';
@@ -80,10 +83,32 @@ export async function updateInvitationAction(
return { success: true }; return { success: true };
} }
async function assertSession(client: SupabaseClient<Database>) { export async function acceptInvitationAction(data: FormData) {
const { data, error } = await client.auth.getUser(); const client = getSupabaseServerActionClient();
if (error ?? !data.user) { const { inviteToken, nextPath } = AcceptInvitationSchema.parse(
Object.fromEntries(data),
);
const { user } = await assertSession(client);
const service = new AccountInvitationsService(client);
await service.acceptInvitationToTeam({
adminClient: getSupabaseServerActionClient({ admin: true }),
inviteToken,
userId: user.id,
});
return redirect(nextPath);
}
async function assertSession(client: SupabaseClient<Database>) {
const { error, data } = await requireAuth(client);
if (error) {
throw new Error(`Authentication required`); throw new Error(`Authentication required`);
} }
return data;
} }

View File

@@ -6,6 +6,7 @@ import { z } from 'zod';
import { Mailer } from '@kit/mailers'; import { Mailer } from '@kit/mailers';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { requireAuth } from '@kit/supabase/require-auth';
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema'; import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { InviteMembersSchema } from '../../schema/invite-members.schema';
@@ -206,8 +207,28 @@ export class AccountInvitationsService {
); );
} }
/**
* Accepts an invitation to join a team.
*/
async acceptInvitationToTeam(params: {
userId: string;
inviteToken: string;
adminClient: SupabaseClient<Database>;
}) {
const { error, data } = await params.adminClient.rpc('accept_invitation', {
token: params.inviteToken,
user_id: params.userId,
});
if (error) {
throw error;
}
return data;
}
private async getUser() { private async getUser() {
const { data, error } = await this.client.auth.getUser(); const { data, error } = await requireAuth(this.client);
if (error ?? !data) { if (error ?? !data) {
throw new Error('Authentication required'); throw new Error('Authentication required');
@@ -217,6 +238,6 @@ export class AccountInvitationsService {
} }
private getInvitationLink(token: string) { private getInvitationLink(token: string) {
return new URL(env.invitePath, env.siteURL).href + `?token=${token}`; return new URL(env.siteURL, env.siteURL).href + `?invite_token=${token}`;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -119,7 +119,8 @@
"./auth-change-listener": "./src/makerkit/auth-change-listener.tsx", "./auth-change-listener": "./src/makerkit/auth-change-listener.tsx",
"./loading-overlay": "./src/makerkit/loading-overlay.tsx", "./loading-overlay": "./src/makerkit/loading-overlay.tsx",
"./profile-avatar": "./src/makerkit/profile-avatar.tsx", "./profile-avatar": "./src/makerkit/profile-avatar.tsx",
"./mdx": "./src/makerkit/mdx/mdx-renderer.tsx" "./mdx": "./src/makerkit/mdx/mdx-renderer.tsx",
"./mode-toggle": "./src/makerkit/mode-toggle.tsx"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -0,0 +1,99 @@
'use client';
import { useMemo } from 'react';
import { Check, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '../shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { If } from './if';
import { Trans } from './trans';
const MODES = ['light', 'dark', 'system'];
export function ModeToggle() {
const { setTheme } = useTheme();
const Items = useMemo(() => {
return MODES.map((mode) => {
return (
<DropdownMenuItem
key={mode}
onClick={() => {
setTheme(mode);
}}
>
<Trans i18nKey={`common:${mode}Theme`} />
</DropdownMenuItem>
);
});
}, [setTheme]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{Items}</DropdownMenuContent>
</DropdownMenu>
);
}
export function SubMenuModeToggle() {
const { setTheme, theme, resolvedTheme } = useTheme();
const MenuItems = useMemo(
() =>
['light', 'dark', 'system'].map((item) => {
return (
<DropdownMenuItem
className={'justify-between'}
key={item}
onClick={() => {
setTheme(item);
}}
>
<Trans i18nKey={`common:${item}Theme`} />
<If condition={theme === item}>
<Check className={'mr-2 h-4'} />
</If>
</DropdownMenuItem>
);
}),
[setTheme, theme],
);
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className={'flex w-full items-center space-x-2'}>
{resolvedTheme === 'light' ? (
<Sun className={'h-5'} />
) : (
<Moon className={'h-5'} />
)}
<span>
<Trans i18nKey={'common:theme'} />
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>{MenuItems}</DropdownMenuSubContent>
</DropdownMenuSub>
);
}

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator'; import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@kit/ui/utils'; import { cn } from '../utils';
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,

13
pnpm-lock.yaml generated
View File

@@ -328,6 +328,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.363.0 specifier: ^0.363.0
version: 0.363.0(react@18.2.0) version: 0.363.0(react@18.2.0)
next-themes:
specifier: 0.3.0
version: 0.3.0(react-dom@18.2.0)(react@18.2.0)
react-hook-form: react-hook-form:
specifier: ^7.51.2 specifier: ^7.51.2
version: 7.51.2(react@18.2.0) version: 7.51.2(react@18.2.0)
@@ -8843,6 +8846,16 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/next-themes@0.3.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==}
peerDependencies:
react: ^16.8 || ^17 || ^18
react-dom: ^16.8 || ^17 || ^18
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: true
/next@14.1.0(react-dom@18.2.0)(react@18.2.0): /next@14.1.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==}
engines: {node: '>=18.17.0'} engines: {node: '>=18.17.0'}

View File

@@ -735,6 +735,41 @@ insert
has_role_on_account (account_id) has_role_on_account (account_id)
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)); and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions));
-- Functions
-- Function to accept an invitation to an account
create or replace function accept_invitation(token text, user_id uuid) returns void as $$
declare
target_account_id uuid;
target_role public.account_role;
begin
select
account_id,
role
into
target_account_id,
target_role
from
public.invitations
where
invite_token = token;
insert into
public.accounts_memberships(
user_id,
account_id,
account_role)
values
(accept_invitation.user_id, target_account_id, target_role);
delete from
public.invitations
where
invite_token = token;
end;
$$ language plpgsql;
grant execute on function accept_invitation (uuid) to service_role;
/* /*
* ------------------------------------------------------- * -------------------------------------------------------
* Section: Billing Customers * Section: Billing Customers