fix: update teams API tokens logic

This commit is contained in:
David Nguyen
2025-02-21 00:34:50 +11:00
parent 7728c8641c
commit 991ce5ff46
10 changed files with 157 additions and 276 deletions

View File

@@ -30,22 +30,20 @@ import {
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = { export type TokenDeleteDialogProps = {
teamId?: number;
token: Pick<ApiToken, 'id' | 'name'>; token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void; onDelete?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
}; };
export default function TokenDeleteDialog({ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
teamId,
token,
onDelete,
children,
}: TokenDeleteDialogProps) {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const deleteMessage = _(msg`delete ${token.name}`); const deleteMessage = _(msg`delete ${token.name}`);
@@ -75,7 +73,7 @@ export default function TokenDeleteDialog({
try { try {
await deleteTokenMutation({ await deleteTokenMutation({
id: token.id, id: token.id,
teamId, teamId: team?.id,
}); });
toast({ toast({

View File

@@ -1,4 +1,4 @@
import { useState, useTransition } from 'react'; import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
@@ -38,6 +38,8 @@ import {
import { Switch } from '@documenso/ui/primitives/switch'; import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = { export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`, ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`, ONE_MONTH: msg`1 month`,
@@ -59,15 +61,14 @@ type NewlyCreatedToken = {
export type ApiTokenFormProps = { export type ApiTokenFormProps = {
className?: string; className?: string;
teamId?: number;
tokens?: Pick<ApiToken, 'id'>[]; tokens?: Pick<ApiToken, 'id'>[];
}; };
export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => { export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [isTransitionPending, startTransition] = useTransition();
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const team = useOptionalCurrentTeam();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@@ -113,7 +114,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => { const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try { try {
await createTokenMutation({ await createTokenMutation({
teamId, teamId: team?.id,
tokenName, tokenName,
expirationDate: noExpirationDate ? null : expirationDate, expirationDate: noExpirationDate ? null : expirationDate,
}); });
@@ -238,7 +239,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
type="submit" type="submit"
className="hidden md:inline-flex" className="hidden md:inline-flex"
disabled={!form.formState.isDirty} disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting || isTransitionPending} loading={form.formState.isSubmitting}
> >
<Trans>Create token</Trans> <Trans>Create token</Trans>
</Button> </Button>
@@ -247,7 +248,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
<Button <Button
type="submit" type="submit"
disabled={!form.formState.isDirty} disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting || isTransitionPending} loading={form.formState.isSubmitting}
> >
<Trans>Create token</Trans> <Trans>Create token</Trans>
</Button> </Button>

View File

@@ -3,8 +3,8 @@ import React from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = { export type SettingsHeaderProps = {
title: string; title: string | React.ReactNode;
subtitle: string; subtitle: string | React.ReactNode;
hideDivider?: boolean; hideDivider?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;

View File

@@ -1,90 +1,115 @@
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog'; import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token'; import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
export default function ApiTokensPage() { export default function ApiTokensPage() {
const { i18n } = useLingui(); const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery(); const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const team = useOptionalCurrentTeam();
return ( return (
<div> <div>
<h3 className="text-2xl font-semibold"> <SettingsHeader
<Trans>API Tokens</Trans> title={<Trans>API Tokens</Trans>}
</h3> subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>{' '}
for more information.
</Trans>
}
/>
<p className="text-muted-foreground mt-2 text-sm"> {team && team?.currentTeamMember.role !== TeamMemberRole.ADMIN ? (
<Trans> <Alert
On this page, you can create new API tokens and manage the existing ones. <br /> className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
Also see our{' '} variant="warning"
<a >
className="text-primary underline" <div>
href={'https://docs.documenso.com/developers/public-api'} <AlertTitle>
target="_blank" <Trans>Unauthorized</Trans>
> </AlertTitle>
Documentation <AlertDescription className="mr-2">
</a> <Trans>You need to be an admin to manage API tokens.</Trans>
. </AlertDescription>
</Trans> </div>
</p> </Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="my-4" /> <hr className="mb-4 mt-8" />
<ApiTokenForm className="max-w-xl" tokens={tokens} /> <h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
<hr className="mb-4 mt-8" /> {tokens && tokens.length === 0 && (
<div className="mb-4">
<h4 className="text-xl font-medium"> <p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your existing tokens</Trans> <Trans>Your tokens will be shown here once you create them.</Trans>
</h4> </p>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>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="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 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>
))} )}
</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="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}
</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>
Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}
</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 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>
)}
</>
)} )}
</div> </div>
); );

View File

@@ -1,126 +1,3 @@
import { useLingui } from '@lingui/react'; import ApiTokensPage from '~/routes/_authenticated+/settings+/tokens+/index';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { getSession } from '@documenso/auth/server/lib/utils/get-session'; export default ApiTokensPage;
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';
import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/tokens';
// 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);
return {
user,
team,
tokens,
};
}
export default function ApiTokensPage({ loaderData }: Route.ComponentProps) {
const { i18n } = useLingui();
const { team, tokens } = loaderData;
if (!tokens) {
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Something went wrong.</Trans>
</p>
</div>
);
}
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
On this page, you can create new API tokens and manage the existing ones. <br />
You can view our swagger docs{' '}
<a
className="text-primary underline"
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
target="_blank"
>
here
</a>
</Trans>
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" teamId={team.id} tokens={tokens} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token} teamId={team.id}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { generateOpenApi } from '@ts-rest/open-api'; import { generateOpenApi } from '@ts-rest/open-api';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
export const OpenAPIV1 = Object.assign( export const OpenAPIV1 = Object.assign(
@@ -11,6 +13,11 @@ export const OpenAPIV1 = Object.assign(
version: '1.0.0', version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.', description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
}, },
servers: [
{
url: NEXT_PUBLIC_WEBAPP_URL(),
},
],
}, },
{ {
setOperationId: true, setOperationId: true,

View File

@@ -1,42 +0,0 @@
import { TeamMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type GetUserTokensOptions = {
userId: number;
teamId: number;
};
export type GetTeamTokensResponse = Awaited<ReturnType<typeof getTeamTokens>>;
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have the required permissions to view this page.',
});
}
return await prisma.apiToken.findMany({
where: {
teamId,
},
select: {
id: true,
name: true,
algorithm: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -1,24 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetUserTokensOptions = {
userId: number;
};
export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
userId,
teamId: null,
},
select: {
id: true,
name: true,
algorithm: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -0,0 +1,39 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type GetApiTokensOptions = {
userId: number;
teamId?: number;
};
export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
role: TeamMemberRole.ADMIN,
},
},
},
}
: {
userId,
teamId: null,
}),
},
select: {
id: true,
name: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -1,7 +1,7 @@
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { getApiTokenById } from '@documenso/lib/server-only/public-api/get-api-token-by-id'; import { getApiTokenById } from '@documenso/lib/server-only/public-api/get-api-token-by-id';
import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { import {
@@ -12,7 +12,7 @@ import {
export const apiTokenRouter = router({ export const apiTokenRouter = router({
getTokens: authenticatedProcedure.query(async ({ ctx }) => { getTokens: authenticatedProcedure.query(async ({ ctx }) => {
return await getUserTokens({ userId: ctx.user.id }); return await getApiTokens({ userId: ctx.user.id, teamId: ctx.teamId });
}), }),
getTokenById: authenticatedProcedure getTokenById: authenticatedProcedure