fix: rework sessions

This commit is contained in:
David Nguyen
2025-02-17 22:46:36 +11:00
parent 1ed1cb0773
commit 5fc724b247
57 changed files with 1512 additions and 1446 deletions

View File

@@ -56,7 +56,8 @@ export const DirectTemplateConfigureForm = ({
}: DirectTemplateConfigureFormProps) => {
const { _ } = useLingui();
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { recipients } = template;
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();

View File

@@ -48,7 +48,8 @@ export const DocumentSigningForm = ({
allRecipients = [],
setSelectedSignerId,
}: DocumentSigningFormProps) => {
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { _ } = useLingui();
const { toast } = useToast();

View File

@@ -139,10 +139,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>

View File

@@ -8,7 +8,6 @@ import { Link, useNavigate } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -66,7 +65,7 @@ export const GenericErrorLayout = ({
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
return (
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">
<div className="absolute -inset-24 -z-10">
<motion.div
className="flex h-full w-full items-center justify-center"
@@ -85,7 +84,7 @@ export const GenericErrorLayout = ({
</motion.div>
</div>
<div className="container mx-auto flex h-full min-h-screen items-center justify-center px-6 py-32">
<div className="inset-0 mx-auto flex h-full flex-grow items-center justify-center px-6 py-32">
<div>
<p className="text-muted-foreground font-semibold">{_(subHeading)}</p>

View File

@@ -262,7 +262,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
{selectedTeam &&
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${selectedTeam.url}/settings/`}>
<Link to={`/t/${selectedTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>

View File

@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
type PortalComponentProps = {
children: React.ReactNode;
target: string;
};
export const PortalComponent = ({ children, target }: PortalComponentProps) => {
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
setPortalRoot(document.getElementById(target));
}, [target]);
return portalRoot ? createPortal(children, portalRoot) : null;
};

View File

@@ -4,7 +4,7 @@ import { Link } from 'react-router';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() {
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">

View File

@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { type Subscription, SubscriptionStatus } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { AlertTriangle } from 'lucide-react';
import { match } from 'ts-pattern';
@@ -22,13 +22,13 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamLayoutBillingBannerProps = {
subscription: Subscription;
subscriptionStatus: SubscriptionStatus;
teamId: number;
userRole: TeamMemberRole;
};
export const TeamLayoutBillingBanner = ({
subscription,
subscriptionStatus,
teamId,
userRole,
}: TeamLayoutBillingBannerProps) => {
@@ -59,7 +59,7 @@ export const TeamLayoutBillingBanner = ({
}
};
if (subscription.status === SubscriptionStatus.ACTIVE) {
if (subscriptionStatus === SubscriptionStatus.ACTIVE) {
return null;
}
@@ -68,16 +68,16 @@ export const TeamLayoutBillingBanner = ({
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
subscription.status === SubscriptionStatus.PAST_DUE,
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground':
subscription.status === SubscriptionStatus.INACTIVE,
subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscription.status)
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Teams restricted</Trans>)
.exhaustive()}
@@ -87,9 +87,9 @@ export const TeamLayoutBillingBanner = ({
variant="ghost"
className={cn({
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
subscription.status === SubscriptionStatus.PAST_DUE,
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscription.status === SubscriptionStatus.INACTIVE,
subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
disabled={isPending}
onClick={() => setIsOpen(true)}
@@ -106,7 +106,7 @@ export const TeamLayoutBillingBanner = ({
<Trans>Payment overdue</Trans>
</DialogTitle>
{match(subscription.status)
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => (
<DialogDescription>
<Trans>

View File

@@ -170,10 +170,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
Void
</DropdownMenuItem> */}
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>

View File

@@ -3,7 +3,6 @@ import { useEffect } from 'react';
import posthog from 'posthog-js';
import { useLocation, useSearchParams } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function PostHogPageview() {
@@ -12,19 +11,20 @@ export function PostHogPageview() {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { user } = useOptionalSession();
// const { sessionData } = useOptionalSession();
// const user = sessionData?.user;
if (typeof window !== 'undefined' && postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
disable_session_recording: true,
loaded: () => {
if (user) {
posthog.identify(user.email ?? user.id.toString());
} else {
posthog.reset();
}
},
// loaded: () => {
// if (user) {
// posthog.identify(user.email ?? user.id.toString());
// } else {
// posthog.reset();
// }
// },
custom_campaign_params: ['src'],
});
}

View File

@@ -1,14 +1,16 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { TGetTeamByUrlResponse } from '@documenso/lib/server-only/team/get-team';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
type TeamProviderValue = TGetTeamsResponse[0];
interface TeamProviderProps {
children: React.ReactNode;
team: TGetTeamByUrlResponse;
team: TeamProviderValue;
}
const TeamContext = createContext<TGetTeamByUrlResponse | null>(null);
const TeamContext = createContext<TeamProviderValue | null>(null);
export const useCurrentTeam = () => {
const context = useContext(TeamContext);

View File

@@ -13,10 +13,11 @@ import {
useLocation,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
import { createPublicEnv, env } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import { TrpcProvider } from '@documenso/trpc/react';
@@ -59,8 +60,21 @@ export function meta() {
return appMetaTags();
}
/**
* Don't revalidate (run the loader on sequential navigations) on the root layout
*
* Update values via providers.
*/
export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
const session = await getOptionalSession(request);
let teams: TGetTeamsResponse = [];
if (session.isAuthenticated) {
teams = await getTeams({ userId: session.user.id });
}
const { getTheme } = await themeSessionResolver(request);
@@ -74,7 +88,13 @@ export async function loader({ request }: Route.LoaderArgs) {
{
lang,
theme: getTheme(),
session,
session: session.isAuthenticated
? {
user: session.user,
session: session.session,
teams,
}
: null,
publicEnv: createPublicEnv(),
},
{
@@ -113,7 +133,7 @@ export function App() {
<script>0</script>
</head>
<body>
<SessionProvider session={session}>
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
<Outlet />

View File

@@ -1,56 +1,57 @@
import { SubscriptionStatus } from '@prisma/client';
import { Outlet } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { Outlet, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getLimits } from '@documenso/ee/server-only/limits/client';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { TeamLayoutBillingBanner } from '~/components/general/teams/team-layout-billing-banner';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const { user, teams, currentTeam } = getLoaderSession();
/**
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
*/
export const shouldRevalidate = () => false;
export const loader = async ({ request }: Route.LoaderArgs) => {
const requestHeaders = Object.fromEntries(request.headers.entries());
// Todo: Should only load this on first render.
const session = await getOptionalSession(request);
if (!session.isAuthenticated) {
return redirect('/signin');
}
const [limits, banner] = await Promise.all([
getLimits({ headers: requestHeaders, teamId: currentTeam?.id }),
getLimits({ headers: requestHeaders }),
getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
),
]);
return {
user,
teams,
banner,
limits,
currentTeam,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams, banner, limits, currentTeam } = loaderData;
const { user, teams } = useSession();
const { banner, limits } = loaderData;
return (
<LimitsProvider initialValue={limits} teamId={currentTeam?.id}>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<LimitsProvider initialValue={limits}>
<div id="portal-header"></div>
{currentTeam?.subscription &&
currentTeam.subscription.status !== SubscriptionStatus.ACTIVE && (
<TeamLayoutBillingBanner
subscription={currentTeam.subscription}
teamId={currentTeam.id}
userRole={currentTeam.currentTeamMember.role}
/>
)}
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}

View File

@@ -1,14 +1,16 @@
import { Trans } from '@lingui/react/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export function loader() {
const { user } = getLoaderSession();
import type { Route } from './+types/_layout';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/documents');

View File

@@ -3,13 +3,14 @@ import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -34,8 +35,14 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id._index';
export async function loader({ params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;

View File

@@ -2,11 +2,12 @@ import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@@ -17,8 +18,14 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id.edit';
export async function loader({ params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;

View File

@@ -6,10 +6,11 @@ import type { Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
@@ -23,10 +24,16 @@ import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/$id.logs';
export async function loader({ params }: Route.LoaderArgs) {
const { id } = params;
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { user, currentTeam: team } = getLoaderSession();
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
const documentId = Number(id);

View File

@@ -59,15 +59,26 @@ export default function DocumentsPage() {
[searchParams],
);
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
...findDocumentSearchParams,
});
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
},
);
// Refetch the documents when the team URL changes.
useEffect(() => {
void refetch();
}, [team?.url]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
if (params.has('page')) {
params.delete('page');
}

View File

@@ -5,8 +5,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
import { trpc } from '@documenso/trpc/react';
@@ -44,8 +44,8 @@ const teamProfileText = {
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
};
export async function loader() {
const { user } = getLoaderSession();
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { profile } = await getUserPublicProfile({
userId: user.id,

View File

@@ -2,8 +2,8 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { prisma } from '@documenso/prisma';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@@ -22,8 +22,8 @@ export function meta() {
return appMetaTags('Security');
}
export async function loader() {
const { user } = getLoaderSession();
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Todo: Use providers instead after RR7 migration.
// const accounts = await prisma.account.findMany({

View File

@@ -1,14 +1,9 @@
import { redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
export function loader() {
const { currentTeam } = getLoaderSession();
import type { Route } from './+types/_index';
if (!currentTeam) {
throw redirect('/settings/teams');
}
throw redirect(formatDocumentsPath(currentTeam.url));
export function loader({ params }: Route.LoaderArgs) {
throw redirect(formatDocumentsPath(params.teamUrl));
}

View File

@@ -1,147 +1,104 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { useMemo } from 'react';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Link, Outlet } from 'react-router';
import { TEAM_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { PortalComponent } from '~/components/general/portal';
import { TeamLayoutBillingBanner } from '~/components/general/teams/team-layout-billing-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export const loader = () => {
const { currentTeam } = getLoaderSession();
export default function Layout({ params }: Route.ComponentProps) {
const { teams } = useSession();
const currentTeam = teams.find((team) => team.url === params.teamUrl);
const limits = useMemo(() => {
if (!currentTeam) {
return undefined;
}
if (
currentTeam?.subscription &&
currentTeam.subscription.status === SubscriptionStatus.INACTIVE
) {
return {
quota: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
remaining: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
};
}
return {
quota: TEAM_PLAN_LIMITS,
remaining: TEAM_PLAN_LIMITS,
};
}, [currentTeam?.subscription, currentTeam?.id]);
if (!currentTeam) {
throw redirect('/settings/teams');
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to="/settings/teams">
<Trans>View teams</Trans>
</Link>
</Button>
}
></GenericErrorLayout>
);
}
const trpcHeaders = {
'x-team-Id': currentTeam.id.toString(),
};
return {
currentTeam,
trpcHeaders,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { currentTeam, trpcHeaders } = loaderData;
return (
<TeamProvider team={currentTeam}>
<TrpcProvider headers={trpcHeaders}>
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</TrpcProvider>
<LimitsProvider initialValue={limits} teamId={currentTeam.id}>
<TrpcProvider headers={trpcHeaders}>
{currentTeam?.subscription &&
currentTeam.subscription.status !== SubscriptionStatus.ACTIVE && (
<PortalComponent target="portal-header">
<TeamLayoutBillingBanner
subscriptionStatus={currentTeam.subscription.status}
teamId={currentTeam.id}
userRole={currentTeam.currentTeamMember.role}
/>
</PortalComponent>
)}
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</TrpcProvider>
</LimitsProvider>
</TeamProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const { _ } = useLingui();
const navigate = useNavigate();
let errorMessage = msg`Unknown error`;
let errorDetails: MessageDescriptor | null = null;
if (error instanceof Error && error.message === AppErrorCode.UNAUTHORIZED) {
errorMessage = msg`Unauthorized`;
errorDetails = msg`You are not authorized to view this page.`;
}
if (isRouteErrorResponse(error)) {
return match(error.status)
.with(404, () => (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">
<Trans>404 Team not found</Trans>
</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
The team you are looking for may have been removed, renamed or may have never
existed.
</Trans>
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link to="/settings/teams">
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(500, () => (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">{_(errorMessage)}</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
{errorDetails ? _(errorDetails) : ''}
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="ghost"
className="w-32"
onClick={() => {
void navigate(-1);
}}
>
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Button>
<Button asChild>
<Link to="/settings/teams">
<Trans>View teams</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.otherwise(() => (
<>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</>
));
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}

View File

@@ -2,7 +2,9 @@ import { Trans } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
@@ -17,13 +19,27 @@ import { TeamUpdateForm } from '~/components/forms/team-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
import type { Route } from './+types/_index';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const { user } = useSession();
const team = useCurrentTeam();
const isTransferVerificationExpired =
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);

View File

@@ -1,25 +1,37 @@
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { getLoaderTeamSession } from 'server/utils/get-loader-session';
import { Outlet, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop';
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags('Team Settings');
}
export function loader() {
const { currentTeam: team } = getLoaderTeamSession();
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Response(null, { status: 401 }); // Unauthorized.
throw redirect(`/t/${params.teamUrl}`);
}
}
export async function clientLoader() {
// Do nothing, we only want the loader to run on SSR.
}
export default function TeamsSettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">

View File

@@ -2,11 +2,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { getLoaderTeamSession } from 'server/utils/get-loader-session';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -16,8 +17,13 @@ import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-setti
import type { Route } from './+types/billing';
export async function loader() {
const { currentTeam: team } = getLoaderTeamSession();
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
let teamSubscription: Stripe.Subscription | null = null;

View File

@@ -1,16 +1,30 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-preferences-form';
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
import type { Route } from './+types/preferences';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const { _ } = useLingui();
const team = useCurrentTeam();
return (
<div>
<SettingsHeader

View File

@@ -1,14 +1,22 @@
import { getLoaderTeamSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
export async function loader() {
const { user, currentTeam: team } = getLoaderTeamSession();
import type { Route } from './+types/public-profile';
// Todo: This can be optimized.
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
const { profile } = await getTeamPublicProfile({
userId: user.id,
userId: session.user.id,
teamId: team.id,
});

View File

@@ -1,10 +1,11 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { getLoaderTeamSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
@@ -12,8 +13,14 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/tokens';
export async function loader() {
const { user, currentTeam: team } = getLoaderTeamSession();
// Todo: This can be optimized.
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);

View File

@@ -2,8 +2,9 @@ import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,8 +26,14 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id._index';
export async function loader({ params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;

View File

@@ -1,9 +1,10 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -15,8 +16,14 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/template-direct-link-dialog-wrapper';
import type { Route } from './+types/$id.edit';
export async function loader({ params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;

View File

@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Bird } from 'lucide-react';
import { useSearchParams } from 'react-router';
@@ -27,11 +29,16 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
});
// Refetch the templates when the team URL changes.
useEffect(() => {
void refetch();
}, [team?.url]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">

View File

@@ -1,10 +1,13 @@
import { redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
export function loader() {
const session = getOptionalLoaderSession();
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
if (session) {
import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
if (isAuthenticated) {
throw redirect('/documents');
}

View File

@@ -59,7 +59,8 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const { profile, templates } = publicProfile;
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
return (
<div className="flex flex-col items-center justify-center py-4 sm:py-32">

View File

@@ -1,35 +1,26 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import type { Route } from './+types/_layout';
export function loader() {
const session = getOptionalLoaderSession();
return {
user: session?.user,
teams: session?.teams || [],
};
}
/**
* A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not.
*
* Such as direct template access, or signing.
*/
export default function RecipientLayout({ loaderData }: Route.ComponentProps) {
const { user, teams } = loaderData;
export default function RecipientLayout() {
const { sessionData } = useOptionalSession();
return (
<div className="min-h-screen">
{user && <AuthenticatedHeader user={user} teams={teams} />}
{sessionData?.user && (
<AuthenticatedHeader user={sessionData.user} teams={sessionData.teams} />
)}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">
<Outlet />
@@ -38,6 +29,7 @@ export default function RecipientLayout({ loaderData }: Route.ComponentProps) {
);
}
// Todo: Use generic error boundary.
export function ErrorBoundary() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">

View File

@@ -1,9 +1,9 @@
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
import { redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
@@ -17,8 +17,8 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const { token } = params;
@@ -48,7 +48,7 @@ export async function loader({ params }: Route.LoaderArgs) {
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session?.user))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(null, () => true)
.exhaustive();
@@ -66,7 +66,8 @@ export async function loader({ params }: Route.LoaderArgs) {
}
export default function DirectTemplatePage() {
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const data = useSuperLoaderData<typeof loader>();

View File

@@ -5,6 +5,7 @@ import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
@@ -27,8 +28,10 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
export async function loader({ params }: Route.LoaderArgs) {
const { session, requestMetadata } = getOptionalLoaderContext();
export async function loader({ params, request }: Route.LoaderArgs) {
const { requestMetadata } = getOptionalLoaderContext();
const { user } = await getOptionalSession(request);
const { token } = params;
@@ -36,8 +39,6 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const user = session?.user;
const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
@@ -136,7 +137,8 @@ export async function loader({ params }: Route.LoaderArgs) {
export default function SigningPage() {
const data = useSuperLoaderData<typeof loader>();
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (

View File

@@ -6,10 +6,10 @@ import { Trans } from '@lingui/react/macro';
import { type Document, DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
@@ -31,8 +31,8 @@ import { DocumentSigningAuthPageView } from '~/components/general/document-signi
import type { Route } from './+types/complete';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
@@ -40,8 +40,6 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const user = session?.user;
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
@@ -100,7 +98,8 @@ export async function loader({ params }: Route.LoaderArgs) {
export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const {
isDocumentAccessValid,

View File

@@ -2,8 +2,8 @@ import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { XCircle } from 'lucide-react';
import { Link } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
@@ -17,8 +17,8 @@ import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/rejected';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
@@ -26,8 +26,6 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const user = session?.user;
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
@@ -76,7 +74,8 @@ export async function loader({ params }: Route.LoaderArgs) {
}
export default function RejectedSigningPage({ loaderData }: Route.ComponentProps) {
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { isDocumentAccessValid, recipientReference, truncatedTitle } = loaderData;

View File

@@ -2,8 +2,8 @@ import { Trans } from '@lingui/react/macro';
import type { Team } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -13,8 +13,8 @@ import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/waiting';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
@@ -37,7 +37,6 @@ export async function loader({ params }: Route.LoaderArgs) {
let isOwnerOrTeamMember = false;
const user = session?.user;
let team: Team | null = null;
if (user) {

View File

@@ -1,7 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
@@ -18,15 +18,15 @@ export function meta() {
return appMetaTags('Sign In');
}
export function loader() {
const session = getOptionalLoaderSession();
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
if (session) {
if (isAuthenticated) {
throw redirect('/documents');
}

View File

@@ -2,8 +2,8 @@ import { Trans } from '@lingui/react/macro';
import { TeamMemberInviteStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
@@ -12,8 +12,8 @@ import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/team.decline.$token';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const { token } = params;
@@ -74,7 +74,7 @@ export async function loader({ params }: Route.LoaderArgs) {
} as const;
}
const isSessionUserTheInvitedUser = user.id === session?.user.id;
const isSessionUserTheInvitedUser = user.id === session?.user?.id;
return {
state: 'Success',

View File

@@ -2,8 +2,8 @@ import { Trans } from '@lingui/react/macro';
import { TeamMemberInviteStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
@@ -12,8 +12,8 @@ import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/team.invite.$token';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const { token } = params;
@@ -77,7 +77,7 @@ export async function loader({ params }: Route.LoaderArgs) {
} as const;
}
const isSessionUserTheInvitedUser = user.id === session?.user.id;
const isSessionUserTheInvitedUser = user.id === session.user?.id;
return {
state: 'Success',

View File

@@ -1,7 +1,7 @@
import { data } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -18,7 +18,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/direct.$url';
export async function loader({ params }: Route.LoaderArgs) {
export async function loader({ params, request }: Route.LoaderArgs) {
if (!params.url) {
throw new Response('Not found', { status: 404 });
}
@@ -49,7 +49,7 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const { user } = getLoaderSession();
const { user } = await getSession(request);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,

View File

@@ -1,8 +1,8 @@
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { data } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -22,14 +22,14 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/sign.$url';
export async function loader({ params }: Route.LoaderArgs) {
export async function loader({ params, request }: Route.LoaderArgs) {
if (!params.url) {
throw new Response('Not found', { status: 404 });
}
const token = params.url;
const { user } = getLoaderSession();
const { user } = await getSession(request);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({

View File

@@ -1,79 +1,37 @@
import type { Context, Next } from 'hono';
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import type { AppSession } from '@documenso/lib/client-only/providers/session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
import {
type RequestMetadata,
extractRequestMetadata,
} from '@documenso/lib/universal/extract-request-metadata';
import { AppDebugger } from '@documenso/lib/utils/debugger';
const debug = new AppDebugger('Middleware');
export type AppContext = {
requestMetadata: RequestMetadata;
session: AppSession | null;
};
/**
* Apply a context which can be accessed throughout the app.
*
* Keep this as lean as possible in terms of awaiting, because anything
* here will increase each page load time.
*/
export const appContext = async (c: Context, next: Next) => {
const initTime = Date.now();
const request = c.req.raw;
const url = new URL(request.url);
const noSessionCookie = extractSessionCookieFromHeaders(request.headers) === null;
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
});
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
// debug.log('Pathname ignored', url.pathname);
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
session: null,
});
return next();
}
const splitUrl = url.pathname.replace('.data', '').split('/');
let team: TGetTeamByUrlResponse | null = null;
let teams: TGetTeamsResponse = [];
const session = await getOptionalSession(c);
if (session.isAuthenticated) {
let teamUrl = null;
if (splitUrl[1] === 't' && splitUrl[2]) {
teamUrl = splitUrl[2];
}
const result = await Promise.all([
getTeams({ userId: session.user.id }),
teamUrl ? getTeamByUrl({ userId: session.user.id, teamUrl }).catch(() => null) : null,
]);
teams = result[0];
team = result[1];
}
const endTime = Date.now();
debug.log(`Pathname accepted in ${endTime - initTime}ms`, url.pathname);
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
session: session.isAuthenticated
? {
session: session.session,
user: session.user,
currentTeam: team,
teams,
}
: null,
});
// Add context to any pages you want here.
return next();
};

View File

@@ -1,10 +1,7 @@
import { getContext } from 'hono/context-storage';
import { redirect } from 'react-router';
import type { AppContext } from 'server/context';
import type { HonoEnv } from 'server/router';
import type { AppSession } from '@documenso/lib/client-only/providers/session';
/**
* Get the full context passed to the loader.
*
@@ -14,46 +11,3 @@ export const getOptionalLoaderContext = (): AppContext => {
const { context } = getContext<HonoEnv>().var;
return context;
};
/**
* Returns the session extracted from the app context.
*
* @returns The session, or null if not authenticated.
*/
export const getOptionalLoaderSession = (): AppSession | null => {
const { context } = getContext<HonoEnv>().var;
return context.session;
};
/**
* Returns the session context or throws a redirect to signin if it is not present.
*/
export const getLoaderSession = (): AppSession => {
const session = getOptionalLoaderSession();
if (!session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
return session;
};
/**
* Returns the team session context or throws a redirect to signin if it is not present.
*/
export const getLoaderTeamSession = () => {
const session = getOptionalLoaderSession();
if (!session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
if (!session.currentTeam) {
throw new Response(null, { status: 404 }); // Todo: Test that 404 page shows up.
}
return {
...session,
currentTeam: session.currentTeam,
};
};

View File

@@ -1,10 +1,12 @@
import type { ClientResponse, InferRequestType } from 'hono/client';
import { hc } from 'hono/client';
import superjson from 'superjson';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type {
TDisableTwoFactorRequestSchema,
@@ -45,8 +47,14 @@ export class AuthClient {
window.location.href = redirectPath ?? this.signOutredirectPath;
}
public async session() {
return this.client.session.$get();
public async getSession() {
const response = await this.client['session-json'].$get();
await this.handleError(response);
const result = await response.json();
return superjson.deserialize<SessionValidationResult>(result);
}
private async handleError<T>(response: ClientResponse<T>): Promise<void> {

View File

@@ -1,10 +1,17 @@
import { Hono } from 'hono';
import superjson from 'superjson';
import type { SessionValidationResult } from '../lib/session/session';
import { getOptionalSession } from '../lib/utils/get-session';
export const sessionRoute = new Hono().get('/session', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
export const sessionRoute = new Hono()
.get('/session', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
return c.json(session);
});
return c.json(session);
})
.get('/session-json', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
return c.json(superjson.serialize(session));
});

View File

@@ -1,25 +1,31 @@
import { createContext, useContext } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import React from 'react';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import type { Session } from '@documenso/prisma/client';
import { useLocation } from 'react-router';
import type { TGetTeamByUrlResponse } from '../../server-only/team/get-team';
import type { TGetTeamsResponse } from '../../server-only/team/get-teams';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { type TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import type { Session } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/client';
export type AppSession = {
session: Session;
user: SessionUser;
currentTeam: TGetTeamByUrlResponse | null;
teams: TGetTeamsResponse;
};
interface SessionProviderProps {
children: React.ReactNode;
session: AppSession | null;
initialSession: AppSession | null;
}
const SessionContext = createContext<AppSession | null>(null);
interface SessionContextValue {
sessionData: AppSession | null;
refresh: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export const useSession = () => {
const context = useContext(SessionContext);
@@ -28,18 +34,78 @@ export const useSession = () => {
throw new Error('useSession must be used within a SessionProvider');
}
return context;
if (!context.sessionData) {
throw new Error('Session not found');
}
return {
...context.sessionData,
refresh: context.refresh,
};
};
export const useOptionalSession = () => {
return (
useContext(SessionContext) || {
user: null,
session: null,
}
);
const context = useContext(SessionContext);
if (!context) {
throw new Error('useOptionalSession must be used within a SessionProvider');
}
return context;
};
export const SessionProvider = ({ children, session }: SessionProviderProps) => {
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
export const SessionProvider = ({ children, initialSession }: SessionProviderProps) => {
const [session, setSession] = useState<AppSession | null>(initialSession);
const location = useLocation();
const refreshSession = useCallback(async () => {
const newSession = await authClient.getSession();
if (!newSession.isAuthenticated) {
setSession(null);
return;
}
const teams = await trpc.team.getTeams.query().catch(() => {
// Todo: Log
return [];
});
setSession({
session: newSession.session,
user: newSession.user,
teams,
});
}, []);
useEffect(() => {
const onFocus = () => {
void refreshSession();
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, [refreshSession]);
/**
* Refresh session in background on navigation.
*/
useEffect(() => {
void refreshSession();
}, [location.pathname]);
return (
<SessionContext.Provider
value={{
sessionData: session,
refresh: refreshSession,
}}
>
{children}
</SessionContext.Provider>
);
};

View File

@@ -1,6 +1,7 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { SubscriptionSchema } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
@@ -12,6 +13,9 @@ export const ZGetTeamsResponseSchema = TeamSchema.extend({
currentTeamMember: TeamMemberSchema.pick({
role: true,
}),
subscription: SubscriptionSchema.pick({
status: true,
}).nullable(),
}).array();
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
@@ -26,6 +30,11 @@ export const getTeams = async ({ userId }: GetTeamsOptions): Promise<TGetTeamsRe
},
},
include: {
subscription: {
select: {
status: true,
},
},
members: {
where: {
userId,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';
@@ -7,7 +7,6 @@ import SuperJSON from 'superjson';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
// import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router';
export { getQueryKey } from '@trpc/react-query';
@@ -39,24 +38,27 @@ export interface TrpcProviderProps {
export function TrpcProvider({ children, headers }: TrpcProviderProps) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: SuperJSON,
// May cause remounting issues.
const trpcClient = useMemo(
() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: SuperJSON,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: SuperJSON,
}),
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: SuperJSON,
}),
}),
],
}),
],
}),
[headers],
);
return (