);
@@ -54,17 +58,41 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (!verified) {
return (
+
-
+
-
Your token has expired!
+
Email Confirmed!
- It seems that the provided token has expired. We've just sent you another token, please
- check your email and try again.
+ Your email has been successfully confirmed! You can now use all features of Documenso.
@@ -72,26 +100,6 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
- );
- }
-
- return (
-
-
-
-
-
-
-
Email Confirmed!
-
-
- Your email has been successfully confirmed! You can now use all features of Documenso.
-
-
-
- Go back home
-
-
);
}
diff --git a/apps/web/src/app/(unauthenticated)/verify-email/layout.tsx b/apps/web/src/app/(unauthenticated)/verify-email/layout.tsx
deleted file mode 100644
index e1013720f..000000000
--- a/apps/web/src/app/(unauthenticated)/verify-email/layout.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-
-import Image from 'next/image';
-
-import backgroundPattern from '@documenso/assets/images/background-pattern.png';
-import { Card } from '@documenso/ui/primitives/card';
-
-import { NewHeader } from '../../../components/(dashboard)/layout/new/new-header';
-
-type VerifyEmailLayoutProps = {
- children: React.ReactNode;
-};
-
-export default function VerifyEmailLayout({ children }: VerifyEmailLayoutProps) {
- return (
- <>
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
index 30d2baf16..f002ffda6 100644
--- a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
@@ -11,22 +11,26 @@ export const metadata: Metadata = {
export default function EmailVerificationWithoutTokenPage() {
return (
-
-
-
-
+
+
+
+
+
-
-
Uh oh! Looks like you're missing a token
+
+
+ Uh oh! Looks like you're missing a token
+
-
- It seems that there is no token provided, if you are trying to verify your email please
- follow the link in your email.
-
+
+ It seems that there is no token provided, if you are trying to verify your email please
+ follow the link in your email.
+
-
- Go back home
-
+
+ Go back home
+
+
);
diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
index 08e258f4b..e87c47b67 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { Braces, CreditCard, Globe2, Lock, User, Users } from 'lucide-react';
+import { Braces, CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -91,25 +91,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
)}
-
-
-
-
- Public profile
-
- Coming soon!
-
-
-
);
};
diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
index c607fc175..ad5ca96f6 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { Braces, CreditCard, Globe2, Lock, User, Users } from 'lucide-react';
+import { Braces, CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -94,25 +94,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
)}
-
-
-
-
- Public profile
-
- Coming soon!
-
-
-
);
};
diff --git a/apps/web/src/components/forms/public-profile-claim-dialog.tsx b/apps/web/src/components/forms/public-profile-claim-dialog.tsx
new file mode 100644
index 000000000..54a602dee
--- /dev/null
+++ b/apps/web/src/components/forms/public-profile-claim-dialog.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import React, { useState } from 'react';
+
+import Image from 'next/image';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { User } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { UserProfileSkeleton } from '../ui/user-profile-skeleton';
+
+export const ZClaimPublicProfileFormSchema = z.object({
+ url: z.string().trim().min(1, { message: 'Please enter a valid URL slug.' }),
+});
+
+export type TClaimPublicProfileFormSchema = z.infer
;
+
+export type ClaimPublicProfileDialogFormProps = {
+ open: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onClaimed?: () => void;
+ user: User;
+};
+
+export const ClaimPublicProfileDialogForm = ({
+ open,
+ onOpenChange,
+ onClaimed,
+ user,
+}: ClaimPublicProfileDialogFormProps) => {
+ const { toast } = useToast();
+
+ const [claimed, setClaimed] = useState(false);
+
+ const form = useForm({
+ values: {
+ url: user.url || '',
+ },
+ resolver: zodResolver(ZClaimPublicProfileFormSchema),
+ });
+
+ const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
+
+ const isSubmitting = form.formState.isSubmitting;
+
+ const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
+ try {
+ await updatePublicProfile({
+ url,
+ });
+
+ setClaimed(true);
+ onClaimed?.();
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
+ form.setError('url', {
+ type: 'manual',
+ message: 'This URL is already taken',
+ });
+ } else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
+ toast({
+ title: 'An error occurred',
+ description: err.message,
+ variant: 'destructive',
+ });
+ } else {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to save your details. Please try again later.',
+ });
+ }
+ }
+ };
+
+ return (
+
+
+ {!claimed && (
+ <>
+
+
+ Introducing public profiles!
+
+
+
+ Reserve your Documenso public profile username
+
+
+
+
+
+
+
+ >
+ )}
+
+ {claimed && (
+ <>
+
+ All set!
+
+
+ We will let you know as soon as this features is launched
+
+
+
+
+
+
+ onOpenChange?.(false)}>
+ Can't wait!
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index 90184e402..1d6d32f1f 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -184,147 +184,147 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
};
return (
-
+
+ {isSubmitting ? 'Signing in...' : 'Sign In'}
+
+
+ {isGoogleSSOEnabled && (
+ <>
+
+
+
+
+ Google
+
+ >
+ )}
+
+
+
+
+
+ Two-Factor Authentication
+
+
+
+
+ {twoFactorAuthenticationMethod === 'totp' && (
+ (
+
+ Authentication Token
+
+
+
+
+
+ )}
+ />
+ )}
+
+ {twoFactorAuthenticationMethod === 'backup' && (
+ (
+
+ Backup Code
+
+
+
+
+
+ )}
+ />
+ )}
+
+
+
+ {twoFactorAuthenticationMethod === 'totp'
+ ? 'Use Backup Code'
+ : 'Use Authenticator'}
+
+
+
+ {isSubmitting ? 'Signing in...' : 'Sign In'}
+
+
+
+
+
+
+
);
};
diff --git a/apps/web/src/components/ui/user-profile-skeleton.tsx b/apps/web/src/components/ui/user-profile-skeleton.tsx
new file mode 100644
index 000000000..9b5ce1f61
--- /dev/null
+++ b/apps/web/src/components/ui/user-profile-skeleton.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { File } from 'lucide-react';
+
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import type { User } from '@documenso/prisma/client';
+import { VerifiedIcon } from '@documenso/ui/icons/verified';
+import { cn } from '@documenso/ui/lib/utils';
+import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+
+export type UserProfileSkeletonProps = {
+ className?: string;
+ user: Pick;
+ rows?: number;
+};
+
+export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
+ const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
+
+ return (
+
+
+ {baseUrl.host}/u/{user.url}
+
+
+
+
+
+
+ {user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase()}
+
+
+
+
+
+
+
+
{user.name}
+
+
+
+
+
+
+
+
+
+
+
+ Documents
+
+
+ {Array(rows)
+ .fill(0)
+ .map((_, index) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/assets/images/community-cards.png b/packages/assets/images/community-cards.png
new file mode 100644
index 000000000..fe9b7edb4
Binary files /dev/null and b/packages/assets/images/community-cards.png differ
diff --git a/packages/assets/images/profile-claim-teaser.png b/packages/assets/images/profile-claim-teaser.png
new file mode 100644
index 000000000..b388de0d2
Binary files /dev/null and b/packages/assets/images/profile-claim-teaser.png differ
diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts
index 3337bab4c..bc2db70c2 100644
--- a/packages/lib/errors/app-error.ts
+++ b/packages/lib/errors/app-error.ts
@@ -18,6 +18,7 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests',
+ 'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
}
const genericErrorCodeToTrpcErrorCodeMap: Record = {
@@ -32,6 +33,7 @@ const genericErrorCodeToTrpcErrorCodeMap: Record = {
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
+ [AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
};
export const ZAppErrorJsonSchema = z.object({
diff --git a/packages/lib/server-only/user/update-public-profile.ts b/packages/lib/server-only/user/update-public-profile.ts
index 910932e8c..0aebe3ecf 100644
--- a/packages/lib/server-only/user/update-public-profile.ts
+++ b/packages/lib/server-only/user/update-public-profile.ts
@@ -1,35 +1,49 @@
import { prisma } from '@documenso/prisma';
-import type { User, UserProfile } from '@documenso/prisma/client';
-import { getUserById } from './get-user-by-id';
+import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = {
- id: User['id'];
- profileURL: UserProfile['profileURL'];
+ userId: number;
+ url: string;
};
-export const updatePublicProfile = async ({ id, profileURL }: UpdatePublicProfileOptions) => {
- const user = await getUserById({ id });
- // Existence check
- await prisma.userProfile.findFirstOrThrow({
+export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
+ const isUrlTaken = await prisma.user.findFirst({
+ select: {
+ id: true,
+ },
where: {
- profileURL: user.profileURL ?? undefined,
+ id: {
+ not: userId,
+ },
+ url,
},
});
- return await prisma.$transaction(async (tx) => {
- await tx.userProfile.create({
- data: {
- profileURL,
+ if (isUrlTaken) {
+ throw new AppError(
+ AppErrorCode.PROFILE_URL_TAKEN,
+ 'Profile URL is taken',
+ 'The profile URL is already taken',
+ );
+ }
+
+ return await prisma.user.update({
+ where: {
+ id: userId,
+ },
+ data: {
+ url,
+ userProfile: {
+ upsert: {
+ create: {
+ bio: '',
+ },
+ update: {
+ bio: '',
+ },
+ },
},
- });
- await tx.userProfile.update({
- where: {
- profileURL: user.profileURL ?? undefined,
- },
- data: {
- profileURL: profileURL,
- },
- });
+ },
});
};
diff --git a/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql b/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql
new file mode 100644
index 000000000..6bf9c0759
--- /dev/null
+++ b/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql
@@ -0,0 +1,37 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `profileURL` on the `User` table. All the data in the column will be lost.
+ - The primary key for the `UserProfile` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - You are about to drop the column `profileBio` on the `UserProfile` table. All the data in the column will be lost.
+ - You are about to drop the column `profileURL` on the `UserProfile` table. All the data in the column will be lost.
+ - A unique constraint covering the columns `[url]` on the table `User` will be added. If there are existing duplicate values, this will fail.
+ - Added the required column `id` to the `UserProfile` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- DropForeignKey
+ALTER TABLE "User" DROP CONSTRAINT "User_profileURL_fkey";
+
+-- DropIndex
+DROP INDEX "User_profileURL_key";
+
+-- DropIndex
+DROP INDEX "UserProfile_profileURL_key";
+
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "profileURL",
+ADD COLUMN "url" TEXT;
+
+-- AlterTable
+ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_pkey",
+DROP COLUMN "profileBio",
+DROP COLUMN "profileURL",
+ADD COLUMN "bio" TEXT,
+ADD COLUMN "id" INTEGER NOT NULL,
+ADD CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_url_key" ON "User"("url");
+
+-- AddForeignKey
+ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_id_fkey" FOREIGN KEY ("id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 1e250821c..75dd9d1a5 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -43,9 +43,9 @@ model User {
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
- profileURL String? @unique
+ url String? @unique
- UserProfile UserProfile? @relation(fields: [profileURL], references: [profileURL], onDelete: Cascade)
+ userProfile UserProfile?
VerificationToken VerificationToken[]
ApiToken ApiToken[]
Template Template[]
@@ -56,10 +56,10 @@ model User {
}
model UserProfile {
- profileURL String @id @unique
- profileBio String?
+ id Int @id
+ bio String?
- User User?
+ User User? @relation(fields: [id], references: [id], onDelete: Cascade)
}
enum UserSecurityAuditLogType {
diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts
index 9730a97ec..2b83caa84 100644
--- a/packages/trpc/server/profile-router/router.ts
+++ b/packages/trpc/server/profile-router/router.ts
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
@@ -80,14 +81,20 @@ export const profileRouter = router({
.input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
- const { profileURL } = input;
+ const { url } = input;
- return await updatePublicProfile({
- id: ctx.user.id,
- profileURL,
+ const user = await updatePublicProfile({
+ userId: ctx.user.id,
+ url,
});
+
+ return { success: true, url: user.url };
} catch (err) {
- console.error(err);
+ const error = AppError.parseError(err);
+
+ if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
+ throw AppError.parseErrorToTRPCError(error);
+ }
throw new TRPCError({
code: 'BAD_REQUEST',
diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts
index 1d139d20d..ecee47f34 100644
--- a/packages/trpc/server/profile-router/schema.ts
+++ b/packages/trpc/server/profile-router/schema.ts
@@ -17,7 +17,7 @@ export const ZUpdateProfileMutationSchema = z.object({
});
export const ZUpdatePublicProfileMutationSchema = z.object({
- profileURL: z.string().min(1),
+ url: z.string().min(1),
});
export const ZUpdatePasswordMutationSchema = z.object({
diff --git a/packages/ui/icons/verified.tsx b/packages/ui/icons/verified.tsx
new file mode 100644
index 000000000..5984e603d
--- /dev/null
+++ b/packages/ui/icons/verified.tsx
@@ -0,0 +1,31 @@
+import { forwardRef } from 'react';
+
+import type { LucideIcon } from 'lucide-react/dist/lucide-react';
+
+export const VerifiedIcon: LucideIcon = forwardRef(
+ ({ size = 24, color = 'currentColor', ...props }, ref) => {
+ return (
+
+
+
+
+
+ );
+ },
+);
+
+VerifiedIcon.displayName = 'VerifiedIcon';
diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx
index 1a5fba1bb..71b3cb521 100644
--- a/packages/ui/primitives/input.tsx
+++ b/packages/ui/primitives/input.tsx
@@ -10,7 +10,7 @@ const Input = React.forwardRef(