mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
feat: add team feature flag (#915)
## Description Add the ability to feature flag the teams feature via UI. Also added minor UI changes ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines.
This commit is contained in:
@ -7,6 +7,7 @@ import { useParams } from 'next/navigation';
|
|||||||
|
|
||||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
import { getRootHref } from '@documenso/lib/utils/params';
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
@ -18,6 +19,7 @@ import { CommandMenu } from '../common/command-menu';
|
|||||||
import { DesktopNav } from './desktop-nav';
|
import { DesktopNav } from './desktop-nav';
|
||||||
import { MenuSwitcher } from './menu-switcher';
|
import { MenuSwitcher } from './menu-switcher';
|
||||||
import { MobileNavigation } from './mobile-navigation';
|
import { MobileNavigation } from './mobile-navigation';
|
||||||
|
import { ProfileDropdown } from './profile-dropdown';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
user: User;
|
user: User;
|
||||||
@ -27,6 +29,10 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isTeamsEnabled = getFlag('app_teams');
|
||||||
|
|
||||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
@ -41,6 +47,34 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
return () => window.removeEventListener('scroll', onScroll);
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!isTeamsEnabled) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||||
|
scrollY > 5 && 'border-b-border',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Logo className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<DesktopNav />
|
||||||
|
|
||||||
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
|
<ProfileDropdown user={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -66,11 +100,11 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
|
|
||||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||||
<SearchIcon className="h-6 w-6" />
|
<SearchIcon className="text-muted-foreground h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
||||||
<MenuIcon className="h-6 w-6" />
|
<MenuIcon className="text-muted-foreground h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
return (
|
return (
|
||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||||
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
<Link href="/" onClick={handleMenuItemClick}>
|
||||||
<Image
|
<Image
|
||||||
src={LogoImage}
|
src={LogoImage}
|
||||||
alt="Documenso Logo"
|
alt="Documenso Logo"
|
||||||
|
|||||||
169
apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
Normal file
169
apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
User as LucideUser,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Sun,
|
||||||
|
UserCog,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
export type ProfileDropdownProps = {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
|
const avatarFallback = user.name
|
||||||
|
? extractInitials(user.name)
|
||||||
|
: user.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
title="Profile Dropdown"
|
||||||
|
className="relative h-10 w-10 rounded-full"
|
||||||
|
>
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="z-[60] w-56" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{isUserAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/admin" className="cursor-pointer">
|
||||||
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/profile" className="cursor-pointer">
|
||||||
|
<LucideUser className="mr-2 h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/security" className="cursor-pointer">
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Security
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{isBillingEnabled && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
|
Billing
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/templates" className="cursor-pointer">
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Palette className="mr-2 h-4 w-4" />
|
||||||
|
Themes
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="z-[60]">
|
||||||
|
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||||
|
<DropdownMenuRadioItem value="light">
|
||||||
|
<Sun className="mr-2 h-4 w-4" /> Light
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark">
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
Dark
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system">
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
System
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="https://github.com/documenso/documenso"
|
||||||
|
className="cursor-pointer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<LuGithub className="mr-2 h-4 w-4" />
|
||||||
|
Star on Github
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
void signOut({
|
||||||
|
callbackUrl: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
const isTeamsEnabled = getFlag('app_teams');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
@ -35,18 +36,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/teams">
|
{isTeamsEnabled && (
|
||||||
<Button
|
<Link href="/settings/teams">
|
||||||
variant="ghost"
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
'w-full justify-start',
|
className={cn(
|
||||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
'w-full justify-start',
|
||||||
)}
|
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||||
>
|
)}
|
||||||
<Users className="mr-2 h-5 w-5" />
|
>
|
||||||
Teams
|
<Users className="mr-2 h-5 w-5" />
|
||||||
</Button>
|
Teams
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
const isTeamsEnabled = getFlag('app_teams');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -38,18 +39,20 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/teams">
|
{isTeamsEnabled && (
|
||||||
<Button
|
<Link href="/settings/teams">
|
||||||
variant="ghost"
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
'w-full justify-start',
|
className={cn(
|
||||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
'w-full justify-start',
|
||||||
)}
|
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||||
>
|
)}
|
||||||
<Users className="mr-2 h-5 w-5" />
|
>
|
||||||
Teams
|
<Users className="mr-2 h-5 w-5" />
|
||||||
</Button>
|
Teams
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -158,6 +158,7 @@ export const CreateTeamCheckoutDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={selectedPrice.interval === 'yearly'}
|
||||||
loading={isCreatingCheckout}
|
loading={isCreatingCheckout}
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
createCheckout({
|
createCheckout({
|
||||||
@ -166,7 +167,7 @@ export const CreateTeamCheckoutDialog = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Checkout
|
{selectedPrice.interval === 'monthly' ? 'Checkout' : 'Coming soon'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,13 +30,11 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EnableAuthenticatorAppDialog
|
<EnableAuthenticatorAppDialog
|
||||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
|
||||||
open={isEnableDialogOpen}
|
open={isEnableDialogOpen}
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DisableAuthenticatorAppDialog
|
<DisableAuthenticatorAppDialog
|
||||||
key={isDisableDialogOpen ? 'open' : 'closed'}
|
|
||||||
open={isDisableDialogOpen}
|
open={isDisableDialogOpen}
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
|||||||
*/
|
*/
|
||||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||||
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||||
|
app_teams: true,
|
||||||
marketing_header_single_player_mode: false,
|
marketing_header_single_player_mode: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||||
import { VariantProps, cva } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
@ -12,7 +13,7 @@ const Sheet = SheetPrimitive.Root;
|
|||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger;
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
const portalVariants = cva('fixed inset-0 z-50 flex', {
|
const portalVariants = cva('fixed inset-0 z-[61] flex', {
|
||||||
variants: {
|
variants: {
|
||||||
position: {
|
position: {
|
||||||
top: 'items-start',
|
top: 'items-start',
|
||||||
@ -42,7 +43,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
>(({ className, children: _children, ...props }, ref) => (
|
>(({ className, children: _children, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 backdrop-blur-sm transition-all duration-100',
|
'bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-[61] backdrop-blur-sm transition-all duration-100',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -53,7 +54,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
'fixed z-50 scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
|
'fixed z-[61] scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
position: {
|
position: {
|
||||||
|
|||||||
Reference in New Issue
Block a user