mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
fix: merge conflicts
This commit is contained in:
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
export type MonthlyActiveUsersChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
cummulative?: boolean;
|
||||
data: GetMonthlyActiveUsersResult;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<p>{label}</p>
|
||||
<p className="text-documenso">
|
||||
{payload[0].name === 'cume_count' ? 'Cumulative MAU' : 'Monthly Active Users'}:{' '}
|
||||
<span className="text-black">{Number(payload[0].value).toLocaleString('en-US')}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const MonthlyActiveUsersChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
cummulative = false,
|
||||
}: MonthlyActiveUsersChartProps) => {
|
||||
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
|
||||
count: Number(count),
|
||||
cume_count: Number(cume_count),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} />
|
||||
|
||||
<Bar
|
||||
dataKey={cummulative ? 'cume_count' : 'count'}
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label={cummulative ? 'Cumulative MAU' : 'Monthly Active Users'}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Theme, useTheme } from 'remix-themes';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
@ -20,6 +21,7 @@ import {
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
@ -33,28 +35,7 @@ import {
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const DOCUMENTS_PAGES = [
|
||||
{
|
||||
label: msg`All documents`,
|
||||
path: '/documents?status=ALL',
|
||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
{ label: msg`Draft documents`, path: '/documents?status=DRAFT' },
|
||||
{
|
||||
label: msg`Completed documents`,
|
||||
path: '/documents?status=COMPLETED',
|
||||
},
|
||||
{ label: msg`Pending documents`, path: '/documents?status=PENDING' },
|
||||
{ label: msg`Inbox documents`, path: '/documents?status=INBOX' },
|
||||
];
|
||||
|
||||
const TEMPLATES_PAGES = [
|
||||
{
|
||||
label: msg`All templates`,
|
||||
path: '/templates',
|
||||
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
];
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const SETTINGS_PAGES = [
|
||||
{
|
||||
@ -73,8 +54,10 @@ export type AppCommandMenuProps = {
|
||||
|
||||
export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const { _ } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(() => open ?? false);
|
||||
const [search, setSearch] = useState('');
|
||||
@ -94,6 +77,60 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const teamUrl = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
||||
teamUrl = organisations[0].teams[0]?.url || null;
|
||||
}
|
||||
|
||||
return teamUrl;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
const documentPageLinks = useMemo(() => {
|
||||
if (!teamUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: msg`All documents`,
|
||||
path: `/t/${teamUrl}/documents?status=ALL`,
|
||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
{
|
||||
label: msg`Draft documents`,
|
||||
path: `/t/${teamUrl}/documents?status=DRAFT`,
|
||||
},
|
||||
{
|
||||
label: msg`Completed documents`,
|
||||
path: `/t/${teamUrl}/documents?status=COMPLETED`,
|
||||
},
|
||||
{
|
||||
label: msg`Pending documents`,
|
||||
path: `/t/${teamUrl}/documents?status=PENDING`,
|
||||
},
|
||||
{
|
||||
label: msg`Inbox documents`,
|
||||
path: `/t/${teamUrl}/documents?status=INBOX`,
|
||||
},
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
const templatePageLinks = useMemo(() => {
|
||||
if (!teamUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: msg`All templates`,
|
||||
path: `/t/${teamUrl}/templates`,
|
||||
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
@ -145,8 +182,8 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
};
|
||||
|
||||
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
|
||||
const goToDocuments = useCallback(() => push(documentPageLinks[0].path), [push]);
|
||||
const goToTemplates = useCallback(() => push(templatePageLinks[0].path), [push]);
|
||||
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
|
||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||
@ -197,12 +234,16 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
)}
|
||||
{!currentPage && (
|
||||
<>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||
</CommandGroup>
|
||||
{documentPageLinks.length > 0 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
|
||||
<Commands push={push} pages={documentPageLinks} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
{templatePageLinks.length > 0 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||
<Commands push={push} pages={templatePageLinks} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
import { ReadStatus } from '@prisma/client';
|
||||
import { InboxIcon, MenuIcon, SearchIcon } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
|
||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
|
||||
@ -14,20 +17,28 @@ import { AppCommandMenu } from './app-command-menu';
|
||||
import { AppNavDesktop } from './app-nav-desktop';
|
||||
import { AppNavMobile } from './app-nav-mobile';
|
||||
import { MenuSwitcher } from './menu-switcher';
|
||||
import { OrgMenuSwitcher } from './org-menu-switcher';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
user: SessionUser;
|
||||
teams: TGetTeamsResponse;
|
||||
};
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||
{
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
},
|
||||
{
|
||||
// refetchInterval: 30000, // Refetch every 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setScrollY(window.scrollY);
|
||||
@ -38,16 +49,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const isPathTeamUrl = (teamUrl: string) => {
|
||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname.split('/')[2] === teamUrl;
|
||||
};
|
||||
|
||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@ -59,7 +60,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
>
|
||||
<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
|
||||
to={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
|
||||
to={`${getRootHref(params, { returnEmptyRootString: true })}`}
|
||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||
>
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
@ -67,11 +68,20 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
|
||||
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||
|
||||
<div
|
||||
className="flex gap-x-4 md:ml-8"
|
||||
title={selectedTeam ? selectedTeam.name : (user.name ?? '')}
|
||||
>
|
||||
<MenuSwitcher user={user} teams={teams} />
|
||||
<Button asChild variant="outline" className="relative hidden h-10 w-10 rounded-lg md:flex">
|
||||
<Link to="/inbox" className="relative block h-10 w-10">
|
||||
<InboxIcon className="text-muted-foreground hover:text-foreground h-5 w-5 flex-shrink-0 transition-colors" />
|
||||
|
||||
{unreadCountData && unreadCountData.count > 0 && (
|
||||
<span className="bg-primary text-primary-foreground absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold">
|
||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="md:ml-4">
|
||||
{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||
|
||||
@ -1,26 +1,20 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
href: '/documents',
|
||||
label: msg`Documents`,
|
||||
},
|
||||
{
|
||||
href: '/templates',
|
||||
label: msg`Templates`,
|
||||
},
|
||||
];
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type AppNavDesktopProps = HTMLAttributes<HTMLDivElement> & {
|
||||
setIsCommandMenuOpen: (value: boolean) => void;
|
||||
@ -32,13 +26,13 @@ export const AppNavDesktop = ({
|
||||
...props
|
||||
}: AppNavDesktopProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||
@ -47,6 +41,29 @@ export const AppNavDesktop = ({
|
||||
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
|
||||
}, []);
|
||||
|
||||
const menuNavigationLinks = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
||||
teamUrl = organisations[0].teams[0]?.url || null;
|
||||
}
|
||||
|
||||
if (!teamUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
label: msg`Documents`,
|
||||
},
|
||||
{
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
label: msg`Templates`,
|
||||
},
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -55,23 +72,32 @@ export const AppNavDesktop = ({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-baseline gap-x-6">
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
to={`${rootHref}${href}`}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||
`${rootHref}${href}`,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{_(label)}
|
||||
</Link>
|
||||
))}
|
||||
<div>
|
||||
<AnimatePresence>
|
||||
{menuNavigationLinks.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-baseline gap-x-6"
|
||||
>
|
||||
{menuNavigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
to={href}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{_(label)}
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@ -1,48 +1,84 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { ReadStatus } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type AppNavMobileProps = {
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
};
|
||||
|
||||
export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { t } = useLingui();
|
||||
|
||||
const params = useParams();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
|
||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||
{
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
},
|
||||
{
|
||||
// refetchInterval: 30000, // Refetch every 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
onMenuOpenChange?.(false);
|
||||
};
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
const menuNavigationLinks = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
const menuNavigationLinks = [
|
||||
{
|
||||
href: `${rootHref}/documents`,
|
||||
text: msg`Documents`,
|
||||
},
|
||||
{
|
||||
href: `${rootHref}/templates`,
|
||||
text: msg`Templates`,
|
||||
},
|
||||
{
|
||||
href: '/settings/teams',
|
||||
text: msg`Teams`,
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: msg`Settings`,
|
||||
},
|
||||
];
|
||||
if (!teamUrl && isPersonalLayout(organisations)) {
|
||||
teamUrl = organisations[0].teams[0]?.url || null;
|
||||
}
|
||||
|
||||
if (!teamUrl) {
|
||||
return [
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
text: t`Documents`,
|
||||
},
|
||||
{
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
text: t`Templates`,
|
||||
},
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
},
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
@ -61,11 +97,16 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
|
||||
to={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{_(text)}
|
||||
{text}
|
||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
|
||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
|
||||
@ -1,91 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Building2Icon, PlusIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type Interval = keyof PriceIntervals;
|
||||
|
||||
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||
|
||||
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
|
||||
day: msg`Daily`,
|
||||
week: msg`Weekly`,
|
||||
month: msg`Monthly`,
|
||||
year: msg`Yearly`,
|
||||
};
|
||||
import { ZCreateOrganisationFormSchema } from '../dialogs/organisation-create-dialog';
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export type BillingPlansProps = {
|
||||
prices: PriceIntervals;
|
||||
plans: InternalClaimPlans;
|
||||
};
|
||||
|
||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
||||
|
||||
const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation();
|
||||
const pricesToDisplay = useMemo(() => {
|
||||
const prices = [];
|
||||
|
||||
const onSubscribeClick = async (priceId: string) => {
|
||||
try {
|
||||
setCheckoutSessionPriceId(priceId);
|
||||
|
||||
const url = await createCheckoutSession({ priceId });
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Unable to create session');
|
||||
for (const plan of Object.values(plans)) {
|
||||
if (plan[interval] && plan[interval].isVisibleInApp) {
|
||||
prices.push({
|
||||
...plan[interval],
|
||||
memberCount: plan.memberCount,
|
||||
claim: plan.id,
|
||||
});
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while trying to create a checkout session.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCheckoutSessionPriceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return prices;
|
||||
}, [plans, interval]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||
<Tabs
|
||||
value={interval}
|
||||
onValueChange={(value) => setInterval(value as 'monthlyPrice' | 'yearlyPrice')}
|
||||
>
|
||||
<TabsList>
|
||||
{INTERVALS.map(
|
||||
(interval) =>
|
||||
prices[interval].length > 0 && (
|
||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||
{_(FRIENDLY_INTERVALS[interval])}
|
||||
</TabsTrigger>
|
||||
),
|
||||
)}
|
||||
<TabsTrigger className="min-w-[150px]" value="monthlyPrice">
|
||||
<Trans>Monthly</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="min-w-[150px]" value="yearlyPrice">
|
||||
<Trans>Yearly</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{prices[interval].map((price) => (
|
||||
{pricesToDisplay.map((price) => (
|
||||
<MotionCard
|
||||
key={price.id}
|
||||
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||
@ -96,8 +96,14 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
<CardTitle>{price.product.name}</CardTitle>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||
<span className="text-xs">per {interval}</span>
|
||||
{price.friendlyPrice + ' '}
|
||||
<span className="text-xs">
|
||||
{interval === 'monthlyPrice' ? (
|
||||
<Trans>per month</Trans>
|
||||
) : (
|
||||
<Trans>per year</Trans>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||
@ -120,14 +126,12 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={checkoutSessionPriceId !== null}
|
||||
loading={checkoutSessionPriceId === price.id}
|
||||
onClick={() => void onSubscribeClick(price.id)}
|
||||
>
|
||||
<Trans>Subscribe</Trans>
|
||||
</Button>
|
||||
<BillingDialog
|
||||
priceId={price.id}
|
||||
planName={price.product.name}
|
||||
memberCount={price.memberCount}
|
||||
claim={price.claim}
|
||||
/>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
))}
|
||||
@ -136,3 +140,178 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingDialog = ({
|
||||
priceId,
|
||||
planName,
|
||||
claim,
|
||||
}: {
|
||||
priceId: string;
|
||||
planName: string;
|
||||
memberCount: number;
|
||||
claim: string;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [subscriptionOption, setSubscriptionOption] = useState<'update' | 'create'>(
|
||||
organisation.type === 'PERSONAL' && claim === INTERNAL_CLAIM_ID.INDIVIDUAL
|
||||
? 'update'
|
||||
: 'create',
|
||||
);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
|
||||
trpc.billing.subscription.create.useMutation();
|
||||
|
||||
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
|
||||
trpc.organisation.create.useMutation();
|
||||
|
||||
const isPending = isCreatingSubscription || isCreatingOrganisation;
|
||||
|
||||
const onSubscribeClick = async () => {
|
||||
try {
|
||||
let redirectUrl = '';
|
||||
|
||||
if (subscriptionOption === 'update') {
|
||||
const createSubscriptionResponse = await createSubscription({
|
||||
organisationId: organisation.id,
|
||||
priceId,
|
||||
});
|
||||
|
||||
redirectUrl = createSubscriptionResponse.redirectUrl;
|
||||
} else {
|
||||
const createOrganisationResponse = await createOrganisation({
|
||||
name: form.getValues('name'),
|
||||
priceId,
|
||||
});
|
||||
|
||||
if (!createOrganisationResponse.paymentRequired) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
redirectUrl = createOrganisationResponse.checkoutUrl;
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`An error occurred while trying to create a checkout session.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Trans>Subscribe</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Subscribe</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>You are about to subscribe to the {planName}</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 0 ? (
|
||||
<div>
|
||||
<RadioGroup
|
||||
className="space-y-2"
|
||||
value={subscriptionOption}
|
||||
onValueChange={(value) => setSubscriptionOption(value as 'update' | 'create')}
|
||||
>
|
||||
<div className="flex items-start space-x-3 space-y-0">
|
||||
<RadioGroupItem value="update" id="update" />
|
||||
<div className="space-y-1.5 leading-none">
|
||||
<Label htmlFor="update" className="flex items-center gap-2 font-medium">
|
||||
<Building2Icon className="h-4 w-4" />
|
||||
<Trans>Update current organisation</Trans>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Upgrade <strong>{organisation.name}</strong> to {planName}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 space-y-0">
|
||||
<RadioGroupItem value="create" id="create" />
|
||||
<div className="space-y-1.5 leading-none">
|
||||
<Label htmlFor="create" className="flex items-center gap-2 font-medium">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<Trans>Create separate organisation</Trans>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Create a new organisation with {planName} plan. Keep your current organisation
|
||||
on it's current plan
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Organisation Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button disabled={isPending} variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
{subscriptionOption === 'create' && step === 0 ? (
|
||||
<Button className="mt-4" loading={isPending} onClick={() => setStep(1)}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="mt-4" loading={isPending} onClick={() => void onSubscribeClick()}>
|
||||
<Trans>Checkout</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: createBillingPortal, isPending } =
|
||||
trpc.profile.createBillingPortal.useMutation({
|
||||
onSuccess: (sessionUrl) => {
|
||||
window.open(sessionUrl, '_blank');
|
||||
},
|
||||
onError: (err) => {
|
||||
let description = _(
|
||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||
);
|
||||
|
||||
if (err.message === 'CUSTOMER_NOT_FOUND') {
|
||||
description = _(
|
||||
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button {...buttonProps} onClick={async () => createBillingPortal()} loading={isPending}>
|
||||
{children || <Trans>Manage Subscription</Trans>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -130,7 +130,7 @@ export const DirectTemplateConfigureForm = ({
|
||||
{...field}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
derivedRecipientAccessAuth !== null ||
|
||||
derivedRecipientAccessAuth.length > 0 ||
|
||||
user?.email !== undefined
|
||||
}
|
||||
placeholder="recipient@documenso.com"
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import { ChevronLeftIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -7,6 +10,7 @@ import {
|
||||
type TRecipientActionAuth,
|
||||
type TRecipientActionAuthTypes,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -18,11 +22,12 @@ import {
|
||||
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
|
||||
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
|
||||
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
|
||||
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthDialogProps = {
|
||||
title?: string;
|
||||
documentAuthType: TRecipientActionAuthTypes;
|
||||
availableAuthTypes: TRecipientActionAuthTypes[];
|
||||
description?: string;
|
||||
actionTarget: FieldType | 'DOCUMENT';
|
||||
open: boolean;
|
||||
@ -37,54 +42,158 @@ export type DocumentSigningAuthDialogProps = {
|
||||
export const DocumentSigningAuthDialog = ({
|
||||
title,
|
||||
description,
|
||||
documentAuthType,
|
||||
availableAuthTypes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentSigningAuthDialogProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||
const validAuthTypes = availableAuthTypes.filter(
|
||||
(authType) => authType !== DocumentAuth.EXPLICIT_NONE,
|
||||
);
|
||||
|
||||
const [selectedAuthType, setSelectedAuthType] = useState<TRecipientActionAuthTypes | null>(() => {
|
||||
// Auto-select if there's only one valid option
|
||||
if (validAuthTypes.length === 1) {
|
||||
return validAuthTypes[0];
|
||||
}
|
||||
// Return null if multiple options - show chooser
|
||||
return null;
|
||||
});
|
||||
|
||||
const handleOnOpenChange = (value: boolean) => {
|
||||
if (isCurrentlyAuthenticating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset selected auth type when dialog closes
|
||||
if (!value) {
|
||||
setSelectedAuthType(() => {
|
||||
if (validAuthTypes.length === 1) {
|
||||
return validAuthTypes[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
const handleBackToChooser = () => {
|
||||
setSelectedAuthType(null);
|
||||
};
|
||||
|
||||
// If no valid auth types available, don't render anything
|
||||
if (validAuthTypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{selectedAuthType && validAuthTypes.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBackToChooser}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span>{title || <Trans>Sign field</Trans>}</span>
|
||||
</div>
|
||||
)}
|
||||
{(!selectedAuthType || validAuthTypes.length === 1) &&
|
||||
(title || <Trans>Sign field</Trans>)}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{description || <Trans>Reauthentication is required to sign this field</Trans>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{match({ documentAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
<DocumentSigningAuthPasskey
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuth2FA
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.exhaustive()}
|
||||
{/* Show chooser if no auth type is selected and there are multiple options */}
|
||||
{!selectedAuthType && validAuthTypes.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Choose your preferred authentication method:</Trans>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{validAuthTypes.map((authType) => (
|
||||
<Button
|
||||
key={authType}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start p-4"
|
||||
onClick={() => setSelectedAuthType(authType)}
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{match(authType)
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{match(authType)
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => (
|
||||
<Trans>Use your passkey for authentication</Trans>
|
||||
))
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => (
|
||||
<Trans>Enter your 2FA code</Trans>
|
||||
))
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show the selected auth component */}
|
||||
{selectedAuthType &&
|
||||
match({ documentAuthType: selectedAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
<DocumentSigningAuthPasskey
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuth2FA
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.PASSWORD }, () => (
|
||||
<DocumentSigningAuthPassword
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthPasswordProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const ZPasswordAuthFormSchema = z.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: 'Password is required' })
|
||||
.max(72, { message: 'Password must be at most 72 characters long' }),
|
||||
});
|
||||
|
||||
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthPassword = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthPasswordProps) => {
|
||||
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const form = useForm<TPasswordAuthFormSchema>({
|
||||
resolver: zodResolver(ZPasswordAuthFormSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
|
||||
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.PASSWORD,
|
||||
password,
|
||||
});
|
||||
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
password: '',
|
||||
});
|
||||
|
||||
setFormErrorCode(null);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Unauthorized</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -1,7 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
@ -33,8 +32,8 @@ export type DocumentSigningAuthContextValue = {
|
||||
recipient: Recipient;
|
||||
recipientAuthOption: TRecipientAuthOptions;
|
||||
setRecipient: (_value: Recipient) => void;
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||
isAuthRedirectRequired: boolean;
|
||||
isCurrentlyAuthenticating: boolean;
|
||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||
@ -100,7 +99,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
|
||||
enabled: derivedRecipientActionAuth?.includes(DocumentAuth.PASSKEY) ?? false,
|
||||
},
|
||||
);
|
||||
|
||||
@ -121,21 +120,28 @@ export const DocumentSigningAuthProvider = ({
|
||||
* Will be `null` if the user still requires authentication, or if they don't need
|
||||
* authentication.
|
||||
*/
|
||||
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
|
||||
.with(DocumentAuth.ACCOUNT, () => {
|
||||
if (recipient.email !== user?.email) {
|
||||
return null;
|
||||
}
|
||||
const preCalculatedActionAuthOptions = useMemo(() => {
|
||||
if (
|
||||
!derivedRecipientActionAuth ||
|
||||
derivedRecipientActionAuth.length === 0 ||
|
||||
derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE)
|
||||
) {
|
||||
return {
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
|
||||
user?.email == recipient.email
|
||||
) {
|
||||
return {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
};
|
||||
})
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
}))
|
||||
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [derivedRecipientActionAuth, user, recipient]);
|
||||
|
||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||
// Directly run callback if no auth required.
|
||||
@ -170,7 +176,8 @@ export const DocumentSigningAuthProvider = ({
|
||||
// Assume that a user must be logged in for any auth requirements.
|
||||
const isAuthRedirectRequired = Boolean(
|
||||
derivedRecipientActionAuth &&
|
||||
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
|
||||
derivedRecipientActionAuth.length > 0 &&
|
||||
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
|
||||
user?.email !== recipient.email,
|
||||
);
|
||||
|
||||
@ -208,7 +215,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
onOpenChange={() => setDocumentAuthDialogPayload(null)}
|
||||
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
|
||||
actionTarget={documentAuthDialogPayload.actionTarget}
|
||||
documentAuthType={derivedRecipientActionAuth}
|
||||
availableAuthTypes={derivedRecipientActionAuth}
|
||||
/>
|
||||
)}
|
||||
</DocumentSigningAuthContext.Provider>
|
||||
@ -217,7 +224,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
|
||||
type ExecuteActionAuthProcedureOptions = Omit<
|
||||
DocumentSigningAuthDialogProps,
|
||||
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
|
||||
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
|
||||
>;
|
||||
|
||||
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';
|
||||
|
||||
@ -44,6 +44,7 @@ const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
|
||||
// other field types.
|
||||
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.PASSWORD,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
];
|
||||
|
||||
@ -96,8 +97,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
return true;
|
||||
});
|
||||
|
||||
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
|
||||
derivedRecipientActionAuth ?? '',
|
||||
const actionAuthAllowsAutoSign = derivedRecipientActionAuth.every(
|
||||
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
|
||||
);
|
||||
|
||||
const onSubmit = async () => {
|
||||
@ -110,16 +111,16 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
.with(FieldType.DATE, () => new Date().toISOString())
|
||||
.otherwise(() => '');
|
||||
|
||||
const authOptions = match(derivedRecipientActionAuth)
|
||||
const authOptions = match(derivedRecipientActionAuth.at(0))
|
||||
.with(DocumentAuth.ACCOUNT, () => ({
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
}))
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
}))
|
||||
.with(null, () => undefined)
|
||||
.with(undefined, () => undefined)
|
||||
.with(
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
|
||||
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||
() => 'NOT_SUPPORTED' as const,
|
||||
)
|
||||
|
||||
@ -92,8 +92,6 @@ export const DocumentSigningCompleteDialog = ({
|
||||
};
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
console.log('data', data);
|
||||
console.log('form.formState.errors', form.formState.errors);
|
||||
try {
|
||||
if (allowDictateNextSigner && data.name && data.email) {
|
||||
await onSignatureComplete({ name: data.name, email: data.email });
|
||||
|
||||
@ -47,6 +47,7 @@ export type DocumentSigningPageViewProps = {
|
||||
completedFields: CompletedField[];
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
includeSenderDetails: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningPageView = ({
|
||||
@ -56,18 +57,16 @@ export const DocumentSigningPageView = ({
|
||||
completedFields,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
includeSenderDetails,
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
|
||||
const shouldUseTeamDetails =
|
||||
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
|
||||
|
||||
let senderName = document.user.name ?? '';
|
||||
let senderEmail = `(${document.user.email})`;
|
||||
|
||||
if (shouldUseTeamDetails) {
|
||||
if (includeSenderDetails) {
|
||||
senderName = document.team?.name ?? '';
|
||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
||||
}
|
||||
@ -92,7 +91,7 @@ export const DocumentSigningPageView = ({
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
@ -101,7 +100,7 @@ export const DocumentSigningPageView = ({
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
@ -110,7 +109,7 @@ export const DocumentSigningPageView = ({
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
@ -119,7 +118,7 @@ export const DocumentSigningPageView = ({
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.ASSISTANT, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||
</Trans>
|
||||
|
||||
@ -10,6 +10,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@ -21,7 +22,7 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export interface DocumentDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
@ -34,10 +35,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@ -53,7 +55,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
||||
await navigate('/settings/billing');
|
||||
await navigate(`/o/${organisation.url}/settings/billing`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -83,11 +85,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await navigate(
|
||||
folderId
|
||||
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||
);
|
||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -156,7 +154,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
|
||||
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
to={`/o/${organisation.url}/settings/billing`}
|
||||
className="mt-4 text-sm text-amber-500 hover:underline dark:text-amber-400"
|
||||
>
|
||||
<Trans>Upgrade your plan to upload more documents</Trans>
|
||||
|
||||
@ -29,13 +29,12 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentEditFormProps = {
|
||||
className?: string;
|
||||
initialDocument: TDocument;
|
||||
documentRootPath: string;
|
||||
isDocumentEnterprise: boolean;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
||||
@ -45,7 +44,6 @@ export const DocumentEditForm = ({
|
||||
className,
|
||||
initialDocument,
|
||||
documentRootPath,
|
||||
isDocumentEnterprise,
|
||||
}: DocumentEditFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
@ -53,7 +51,7 @@ export const DocumentEditForm = ({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const team = useOptionalCurrentTeam();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
@ -183,8 +181,8 @@ export const DocumentEditForm = ({
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
globalAccessAuth: data.globalAccessAuth ?? [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
timezone,
|
||||
@ -229,7 +227,7 @@ export const DocumentEditForm = ({
|
||||
recipients: data.signers.map((signer) => ({
|
||||
...signer,
|
||||
// Explicitly set to null to indicate we want to remove auth if required.
|
||||
actionAuth: signer.actionAuth || null,
|
||||
actionAuth: signer.actionAuth ?? [],
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
@ -355,10 +353,9 @@ export const DocumentEditForm = ({
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.settings}
|
||||
document={document}
|
||||
currentTeamMemberRole={team?.currentTeamMember?.role}
|
||||
currentTeamMemberRole={team.currentTeamRole}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
/>
|
||||
@ -370,7 +367,6 @@ export const DocumentEditForm = ({
|
||||
signingOrder={document.documentMeta?.signingOrder}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
fields={fields}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
@ -382,7 +378,7 @@ export const DocumentEditForm = ({
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
teamId={team?.id}
|
||||
teamId={team.id}
|
||||
/>
|
||||
|
||||
<AddSubjectFormPartial
|
||||
|
||||
@ -81,11 +81,15 @@ export const DocumentHistorySheet = ({
|
||||
* @param text The text to format
|
||||
* @returns The formatted text
|
||||
*/
|
||||
const formatGenericText = (text?: string | null) => {
|
||||
const formatGenericText = (text?: string | string[] | null): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (Array.isArray(text)) {
|
||||
return text.map((t) => formatGenericText(t)).join(', ');
|
||||
}
|
||||
|
||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||
};
|
||||
|
||||
@ -245,11 +249,19 @@ export const DocumentHistorySheet = ({
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
|
||||
value: Array.isArray(data.from)
|
||||
? data.from
|
||||
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
|
||||
.join(', ')
|
||||
: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
|
||||
value: Array.isArray(data.to)
|
||||
? data.to
|
||||
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
|
||||
.join(', ')
|
||||
: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -19,7 +19,7 @@ export type DocumentPageViewButtonProps = {
|
||||
document: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
team: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
};
|
||||
|
||||
@ -37,10 +37,8 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
|
||||
const documentsPath = formatDocumentsPath(document.team?.url);
|
||||
const formatPath = document.folderId
|
||||
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
||||
: `${documentsPath}/${document.id}/edit`;
|
||||
const documentsPath = formatDocumentsPath(document.team.url);
|
||||
const formatPath = `${documentsPath}/${document.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
|
||||
@ -36,7 +36,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentPageViewDropdownProps = {
|
||||
document: Document & {
|
||||
@ -52,7 +52,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
const { _ } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useOptionalCurrentTeam();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
@ -67,7 +67,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
|
||||
@ -30,8 +30,12 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSearch(searchTerm);
|
||||
}, [debouncedSearchTerm]);
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@ -3,12 +3,12 @@ import { useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@ -26,7 +26,7 @@ import {
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentUploadDropzoneProps = {
|
||||
className?: string;
|
||||
@ -38,10 +38,11 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const userTimezone =
|
||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||
@ -54,10 +55,12 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (organisation.subscription && remaining.documents === 0) {
|
||||
return msg`Document upload disabled due to unpaid invoices`;
|
||||
}
|
||||
|
||||
if (remaining.documents === 0) {
|
||||
return team
|
||||
? msg`Document upload disabled due to unpaid invoices`
|
||||
: msg`You have reached your document limit.`;
|
||||
return msg`You have reached your document limit.`;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
@ -81,6 +84,8 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
|
||||
void refreshLimits();
|
||||
|
||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
@ -92,12 +97,6 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await navigate(
|
||||
folderId
|
||||
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||
);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -138,6 +137,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DocumentDropzone
|
||||
loading={isLoading}
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
@ -145,6 +145,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
@ -158,12 +159,6 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,19 +1,32 @@
|
||||
import { FolderIcon, PinIcon } from 'lucide-react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
FolderIcon,
|
||||
FolderPlusIcon,
|
||||
MoreVerticalIcon,
|
||||
PinIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onNavigate: (folderId: string) => void;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
@ -23,66 +36,132 @@ export type FolderCardProps = {
|
||||
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onNavigate,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const formatPath = () => {
|
||||
const rootPath =
|
||||
folder.type === FolderType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
return `${rootPath}/f/${folder.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
className="flex items-center space-x-2 text-left"
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
>
|
||||
<FolderIcon className="text-documenso h-6 w-6" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{folder.name}</h3>
|
||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
|
||||
</div>
|
||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||
<span>
|
||||
{formatFolderCount(
|
||||
folder.type === FolderType.TEMPLATE
|
||||
? folder._count.templates
|
||||
: folder._count.documents,
|
||||
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
|
||||
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
|
||||
<Link to={formatPath()} key={folder.id}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<FolderIcon className="text-documenso h-6 w-6 flex-shrink-0" />
|
||||
|
||||
<div className="flex w-full min-w-0 items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3 flex-shrink-0" />}
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-1 flex space-x-2 truncate text-xs">
|
||||
<span>
|
||||
{folder.type === FolderType.TEMPLATE ? (
|
||||
<Plural
|
||||
value={folder._count.templates}
|
||||
one={<Trans># template</Trans>}
|
||||
other={<Trans># templates</Trans>}
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={folder._count.documents}
|
||||
one={<Trans># document</Trans>}
|
||||
other={<Trans># documents</Trans>}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
<Plural
|
||||
value={folder._count.subfolders}
|
||||
one={<Trans># folder</Trans>}
|
||||
other={<Trans># folders</Trans>}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="folder-card-more-button"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent onClick={(e) => e.stopPropagation()} align="end">
|
||||
<DropdownMenuItem onClick={() => onMove(folder)}>
|
||||
<ArrowRightIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
||||
>
|
||||
<PinIcon className="mr-2 h-4 w-4" />
|
||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => onSettings(folder)}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Settings</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={() => onDelete(folder)}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
|
||||
{folder.pinned ? (
|
||||
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const FolderCardEmpty = ({ type }: { type: FolderType }) => {
|
||||
return (
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderPlusIcon className="text-muted-foreground/60 h-6 w-6" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-medium">
|
||||
<Trans>Create folder</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground/60 mt-1 flex space-x-2 truncate text-xs">
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<Trans>Organise your documents</Trans>
|
||||
) : (
|
||||
<Trans>Organise your templates</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
249
apps/remix/app/components/general/folder/folder-grid.tsx
Normal file
249
apps/remix/app/components/general/folder/folder-grid.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
|
||||
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderGridProps = {
|
||||
type: FolderType;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
||||
type,
|
||||
parentId,
|
||||
});
|
||||
|
||||
const formatBreadCrumbPath = (folderId: string) => {
|
||||
const rootPath =
|
||||
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
return `${rootPath}/f/${folderId}`;
|
||||
};
|
||||
|
||||
const formatViewAllFoldersPath = () => {
|
||||
const rootPath =
|
||||
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
return `${rootPath}/folders`;
|
||||
};
|
||||
|
||||
const formatRootPath = () => {
|
||||
return type === FolderType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
};
|
||||
|
||||
const pinnedFolders = foldersData?.folders.filter((folder) => folder.pinned) || [];
|
||||
const unpinnedFolders = foldersData?.folders.filter((folder) => !folder.pinned) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
|
||||
data-testid="folder-grid-breadcrumbs"
|
||||
>
|
||||
<Link to={formatRootPath()} className="flex items-center">
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home</Trans>
|
||||
</Link>
|
||||
|
||||
{isPending && parentId ? (
|
||||
<div className="flex items-center">
|
||||
<Skeleton className="mx-3 h-4 w-1 rotate-12" />
|
||||
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
) : (
|
||||
foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center">
|
||||
<span className="px-3">/</span>
|
||||
<Link to={formatBreadCrumbPath(folder.id)} className="flex items-center">
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<DocumentUploadDropzone />
|
||||
) : (
|
||||
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
||||
)}
|
||||
|
||||
<FolderCreateDialog type={type} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Skeleton className="mb-2 h-4 w-24" />
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : foldersData && foldersData.folders.length === 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<FolderCreateDialog
|
||||
type={type}
|
||||
trigger={
|
||||
<button>
|
||||
<FolderCardEmpty type={type} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
foldersData && (
|
||||
<div key="content" className="space-y-4">
|
||||
{pinnedFolders.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{pinnedFolders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unpinnedFolders.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{unpinnedFolders.slice(0, 12).map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{foldersData.folders.length > 12 && (
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium"
|
||||
to={formatViewAllFoldersPath()}
|
||||
>
|
||||
View all folders
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{folderToDelete && (
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -7,11 +7,8 @@ import { ChevronLeft } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
type ErrorCodeMap = Record<
|
||||
number,
|
||||
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
|
||||
@ -22,7 +19,7 @@ export type GenericErrorLayoutProps = {
|
||||
errorCode?: number;
|
||||
errorCodeMap?: ErrorCodeMap;
|
||||
/**
|
||||
* The primary button to display. If left as undefined, the default /documents link will be displayed.
|
||||
* The primary button to display. If left as undefined, the default home link will be displayed.
|
||||
*
|
||||
* Set to null if you want no button.
|
||||
*/
|
||||
@ -59,8 +56,6 @@ export const GenericErrorLayout = ({
|
||||
const navigate = useNavigate();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { subHeading, heading, message } =
|
||||
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
|
||||
|
||||
@ -110,8 +105,8 @@ export const GenericErrorLayout = ({
|
||||
{primaryButton ||
|
||||
(primaryButton !== null && (
|
||||
<Button asChild>
|
||||
<Link to={formatDocumentsPath(team?.url)}>
|
||||
<Trans>Documents</Trans>
|
||||
<Link to={'/'}>
|
||||
<Trans>Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@ -3,19 +3,14 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { ChevronsUpDown, Plus } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@ -24,79 +19,27 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
const MotionLink = motion(Link);
|
||||
|
||||
export type MenuSwitcherProps = {
|
||||
user: SessionUser;
|
||||
teams: TGetTeamsResponse;
|
||||
};
|
||||
|
||||
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
|
||||
export const MenuSwitcher = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { user } = useSession();
|
||||
|
||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
||||
initialData: initialTeamsData,
|
||||
});
|
||||
|
||||
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
|
||||
|
||||
const isPathTeamUrl = (teamUrl: string) => {
|
||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname.split('/')[2] === teamUrl;
|
||||
};
|
||||
|
||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
||||
|
||||
const formatAvatarFallback = (teamName?: string) => {
|
||||
if (teamName !== undefined) {
|
||||
return teamName.slice(0, 1).toUpperCase();
|
||||
const formatAvatarFallback = (name?: string) => {
|
||||
if (name !== undefined) {
|
||||
return name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
|
||||
if (!team) {
|
||||
return _(msg`Personal Account`);
|
||||
}
|
||||
|
||||
if (team.ownerUserId === user.id) {
|
||||
return _(msg`Owner`);
|
||||
}
|
||||
|
||||
return _(TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the redirect URL so we can switch between documents and templates page
|
||||
* seemlessly between teams and personal accounts.
|
||||
*/
|
||||
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
||||
const baseUrl = teamUrl ? `/t/${teamUrl}` : '';
|
||||
|
||||
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
||||
|
||||
if (currentPathname === '/templates') {
|
||||
return `${baseUrl}/templates`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@ -106,12 +49,10 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(
|
||||
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId,
|
||||
)}
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
primaryText={selectedTeam ? selectedTeam.name : user.name}
|
||||
secondaryText={formatSecondaryAvatarText(selectedTeam)}
|
||||
avatarSrc={formatAvatarUrl(user.avatarImageId)}
|
||||
avatarFallback={formatAvatarFallback(user.name || user.email)}
|
||||
primaryText={user.name}
|
||||
secondaryText={_(msg`Personal Account`)}
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
@ -121,128 +62,19 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
{teams ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Personal</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={formatRedirectUrlOnSwitch()}>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(user.avatarImageId)}
|
||||
avatarFallback={formatAvatarFallback()}
|
||||
primaryText={user.name}
|
||||
secondaryText={formatSecondaryAvatarText()}
|
||||
rightSideComponent={
|
||||
!pathname?.startsWith(`/t/`) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="mt-2" />
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p>
|
||||
<Trans>Teams</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row space-x-2">
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
title={_(msg`Manage teams`)}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
||||
asChild
|
||||
>
|
||||
<Link to="/settings/teams">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
title={_(msg`Create team`)}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
||||
asChild
|
||||
>
|
||||
<Link to="/settings/teams?action=add-team">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<MotionLink
|
||||
initial="initial"
|
||||
animate="initial"
|
||||
whileHover="animate"
|
||||
to={formatRedirectUrlOnSwitch(team.url)}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
textSectionClassName="w-[200px]"
|
||||
secondaryText={
|
||||
<div className="relative w-full">
|
||||
<motion.span
|
||||
className="overflow-hidden"
|
||||
variants={{
|
||||
initial: { opacity: 1, translateY: 0 },
|
||||
animate: { opacity: 0, translateY: '100%' },
|
||||
}}
|
||||
>
|
||||
{formatSecondaryAvatarText(team)}
|
||||
</motion.span>
|
||||
|
||||
<motion.span
|
||||
className="absolute inset-0"
|
||||
variants={{
|
||||
initial: { opacity: 0, translateY: '100%' },
|
||||
animate: { opacity: 1, translateY: 0 },
|
||||
}}
|
||||
>{`/t/${team.url}`}</motion.span>
|
||||
</div>
|
||||
}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</MotionLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
to="/settings/teams?action=add-team"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<Trans>Create team</Trans>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
to="/settings/organisations?action=add-organisation"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<Trans>Create Organisation</Trans>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isUserAdmin && (
|
||||
@ -253,21 +85,18 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/inbox">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>User settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{selectedTeam &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${selectedTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground px-4 py-2"
|
||||
onClick={() => setLanguageSwitcherOpen(true)}
|
||||
|
||||
338
apps/remix/app/components/general/org-menu-switcher.tsx
Normal file
338
apps/remix/app/components/general/org-menu-switcher.tsx
Normal file
@ -0,0 +1,338 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
Building2Icon,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const OrgMenuSwitcher = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { user, organisations } = useSession();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
||||
const [hoveredOrgId, setHoveredOrgId] = useState<string | null>(null);
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isPathOrgUrl = (orgUrl: string) => {
|
||||
if (!pathname || !pathname.startsWith(`/o/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname.split('/')[2] === orgUrl;
|
||||
};
|
||||
|
||||
const selectedOrg = organisations.find((org) => isPathOrgUrl(org.url));
|
||||
const hoveredOrg = organisations.find(
|
||||
(org) => org.id === hoveredOrgId || organisations.length === 1,
|
||||
);
|
||||
|
||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
|
||||
// Use hovered org for teams display if available,
|
||||
// otherwise use current team's org if in a team,
|
||||
// finally fallback to selected org
|
||||
const displayedOrg = hoveredOrg || currentOrganisation || selectedOrg;
|
||||
|
||||
const formatAvatarFallback = (name?: string) => {
|
||||
if (name !== undefined) {
|
||||
return name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
const dropdownMenuAvatarText = useMemo(() => {
|
||||
if (currentTeam) {
|
||||
return {
|
||||
avatarSrc: formatAvatarUrl(currentTeam.avatarImageId),
|
||||
avatarFallback: formatAvatarFallback(currentTeam.name),
|
||||
primaryText: currentTeam.name,
|
||||
secondaryText: _(EXTENDED_TEAM_MEMBER_ROLE_MAP[currentTeam.currentTeamRole]),
|
||||
};
|
||||
}
|
||||
|
||||
if (currentOrganisation) {
|
||||
return {
|
||||
avatarSrc: formatAvatarUrl(currentOrganisation.avatarImageId),
|
||||
avatarFallback: formatAvatarFallback(currentOrganisation.name),
|
||||
primaryText: currentOrganisation.name,
|
||||
secondaryText: _(
|
||||
EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[currentOrganisation.currentOrganisationRole],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
avatarSrc: formatAvatarUrl(user.avatarImageId),
|
||||
avatarFallback: formatAvatarFallback(user.name ?? user.email),
|
||||
primaryText: user.name,
|
||||
secondaryText: _(msg`Personal Account`),
|
||||
};
|
||||
}, [currentTeam, currentOrganisation, user]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setHoveredOrgId(currentOrganisation?.id || null);
|
||||
}
|
||||
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={dropdownMenuAvatarText.avatarSrc}
|
||||
avatarFallback={dropdownMenuAvatarText.avatarFallback}
|
||||
primaryText={dropdownMenuAvatarText.primaryText}
|
||||
secondaryText={dropdownMenuAvatarText.secondaryText}
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn(
|
||||
'divide-border z-[60] ml-6 flex w-full divide-x p-0 md:ml-0 md:min-w-[40rem]',
|
||||
)}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
<div className="flex h-[400px] w-full divide-x">
|
||||
{/* Organisations column */}
|
||||
<div className="flex w-full flex-col md:w-1/3">
|
||||
<div className="flex h-12 items-center border-b p-2">
|
||||
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
||||
<Building2Icon className="mr-2 h-3.5 w-3.5" />
|
||||
<Trans>Organisations</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
|
||||
{organisations.map((org) => (
|
||||
<div
|
||||
className="group relative"
|
||||
key={org.id}
|
||||
onMouseEnter={() => setHoveredOrgId(org.id)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'text-muted-foreground w-full px-4 py-2',
|
||||
org.id === currentOrganisation?.id && !hoveredOrgId && 'bg-accent',
|
||||
org.id === hoveredOrgId && 'bg-accent',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to={`/o/${org.url}`} className="flex items-center space-x-2 pr-8">
|
||||
<span
|
||||
className={cn('min-w-0 flex-1 truncate', {
|
||||
'font-semibold': org.id === selectedOrg?.id,
|
||||
})}
|
||||
>
|
||||
{org.name}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
org.currentOrganisationRole,
|
||||
) && (
|
||||
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
||||
<Link
|
||||
to={`/o/${org.url}/settings`}
|
||||
className="text-muted-foreground mr-2 rounded-sm border p-1 transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
|
||||
>
|
||||
<Settings2Icon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to="/settings/organisations?action=add-organisation">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Trans>Create Organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams column */}
|
||||
<div className="hidden w-1/3 flex-col md:flex">
|
||||
<div className="flex h-12 items-center border-b p-2">
|
||||
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
||||
<UsersIcon className="mr-2 h-3.5 w-3.5" />
|
||||
<Trans>Teams</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
|
||||
<AnimateGenericFadeInOut key={displayedOrg ? 'displayed-org' : 'no-org'}>
|
||||
{hoveredOrg ? (
|
||||
hoveredOrg.teams.map((team) => (
|
||||
<div className="group relative" key={team.id}>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'text-muted-foreground w-full px-4 py-2',
|
||||
team.id === currentTeam?.id && 'bg-accent',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to={`/t/${team.url}`} className="flex items-center space-x-2 pr-8">
|
||||
<span
|
||||
className={cn('min-w-0 flex-1 truncate', {
|
||||
'font-semibold': team.id === currentTeam?.id,
|
||||
})}
|
||||
>
|
||||
{team.name}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
||||
<Link
|
||||
to={`/t/${team.url}/settings`}
|
||||
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
>
|
||||
<Settings2Icon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground my-12 flex items-center justify-center px-2 text-center text-sm">
|
||||
<Trans>Select an organisation to view teams</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedOrg && (
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to={`/o/${displayedOrg.url}/settings/teams?action=add-team`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Trans>Create Team</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings column */}
|
||||
<div className="hidden w-1/3 flex-col md:flex">
|
||||
<div className="flex h-12 items-center border-b p-2">
|
||||
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
||||
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
||||
<Trans>Settings</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{isUserAdmin && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/admin">
|
||||
<Trans>Admin panel</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/inbox">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>Account</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{currentOrganisation &&
|
||||
canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
currentOrganisation.currentOrganisationRole,
|
||||
) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/o/${currentOrganisation.url}/settings`}>
|
||||
<Trans>Organisation settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground px-4 py-2"
|
||||
onClick={() => setLanguageSwitcherOpen(true)}
|
||||
>
|
||||
<Trans>Language</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
|
||||
onSelect={async () => authClient.signOut()}
|
||||
>
|
||||
<Trans>Sign Out</Trans>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -3,12 +3,12 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamMemberRole } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -21,30 +21,22 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamLayoutBillingBannerProps = {
|
||||
subscriptionStatus: SubscriptionStatus;
|
||||
teamId: number;
|
||||
userRole: TeamMemberRole;
|
||||
};
|
||||
|
||||
export const TeamLayoutBillingBanner = ({
|
||||
subscriptionStatus,
|
||||
teamId,
|
||||
userRole,
|
||||
}: TeamLayoutBillingBannerProps) => {
|
||||
export const OrganisationBillingBanner = () => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createBillingPortal, isPending } =
|
||||
trpc.team.createBillingPortal.useMutation();
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const handleCreatePortal = async () => {
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.billing.subscription.manage.useMutation();
|
||||
|
||||
const handleCreatePortal = async (organisationId: string) => {
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal({ teamId });
|
||||
const { redirectUrl } = await manageSubscription({ organisationId });
|
||||
|
||||
window.open(sessionUrl, '_blank');
|
||||
window.open(redirectUrl, '_blank');
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
@ -59,7 +51,13 @@ export const TeamLayoutBillingBanner = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (subscriptionStatus === SubscriptionStatus.ACTIVE) {
|
||||
const subscriptionStatus = organisation?.subscription?.status;
|
||||
|
||||
if (
|
||||
!organisation ||
|
||||
subscriptionStatus === undefined ||
|
||||
subscriptionStatus === SubscriptionStatus.ACTIVE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -79,14 +77,14 @@ export const TeamLayoutBillingBanner = ({
|
||||
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
|
||||
.with(SubscriptionStatus.INACTIVE, () => <Trans>Teams restricted</Trans>)
|
||||
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
className={cn({
|
||||
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
|
||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
||||
subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
||||
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
|
||||
subscriptionStatus === SubscriptionStatus.INACTIVE,
|
||||
@ -125,9 +123,9 @@ export const TeamLayoutBillingBanner = ({
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<DialogFooter>
|
||||
<Button loading={isPending} onClick={handleCreatePortal}>
|
||||
<Button loading={isPending} onClick={async () => handleCreatePortal(organisation.id)}>
|
||||
<Trans>Resolve payment</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@ -0,0 +1,58 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationBillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
};
|
||||
|
||||
export const OrganisationBillingPortalButton = ({
|
||||
buttonProps,
|
||||
}: OrganisationBillingPortalButtonProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.billing.subscription.manage.useMutation();
|
||||
|
||||
const canManageBilling = canExecuteOrganisationAction(
|
||||
'MANAGE_BILLING',
|
||||
organisation.currentOrganisationRole,
|
||||
);
|
||||
|
||||
const handleCreatePortal = async () => {
|
||||
try {
|
||||
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
|
||||
|
||||
window.open(redirectUrl, '_blank');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={async () => handleCreatePortal()}
|
||||
loading={isPending}
|
||||
disabled={!canManageBilling}
|
||||
>
|
||||
<Trans>Manage billing</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,12 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
@ -21,14 +22,16 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const TeamInvitations = () => {
|
||||
const { data, isLoading } = trpc.team.getTeamInvitations.useQuery();
|
||||
export const OrganisationInvitations = ({ className }: { className?: string }) => {
|
||||
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{data && data.length > 0 && !isLoading && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert variant="secondary">
|
||||
<Alert variant="secondary" className={className}>
|
||||
<div className="flex h-full flex-row items-center p-2">
|
||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||
|
||||
@ -37,12 +40,12 @@ export const TeamInvitations = () => {
|
||||
value={data.length}
|
||||
one={
|
||||
<span>
|
||||
You have <strong>1</strong> pending team invitation
|
||||
You have <strong>1</strong> pending invitation
|
||||
</span>
|
||||
}
|
||||
other={
|
||||
<span>
|
||||
You have <strong>#</strong> pending team invitations
|
||||
You have <strong>#</strong> pending invitations
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@ -66,12 +69,12 @@ export const TeamInvitations = () => {
|
||||
value={data.length}
|
||||
one={
|
||||
<span>
|
||||
You have <strong>1</strong> pending team invitation
|
||||
You have <strong>1</strong> pending invitation
|
||||
</span>
|
||||
}
|
||||
other={
|
||||
<span>
|
||||
You have <strong>#</strong> pending team invitations
|
||||
You have <strong>#</strong> pending invitations
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@ -80,24 +83,26 @@ export const TeamInvitations = () => {
|
||||
|
||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
||||
{data.map((invitation) => (
|
||||
<li key={invitation.teamId}>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(invitation.team.avatarImageId)}
|
||||
className="w-full max-w-none py-4"
|
||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">
|
||||
{invitation.team.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={formatTeamUrl(invitation.team.url)}
|
||||
rightSideComponent={
|
||||
<div className="ml-auto space-x-2">
|
||||
<DeclineTeamInvitationButton teamId={invitation.team.id} />
|
||||
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<li key={invitation.id}>
|
||||
<Alert variant="neutral" className="p-0 px-4">
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
|
||||
className="w-full max-w-none py-4"
|
||||
avatarFallback={invitation.organisation.name.slice(0, 1)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">
|
||||
{invitation.organisation.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={`/o/${invitation.organisation.url}`}
|
||||
rightSideComponent={
|
||||
<div className="ml-auto space-x-2">
|
||||
<DeclineOrganisationInvitationButton token={invitation.token} />
|
||||
<AcceptOrganisationInvitationButton token={invitation.token} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Alert>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -111,26 +116,29 @@ export const TeamInvitations = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
||||
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const {
|
||||
mutateAsync: acceptTeamInvitation,
|
||||
mutateAsync: acceptOrganisationInvitation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
} = trpc.organisation.member.invite.accept.useMutation({
|
||||
onSuccess: async () => {
|
||||
await refreshSession();
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Accepted team invitation`),
|
||||
description: _(msg`Invitation accepted`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Unable to join this team at this time.`),
|
||||
description: _(msg`Unable to join this organisation at this time.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
@ -139,7 +147,7 @@ const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => acceptTeamInvitation({ teamId })}
|
||||
onClick={async () => acceptOrganisationInvitation({ token })}
|
||||
loading={isPending}
|
||||
disabled={isPending || isSuccess}
|
||||
>
|
||||
@ -148,26 +156,29 @@ const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
||||
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const {
|
||||
mutateAsync: declineTeamInvitation,
|
||||
mutateAsync: declineOrganisationInvitation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.team.declineTeamInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
} = trpc.organisation.member.invite.decline.useMutation({
|
||||
onSuccess: async () => {
|
||||
await refreshSession();
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Declined team invitation`),
|
||||
description: _(msg`Invitation declined`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Unable to decline this team invitation at this time.`),
|
||||
description: _(msg`Unable to decline this invitation at this time.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
@ -176,7 +187,7 @@ const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => declineTeamInvitation({ teamId })}
|
||||
onClick={async () => declineOrganisationInvitation({ token })}
|
||||
loading={isPending}
|
||||
disabled={isPending || isSuccess}
|
||||
variant="ghost"
|
||||
@ -1,11 +1,22 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import {
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
Lock,
|
||||
Settings2Icon,
|
||||
User,
|
||||
Users,
|
||||
WebhookIcon,
|
||||
} from 'lucide-react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -14,7 +25,9 @@ export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
@ -31,29 +44,87 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/preferences">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/teams">
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<BracesIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<WebhookIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link to="/settings/organisations">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Teams</Trans>
|
||||
<Trans>Organisations</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@ -69,47 +140,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
<Trans>Security</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import {
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
Lock,
|
||||
Settings2Icon,
|
||||
User,
|
||||
Users,
|
||||
WebhookIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -13,7 +24,9 @@ export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -33,29 +46,87 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/preferences">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/teams">
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<BracesIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<WebhookIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link to="/settings/organisations">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Teams</Trans>
|
||||
<Trans>Organisations</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@ -71,47 +142,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
<Trans>Security</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
export default function DocumentEditSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<Link to="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||
<Link to="/" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamBillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: createBillingPortal, isPending } =
|
||||
trpc.team.createBillingPortal.useMutation();
|
||||
|
||||
const handleCreatePortal = async () => {
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal({ teamId });
|
||||
|
||||
window.open(sessionUrl, '_blank');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button {...buttonProps} onClick={async () => handleCreatePortal()} loading={isPending}>
|
||||
<Trans>Manage subscription</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -3,7 +3,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
|
||||
|
||||
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -17,7 +17,7 @@ import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-di
|
||||
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
|
||||
|
||||
export type TeamEmailDropdownProps = {
|
||||
team: Awaited<ReturnType<typeof getTeamByUrl>>;
|
||||
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
|
||||
};
|
||||
|
||||
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
|
||||
@ -25,7 +25,7 @@ export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
|
||||
trpc.team.resendTeamEmailVerification.useMutation({
|
||||
trpc.team.email.verification.resend.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
|
||||
@ -30,7 +30,7 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
||||
trpc.team.deleteTeamEmail.useMutation({
|
||||
trpc.team.email.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGroup } from '@prisma/client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
|
||||
import { TeamMemberInheritDisableDialog } from '~/components/dialogs/team-inherit-member-disable-dialog';
|
||||
import { TeamMemberInheritEnableDialog } from '~/components/dialogs/team-inherit-member-enable-dialog';
|
||||
|
||||
type TeamInheritMemberAlertProps = {
|
||||
memberAccessTeamGroup: TeamGroup | null;
|
||||
};
|
||||
|
||||
export const TeamInheritMemberAlert = ({ memberAccessTeamGroup }: TeamInheritMemberAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Inherit organisation members</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
{memberAccessTeamGroup ? (
|
||||
<Trans>Currently all organisation members can access this team</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You can enable access to allow all organisation members to access this team by
|
||||
default.
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{memberAccessTeamGroup ? (
|
||||
<TeamMemberInheritDisableDialog group={memberAccessTeamGroup} />
|
||||
) : (
|
||||
<TeamMemberInheritEnableDialog />
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,9 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, Globe2Icon, GroupIcon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -20,9 +19,9 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
|
||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
@ -76,6 +75,16 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={groupsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||
>
|
||||
<GroupIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Groups</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -98,21 +107,6 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to={billingPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
import { Braces, Globe2Icon, GroupIcon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -20,9 +19,9 @@ export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMo
|
||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -85,6 +84,16 @@ export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMo
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={groupsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||
>
|
||||
<GroupIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Groups</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -107,21 +116,6 @@ export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMo
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to={billingPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamMemberRole, TeamTransferVerification } from '@prisma/client';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamTransferStatusProps = {
|
||||
className?: string;
|
||||
currentUserTeamRole: TeamMemberRole;
|
||||
teamId: number;
|
||||
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
|
||||
};
|
||||
|
||||
export const TeamTransferStatus = ({
|
||||
className,
|
||||
currentUserTeamRole,
|
||||
teamId,
|
||||
transferVerification,
|
||||
}: TeamTransferStatusProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
|
||||
|
||||
const { mutateAsync: deleteTeamTransferRequest, isPending } =
|
||||
trpc.team.deleteTeamTransferRequest.useMutation({
|
||||
onSuccess: async () => {
|
||||
if (!isExpired) {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`The team transfer invitation has been successfully deleted.`),
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
await revalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{transferVerification && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert
|
||||
variant={isExpired ? 'destructive' : 'warning'}
|
||||
className={cn(
|
||||
'flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>
|
||||
{isExpired ? (
|
||||
<Trans>Team transfer request expired</Trans>
|
||||
) : (
|
||||
<Trans>Team transfer in progress</Trans>
|
||||
)}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
{isExpired ? (
|
||||
<p className="text-sm">
|
||||
<Trans>
|
||||
The team transfer request to <strong>{transferVerification.name}</strong> has
|
||||
expired.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<section className="text-sm">
|
||||
<p>
|
||||
<Trans>
|
||||
A request to transfer the ownership of this team has been sent to{' '}
|
||||
<strong>
|
||||
{transferVerification.name} ({transferVerification.email})
|
||||
</strong>
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
If they accept this request, the team will be transferred to their account.
|
||||
</Trans>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
|
||||
<Button
|
||||
onClick={async () => deleteTeamTransferRequest({ teamId })}
|
||||
loading={isPending}
|
||||
variant={isExpired ? 'destructive' : 'ghost'}
|
||||
className={cn('ml-auto', {
|
||||
'hover:bg-transparent hover:text-blue-800': !isExpired,
|
||||
})}
|
||||
>
|
||||
{isExpired ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -26,12 +26,11 @@ import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/templat
|
||||
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TemplateEditFormProps = {
|
||||
className?: string;
|
||||
initialTemplate: TTemplate;
|
||||
isEnterprise: boolean;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
@ -41,14 +40,13 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||
export const TemplateEditForm = ({
|
||||
initialTemplate,
|
||||
className,
|
||||
isEnterprise,
|
||||
templateRootPath,
|
||||
}: TemplateEditFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useOptionalCurrentTeam();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [step, setStep] = useState<EditTemplateStep>('settings');
|
||||
|
||||
@ -134,8 +132,8 @@ export const TemplateEditForm = ({
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
globalAccessAuth: data.globalAccessAuth ?? [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
@ -260,12 +258,11 @@ export const TemplateEditForm = ({
|
||||
<AddTemplateSettingsFormPartial
|
||||
key={recipients.length}
|
||||
template={template}
|
||||
currentTeamMemberRole={team?.currentTeamMember?.role}
|
||||
currentTeamMemberRole={team.currentTeamRole}
|
||||
documentFlow={documentFlow.settings}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
@ -278,7 +275,6 @@ export const TemplateEditForm = ({
|
||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||
templateDirectLink={template.directLink}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ import { DocumentsTableActionButton } from '~/components/tables/documents-table-
|
||||
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
||||
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
||||
import { TemplateDocumentsTableEmptyState } from '~/components/tables/template-documents-table-empty-state';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||
DOCUMENT: msg`Document`,
|
||||
@ -54,7 +54,7 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const handleStatusFilterChange = (values: string[]) => {
|
||||
startTransition(() => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { File, User2 } from 'lucide-react';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
@ -9,7 +8,10 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type UserProfileSkeletonProps = {
|
||||
className?: string;
|
||||
user: Pick<User, 'name' | 'url'>;
|
||||
user: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user