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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AcceptInvitationSchema = z.object({
|
||||||
|
inviteToken: z.string().uuid(),
|
||||||
|
nextPath: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
99
packages/ui/src/makerkit/mode-toggle.tsx
Normal file
99
packages/ui/src/makerkit/mode-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
13
pnpm-lock.yaml
generated
@@ -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'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user