diff --git a/.npmrc b/.npmrc index cbc6b6537..75baad7f0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps = true prefer-dedupe = true -min-release-age = 7 +# min-release-age = 7 diff --git a/apps/docs/package.json b/apps/docs/package.json index 76aecb716..da9966679 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "next build", + "build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build", "dev": "next dev", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", @@ -29,7 +29,7 @@ "@types/node": "^25.1.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", - "postcss": "^8.5.14", + "postcss": "^8.5.19", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index 7d9c89316..ce71134c8 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -83,7 +83,7 @@ --accent: hsl(0 0% 27.8431%); --accent-foreground: hsl(95.0847 71.0843% 67.451%); --destructive: hsl(0 86.5979% 61.9608%); - --destructive-foreground: hsl(0 87.6289% 19.0196%); + --destructive-foreground: hsl(0 0% 98.0392%); --border: hsl(0 0% 27.8431%); --input: hsl(0 0% 27.8431%); --ring: hsl(95.0847 71.0843% 67.451%); diff --git a/apps/remix/app/components/dialogs/token-create-dialog.tsx b/apps/remix/app/components/dialogs/token-create-dialog.tsx new file mode 100644 index 000000000..0131638e2 --- /dev/null +++ b/apps/remix/app/components/dialogs/token-create-dialog.tsx @@ -0,0 +1,250 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import type { z } from 'zod'; + +import { useCurrentTeam } from '~/providers/team'; + +const NEVER_EXPIRE = 'NEVER' as const; + +export const EXPIRATION_DATES = { + ONE_WEEK: msg`7 days`, + ONE_MONTH: msg`1 month`, + THREE_MONTHS: msg`3 months`, + SIX_MONTHS: msg`6 months`, + ONE_YEAR: msg`12 months`, + [NEVER_EXPIRE]: msg`Never`, +} as const; + +const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({ + tokenName: true, + expirationDate: true, +}); + +type TCreateTokenFormSchema = z.infer; + +export type TokenCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const [open, setOpen] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + + const form = useForm({ + resolver: zodResolver(ZCreateTokenFormSchema), + defaultValues: { + tokenName: '', + expirationDate: 'THREE_MONTHS', + }, + }); + + const { mutateAsync: createToken } = trpc.apiToken.create.useMutation(); + + const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => { + try { + const { token } = await createToken({ + teamId: team.id, + tokenName, + expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate, + }); + + setCreatedToken(token); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`) + .otherwise(() => msg`Something went wrong. Please try again later.`); + + toast({ + title: _(msg`An error occurred`), + description: _(errorMessage), + variant: 'destructive', + duration: 5000, + }); + } + }; + + useEffect(() => { + if (open) { + form.reset(); + setCreatedToken(null); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} {...props}> + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + { + // Prevent losing the created token by accidentally clicking outside the dialog. + if (createdToken) { + event.preventDefault(); + } + }} + > + {createdToken ? ( + <> + + + Token created + + + + Copy your token now. For security reasons you will not be able to see it again. + + + +
+ +
+ toast({ title: _(msg`Token copied to clipboard`) })} + /> +
+
+ + + + + + ) : ( + <> + + + Create API token + + + + Use API tokens to authenticate with the Documenso API. + + + +
+ +
+ ( + + + Name + + + + + + + + A name to help you identify this token later. + + + + + )} + /> + + ( + + + Expires in + + + + + + + + + )} + /> + + + + + + +
+
+ + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index b4378ceee..0e3553a4d 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe - Are you sure you want to delete this token? + Delete token @@ -139,21 +139,18 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe /> -
- + - -
+
diff --git a/apps/remix/app/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx deleted file mode 100644 index 4239b0c76..000000000 --- a/apps/remix/app/components/forms/token.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { trpc } from '@documenso/trpc/react'; -import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; -import { Switch } from '@documenso/ui/primitives/switch'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { ApiToken } from '@prisma/client'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { match } from 'ts-pattern'; -import type { z } from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -export const EXPIRATION_DATES = { - ONE_WEEK: msg`7 days`, - ONE_MONTH: msg`1 month`, - THREE_MONTHS: msg`3 months`, - SIX_MONTHS: msg`6 months`, - ONE_YEAR: msg`12 months`, -} as const; - -const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({ - tokenName: true, - expirationDate: true, -}); - -type TCreateTokenFormSchema = z.infer; - -type NewlyCreatedToken = { - id: number; - token: string; -}; - -export type ApiTokenFormProps = { - className?: string; - tokens?: Pick[]; -}; - -export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { - const [, copy] = useCopyToClipboard(); - - const team = useCurrentTeam(); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const [newlyCreatedToken, setNewlyCreatedToken] = useState(); - const [noExpirationDate, setNoExpirationDate] = useState(false); - - const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({ - onSuccess(data) { - setNewlyCreatedToken(data); - }, - }); - - const form = useForm({ - resolver: zodResolver(ZCreateTokenFormSchema), - defaultValues: { - tokenName: '', - expirationDate: '', - }, - }); - - const copyToken = async (token: string) => { - try { - const copied = await copy(token); - - if (!copied) { - throw new Error('Unable to copy the token'); - } - - toast({ - title: _(msg`Token copied to clipboard`), - description: _(msg`The token was copied to your clipboard.`), - }); - } catch (error) { - toast({ - title: _(msg`Unable to copy token`), - description: _(msg`We were unable to copy the token to your clipboard. Please try again.`), - variant: 'destructive', - }); - } - }; - - const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => { - try { - await createTokenMutation({ - teamId: team.id, - tokenName, - expirationDate: noExpirationDate ? null : expirationDate, - }); - - toast({ - title: _(msg`Token created`), - description: _(msg`A new token was created successfully.`), - duration: 5000, - }); - - form.reset(); - } catch (err) { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`) - .otherwise(() => msg`Something went wrong. Please try again later.`); - - toast({ - title: _(msg`An error occurred`), - description: _(errorMessage), - variant: 'destructive', - duration: 5000, - }); - } - }; - - return ( -
-
- -
- ( - - - Token name - - -
- - - -
- - - Please enter a meaningful name for your token. This will help you identify it later. - - - -
- )} - /> - -
- ( - - - Token expiration date - - -
- - - -
- - -
- )} - /> - -
- - Never expire - -
- -
-
-
- - - -
- -
-
-
- - - - {newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && ( - - - -

- - Your token was created successfully! Make sure to copy it because you won't be able to see it again! - -

- -

- {newlyCreatedToken.token} -

- - -
-
-
- )} -
-
- ); -}; diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx index 86e949d7a..0ffb3602e 100644 --- a/apps/remix/app/entry.client.tsx +++ b/apps/remix/app/entry.client.tsx @@ -3,27 +3,32 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { i18n } from '@lingui/core'; import { detect, fromHtmlTag } from '@lingui/detect-locale'; import { I18nProvider } from '@lingui/react'; -import { StrictMode, startTransition, useEffect } from 'react'; +import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; import './utils/polyfills/promise-with-resolvers'; -function PosthogInit() { +/** + * Initialised imperatively (not as a component inside `hydrateRoot`) because + * rendering extra client-only siblings changes the React tree structure + * relative to the server render in `entry.server.tsx`. That shifts every + * `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration + * mismatches which can abort hydration entirely when the user interacts with + * the page early, leaving dead event handlers (broken dropdowns, native form + * submits). + */ +function initPosthog() { const postHogConfig = extractPostHogConfig(); - useEffect(() => { - if (postHogConfig) { - void import('posthog-js').then(({ default: posthog }) => { - posthog.init(postHogConfig.key, { - api_host: postHogConfig.host, - capture_exceptions: true, - }); + if (postHogConfig) { + void import('posthog-js').then(({ default: posthog }) => { + posthog.init(postHogConfig.key, { + api_host: postHogConfig.host, + capture_exceptions: true, }); - } - }, []); - - return null; + }); + } } async function main() { @@ -38,11 +43,11 @@ async function main() { - - , ); }); + + void initPosthog(); } // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 096b1504e..a7c29b4be 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -119,7 +119,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+')); return ( - + // `suppressHydrationWarning` because `remix-themes` intentionally mutates + // `data-theme`/`class` on before hydration (PreventFlashOnWrongTheme), + // so the server-rendered attributes never match the client render when the + // theme is resolved from the system preference. Attribute-only, one level deep. + @@ -173,7 +177,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {