Merge branch 'main' into fix/replace-hardcoded-grays-with-theme-tokens

This commit is contained in:
Ephraim Duncan
2026-08-19 10:21:45 +00:00
committed by GitHub
417 changed files with 30071 additions and 13944 deletions
@@ -1,8 +1,10 @@
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
@@ -17,6 +19,8 @@ export default function Layout() {
const team = useOptionalCurrentTeam();
const organisation = useOptionalCurrentOrganisation();
const { layoutMode } = useChildRouteFlags();
const limits = useMemo(() => {
if (!organisation) {
return undefined;
@@ -78,7 +82,7 @@ export default function Layout() {
// Note: We use a key to force a re-render if the team context changes.
// This is required otherwise you would see the wrong page content.
return (
<div key={team.url}>
<div key={team.url} className={cn({ 'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings' })}>
<TrpcProvider headers={trpcHeaders}>
<LimitsProvider initialValue={limits} teamId={team.id}>
<Outlet />
@@ -72,7 +72,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
updatedAt: envelope.updatedAt,
documentMeta: envelope.documentMeta,
},
recipients: envelope.recipients,
recipients: envelope.recipients.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
})),
documentRootPath,
userId: user.id,
};
@@ -118,7 +123,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
},
];
const formatRecipientText = (recipient: Recipient) => {
const formatRecipientText = (recipient: Pick<Recipient, 'email' | 'name' | 'role'>) => {
let text = recipient.email;
if (recipient.name) {
@@ -1,59 +1,57 @@
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
import { XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { useNavigate, useParams } from 'react-router';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import {
type EnvelopeBulkDownloadItem,
EnvelopesBulkDownloadDialog,
} from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { useCurrentTeam } from '~/providers/team';
import { documentsSearchParams } from '~/utils/documents-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Documents`);
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
// Stable initial values: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable, and the metadata cache
// effect below depends on that setter.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
@@ -61,9 +59,18 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`documents-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
`documents-bulk-selection-meta-${team.id}`,
EMPTY_ENVELOPE_META_CACHE,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
@@ -76,18 +83,23 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.EXPIRED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
history: 'push',
});
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
status: findDocumentSearchParams.status ?? undefined,
period: findDocumentSearchParams.period ?? undefined,
senderIds: findDocumentSearchParams.senderIds ?? undefined,
page: findDocumentSearchParams.page ?? undefined,
perPage: findDocumentSearchParams.perPage ?? undefined,
query: findDocumentSearchParams.query ?? undefined,
folderId,
},
{
@@ -95,34 +107,66 @@ export default function DocumentsPage() {
},
);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: EnvelopeMetaCache = {};
params.set('status', value);
for (const id of Object.keys(prev)) {
if (rowSelection[id]) {
next[id] = prev[id];
}
}
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
for (const document of data?.data ?? []) {
if (rowSelection[document.envelopeId]) {
next[document.envelopeId] = {
title: document.title,
status: document.status,
isLegacy: document.internalVersion === 1,
};
}
}
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
params.delete('status');
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
if (params.has('page')) {
params.delete('page');
}
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id): EnvelopeBulkDownloadItem | null => {
const meta = envelopeMetaCache[id];
let path = formatDocumentsPath(team.url);
if (!meta) {
return null;
}
if (folderId) {
path += `/f/${folderId}`;
}
return {
id,
title: meta.title,
status: meta.status,
// Stale cache entries predating this field are treated as legacy so
// the Partial option is never offered without certainty.
isLegacy: meta.isLegacy ?? true,
};
})
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
if (params.toString()) {
path += `?${params.toString()}`;
}
const hasActiveFilters = useMemo(() => {
return Boolean(
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
findDocumentSearchParams.senderIds?.length ||
findDocumentSearchParams.period,
);
}, [findDocumentSearchParams]);
return path;
const onResetFilters = () => {
void setFindDocumentSearchParams({
status: null,
senderIds: null,
period: null,
page: null,
});
};
useEffect(() => {
@@ -136,67 +180,40 @@ export default function DocumentsPage() {
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="mt-8 flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
<h2 className="font-semibold text-4xl">
<Trans>Documents</Trans>
</h2>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.CANCELLED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
<DocumentsTableStatusFilter stats={stats} />
return true;
})
.map((value) => (
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DocumentsTablePeriodFilter />
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
) : (
<DocumentsTable
data={data}
@@ -235,12 +252,28 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -1,162 +0,0 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { match, P } from 'ts-pattern';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
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 { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/settings._index';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamWithEmail({
userId: user.id,
teamUrl: params.teamUrl,
});
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const currentTeam = useCurrentTeam();
return (
<div className="max-w-2xl">
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<AvatarImageForm team={currentTeam} className="mb-8" />
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
You can view documents associated with this email and use this identity when sending documents.
</Trans>
</AlertDescription>
<hr className="mt-2 border-border/50" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>
}
/>
<div className="flex flex-row items-center pr-2">
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
{match({
teamEmail: team.teamEmail,
emailVerification: team.emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
<Trans>Expired</Trans>
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
<Trans>Awaiting email confirmation</Trans>
</>
))
.otherwise(() => null)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</Alert>
)}
{!team.teamEmail && !team.emailVerification && (
<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>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>
<Trans>View documents associated with this email</Trans>
</span>
</ul>
</AlertDescription>
</div>
<TeamEmailAddDialog teamId={team.id} />
</Alert>
)}
{canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<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>Delete team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This team, and any associated data excluding billing invoices will be permanently deleted.
</Trans>
</AlertDescription>
</div>
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
</Alert>
)}
</section>
</div>
);
}
@@ -1,15 +1,11 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { BracesIcon, Globe2Icon, GroupIcon, Settings2Icon, SettingsIcon, Users2Icon, WebhookIcon } from 'lucide-react';
import { Link, NavLink, Outlet, redirect } from 'react-router';
import { redirect } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings._layout';
@@ -18,6 +14,10 @@ export function meta() {
return appMetaTags(msg`Team Settings`);
}
export const handle: RouteHandle = {
layoutMode: 'settings',
};
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
@@ -35,123 +35,6 @@ export async function clientLoader() {
// Do nothing, we only want the loader to run on SSR.
}
export default function TeamsSettingsLayout() {
const { t } = useLingui();
const team = useCurrentTeam();
const teamSettingRoutes = [
{
path: `/t/${team.url}/settings`,
label: t`General`,
icon: SettingsIcon,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/public-profile`,
label: t`Public Profile`,
icon: Globe2Icon,
},
{
path: `/t/${team.url}/settings/members`,
label: t`Members`,
icon: Users2Icon,
},
{
path: `/t/${team.url}/settings/groups`,
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/t/${team.url}/settings/tokens`,
label: t`API Tokens`,
icon: BracesIcon,
},
{
path: `/t/${team.url}/settings/webhooks`,
label: t`Webhooks`,
icon: WebhookIcon,
},
];
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
<Trans>Team Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{teamSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
export default function TeamSettingsLayout() {
return <UnifiedSettingsLayout activeScope="team" />;
}
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -17,6 +17,7 @@ import {
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
@@ -115,7 +116,7 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
@@ -155,6 +156,8 @@ export default function TeamsSettingsPage() {
</Alert>
)}
</section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -0,0 +1,76 @@
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
CertificatePreferencesForm,
type TCertificatePreferencesFormSchema,
} from '~/components/forms/certificate-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsCertificatesPage() {
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
try {
const { includeSigningCertificate, includeAuditLog } = data;
await updateTeamSettings({
teamId: team.id,
data: {
includeSigningCertificate,
includeAuditLog,
},
});
toast({
title: t`Certificate preferences updated`,
description: t`Your certificate preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your certificate preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Certificates`}
subtitle={t`Here you can set certificate and audit log preferences for your team.`}
/>
<section>
<CertificatePreferencesForm
canInherit={true}
settings={teamWithSettings.teamSettings}
onFormSubmit={onCertificatePreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -1,11 +1,8 @@
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import {
DocumentPreferencesForm,
@@ -13,21 +10,8 @@ import {
} from '~/components/forms/document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Document Preferences`);
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const team = useCurrentTeam();
const { t } = useLingui();
@@ -46,15 +30,10 @@ export default function TeamsSettingsPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
} = data;
await updateTeamSettings({
@@ -64,13 +43,8 @@ export default function TeamsSettingsPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -82,7 +56,7 @@ export default function TeamsSettingsPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership: delegateDocumentOwnership,
delegateDocumentOwnership,
},
});
@@ -110,7 +84,7 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Document Preferences`}
subtitle={t`Here you can set preferences and defaults for your team.`}
@@ -119,7 +93,6 @@ export default function TeamsSettingsPage() {
<section>
<DocumentPreferencesForm
canInherit={true}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={teamWithSettings.teamSettings}
onFormSubmit={onDocumentPreferencesSubmit}
/>
@@ -1,17 +1,11 @@
import { trpc } from '@documenso/trpc/react';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { EmailPreferencesForm, type TEmailPreferencesFormSchema } from '~/components/forms/email-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Settings`);
}
export default function TeamEmailSettingsGeneral() {
const { t } = useLingui();
@@ -27,7 +21,7 @@ export default function TeamEmailSettingsGeneral() {
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
try {
const { emailId, emailReplyTo, emailDocumentSettings } = data;
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
await updateTeamSettings({
teamId: team.id,
@@ -36,6 +30,7 @@ export default function TeamEmailSettingsGeneral() {
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
includeSenderDetails,
},
});
@@ -59,7 +54,7 @@ export default function TeamEmailSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<section>
@@ -0,0 +1,281 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { buildTeamWhereQuery, canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { CheckCircle2, Clock, EditIcon, LoaderIcon, MailIcon, MoreHorizontalIcon, XIcon } from 'lucide-react';
import { redirect } from 'react-router';
import { match, P } from 'ts-pattern';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamUpdateForm } from '~/components/forms/team-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/settings.general';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
if (!user || !params.teamUrl) {
throw redirect('/');
}
const team = await prisma.team.findUnique({
where: {
...buildTeamWhereQuery({
teamId: undefined,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
url: params.teamUrl,
},
include: {
teamEmail: {
select: {
email: true,
name: true,
},
},
emailVerification: {
select: {
email: true,
name: true,
expiresAt: true,
},
},
},
});
if (!team) {
throw redirect('/');
}
return {
teamEmail: team.teamEmail
? {
email: team.teamEmail?.email,
name: team.teamEmail.name,
}
: null,
emailVerification: team.emailVerification
? {
email: team.emailVerification?.email,
name: team.emailVerification.name,
expiresAt: team.emailVerification.expiresAt,
}
: null,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const { toast } = useToast();
const { teamEmail, emailVerification } = loaderData;
const team = useCurrentTeam();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.email.verification.resend.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Email verification has been resent`,
duration: 5000,
});
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`Unable to resend verification at this time. Please try again.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<div>
<SettingsHeader title={t`General settings`} subtitle={t`Here you can edit your team's details.`} />
<AvatarImageForm team={team} className="mb-8" />
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(teamEmail || emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
You can view documents associated with this email and use this identity when sending documents.
</Trans>
</AlertDescription>
<hr className="mt-2 border-border/50" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{teamEmail?.name || emailVerification?.name}
</span>
}
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
/>
<div className="flex flex-row items-center pr-2">
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
{match({
teamEmail,
emailVerification: emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
<Trans>Expired</Trans>
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
<Trans>Awaiting email confirmation</Trans>
</>
))
.otherwise(() => null)}
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!teamEmail && emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<MailIcon className="mr-2 h-4 w-4" />
)}
<Trans>Resend verification</Trans>
</DropdownMenuItem>
)}
{teamEmail && (
<TeamEmailUpdateDialog
teamId={team.id}
teamEmail={teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
}
/>
)}
<TeamEmailDeleteDialog
team={team}
teamEmail={teamEmail}
emailVerification={emailVerification}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<XIcon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Alert>
)}
{!teamEmail && !emailVerification && (
<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>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>
<Trans>View documents associated with this email</Trans>
</span>
</ul>
</AlertDescription>
</div>
<TeamEmailAddDialog teamId={team.id} />
</Alert>
)}
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<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>Delete team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This team, and any associated data excluding billing invoices will be permanently deleted.
</Trans>
</AlertDescription>
</div>
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
</Alert>
)}
</section>
</div>
);
}
@@ -56,7 +56,7 @@ export default function TeamsSettingsGroupsPage() {
return (
<div>
<SettingsHeader title={t`Team Groups`} subtitle={t`Manage the groups assigned to this team.`}>
<SettingsHeader hideDivider title={t`Team Groups`} subtitle={t`Manage the groups assigned to this team.`}>
<TeamGroupCreateDialog />
</SettingsHeader>
@@ -40,7 +40,7 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
<SettingsHeader hideDivider title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
<TeamMemberCreateDialog />
</SettingsHeader>
@@ -128,8 +128,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
hideDivider
title={t`Public Profile`}
subtitle={t`You can choose to enable or disable the profile for public view.`}
>
@@ -189,9 +190,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
<div className="mt-4">
<SettingsHeader
hideDivider
title={t`Templates`}
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly`}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
@@ -0,0 +1,76 @@
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
ReminderPreferencesForm,
type TReminderPreferencesFormSchema,
} from '~/components/forms/reminder-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsRemindersPage() {
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
try {
const { envelopeExpirationPeriod, reminderSettings } = data;
await updateTeamSettings({
teamId: team.id,
data: {
envelopeExpirationPeriod,
reminderSettings,
},
});
toast({
title: t`Reminder preferences updated`,
description: t`Your reminder preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your reminder preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Reminders`}
subtitle={t`Here you can set expiration and signing reminder preferences for your team.`}
/>
<section>
<ReminderPreferencesForm
canInherit={true}
settings={teamWithSettings.teamSettings}
onFormSubmit={onReminderPreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -1,14 +1,18 @@
import { trpc } from '@documenso/trpc/react';
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { useMemo } from 'react';
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -18,33 +22,89 @@ export function meta() {
}
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const { t, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
const {
data: tokens,
isLoading,
isError,
} = trpc.apiToken.getMany.useQuery(undefined, {
enabled: !isUnauthorized,
});
const columns = useMemo(() => {
return [
{
header: t`Name`,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Expires`,
cell: ({ row }) => {
if (!row.original.expires) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
if (row.original.expires < new Date()) {
return (
<Badge variant="destructive" size="small">
<Trans>Expired</Trans>
</Badge>
);
}
return i18n.date(row.original.expires);
},
},
{
header: t`Actions`,
cell: ({ row }) => (
<TokenDeleteDialog token={row.original}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
),
},
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
}, []);
return (
<div>
<SettingsHeader
hideDivider
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
Create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
rel="noopener"
>
Documentation
documentation
</a>{' '}
for more information.
</Trans>
}
/>
>
{!isUnauthorized && <TokenCreateDialog />}
</SettingsHeader>
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
{isUnauthorized ? (
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
<div>
<AlertTitle>
@@ -56,58 +116,43 @@ export default function ApiTokensPage() {
</div>
</Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mt-8 mb-4" />
<h4 className="font-medium text-xl">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="mt-2 text-muted-foreground text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
<DataTable
columns={columns}
data={tokens ?? []}
perPage={0}
currentPage={0}
totalPages={0}
error={{
enable: isError,
}}
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="mt-2 text-muted-foreground text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
/>
)}
</div>
);
@@ -216,6 +216,7 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
return (
<div>
<SettingsHeader
hideDivider
title={
<div className="flex items-center gap-2">
<p>
@@ -89,6 +89,7 @@ export default function WebhookPage() {
return (
<div>
<SettingsHeader
hideDivider
title={t`Webhooks`}
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
>
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
export function meta() {
return appMetaTags(msg`Templates`);
}
// Stable initial value: `useSessionStorage` keeps its setter identity stable
// only while the initial value reference is stable.
const EMPTY_ROW_SELECTION: RowSelectionState = {};
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
`templates-bulk-selection-${team.id}`,
EMPTY_ROW_SELECTION,
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);