Merge branch 'main' of https://github.com/documenso/documenso into feat/typefully

This commit is contained in:
Adithya Krishna
2024-03-05 14:58:27 +05:30
99 changed files with 2702 additions and 450 deletions

View File

@ -8,3 +8,5 @@ import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};
export default OpenApiDocsPage;

View File

@ -34,6 +34,7 @@ export const manualLogin = async ({
};
export const manualSignout = async ({ page }: ManualLoginOptions) => {
await page.waitForTimeout(1000);
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);

View File

@ -29,7 +29,10 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByLabel('Public profile username').fill('username-123');
await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.waitForURL('/unverified-account');

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -1,5 +1,5 @@
import { trpc } from '@documenso/trpc/react';
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import { useCopyToClipboard } from './use-copy-to-clipboard';

View File

@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_teams: true,
app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;
/**

View File

@ -18,6 +18,8 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests',
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
}
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
@ -32,6 +34,8 @@ const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
[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',
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
};
export const ZAppErrorJsonSchema = z.object({

View File

@ -0,0 +1,26 @@
import { prisma } from '@documenso/prisma';
export type GetEntireDocumentOptions = {
id: number;
};
export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
const document = await prisma.document.findFirstOrThrow({
where: {
id,
},
include: {
Recipient: {
include: {
Field: {
include: {
Signature: true,
},
},
},
},
},
});
return document;
};

View File

@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type UpdateRecipientOptions = {
id: number;
name: string | undefined;
email: string | undefined;
};
export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id,
},
});
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error('Cannot update a recipient that has already signed.');
}
return await prisma.recipient.update({
where: {
id,
},
data: {
name,
email,
},
});
};

View File

@ -70,6 +70,6 @@ export const getDocumentAndRecipientByToken = async ({
return {
...result,
Recipient: result.Recipient[0],
Recipient: result.Recipient,
};
};

View File

@ -2,7 +2,7 @@
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -22,12 +22,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
sendEmail?: boolean;
isResealing?: boolean;
requestMetadata?: RequestMetadata;
};
export const sealDocument = async ({
documentId,
sendEmail = true,
isResealing = false,
requestMetadata,
}: SealDocumentOptions) => {
'use server';
@ -78,11 +80,43 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has unsigned fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
documentData.data = documentData.initialData;
}
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const doc = await PDFDocument.load(pdfData);
const form = doc.getForm();
// Remove old signatures
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch (e) {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
// Flatten the form to stop annotation layers from appearing above documenso fields
form.flatten();
for (const field of fields) {
await insertFieldInPDF(doc, field);
}
@ -134,7 +168,7 @@ export const sealDocument = async ({
});
});
if (sendEmail) {
if (sendEmail && !isResealing) {
await sendCompletedEmail({ documentId, requestMetadata });
}

View File

@ -7,15 +7,17 @@ import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/pri
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateUserOptions {
name: string;
email: string;
password: string;
signature?: string | null;
url?: string;
}
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
export const createUser = async ({ name, email, password, signature, url }: CreateUserOptions) => {
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
@ -28,6 +30,22 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new Error('User already exists');
}
if (url) {
const urlExists = await prisma.user.findFirst({
where: {
url,
},
});
if (urlExists) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
}
}
const user = await prisma.user.create({
data: {
name,
@ -35,6 +53,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
password: hashedPassword,
signature,
identityProvider: IdentityProvider.DOCUMENSO,
url,
},
});

View File

@ -4,20 +4,18 @@ import { DocumentStatus } from '@documenso/prisma/client';
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
export type DeleteUserOptions = {
email: string;
id: number;
};
export const deleteUser = async ({ email }: DeleteUserOptions) => {
export const deleteUser = async ({ id }: DeleteUserOptions) => {
const user = await prisma.user.findFirst({
where: {
email: {
contains: email,
},
id,
},
});
if (!user) {
throw new Error(`User with email ${email} not found`);
throw new Error(`User with ID ${id} not found`);
}
const serviceAccount = await deletedAccountServiceAccount();

View File

@ -0,0 +1,49 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = {
userId: number;
url: string;
};
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
const isUrlTaken = await prisma.user.findFirst({
select: {
id: true,
},
where: {
id: {
not: userId,
},
url,
},
});
if (isUrlTaken) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
}
return await prisma.user.update({
where: {
id: userId,
},
data: {
url,
userProfile: {
upsert: {
create: {
bio: '',
},
update: {
bio: '',
},
},
},
},
});
};

View File

@ -2,6 +2,7 @@ import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
export type TriggerWebhookOptions = {
event: WebhookTriggerEvents;
@ -19,6 +20,12 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
teamId,
};
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
if (registeredWebhooks.length === 0) {
return;
}
const signature = sign(body);
await Promise.race([

View File

@ -0,0 +1,25 @@
/*
Warnings:
- A unique constraint covering the columns `[profileURL]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "profileURL" TEXT;
-- CreateTable
CREATE TABLE "UserProfile" (
"profileURL" TEXT NOT NULL,
"profileBio" TEXT,
CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("profileURL")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserProfile_profileURL_key" ON "UserProfile"("profileURL");
-- CreateIndex
CREATE UNIQUE INDEX "User_profileURL_key" ON "User"("profileURL");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_profileURL_fkey" FOREIGN KEY ("profileURL") REFERENCES "UserProfile"("profileURL") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -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;

View File

@ -43,7 +43,9 @@ model User {
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
url String? @unique
userProfile UserProfile?
VerificationToken VerificationToken[]
ApiToken ApiToken[]
Template Template[]
@ -54,6 +56,13 @@ model User {
@@index([email])
}
model UserProfile {
id Int @id
bio String?
User User? @relation(fields: [id], references: [id], onDelete: Cascade)
}
enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK

View File

@ -49,6 +49,7 @@ export const seedDatabase = async () => {
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
url: u.email,
},
}),
),

View File

@ -49,6 +49,7 @@ export const seedDatabase = async () => {
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
url: u.email,
},
}),
),

View File

@ -23,6 +23,7 @@ export const seedDatabase = async () => {
email: TEST_USER.email,
password: hashSync(TEST_USER.password),
emailVerified: new Date(),
url: TEST_USER.email,
},
});
};

View File

@ -21,6 +21,7 @@ export const seedUser = async ({
email,
password: hashSync(password),
emailVerified: verified ? new Date() : undefined,
url: name,
},
});
};

View File

@ -5,6 +5,6 @@ export type DocumentWithRecipients = Document & {
};
export type DocumentWithRecipient = Document & {
Recipient: Recipient;
Recipient: Recipient[];
documentData: DocumentData;
};

View File

@ -1,5 +1,13 @@
import signer from 'node-signpdf';
import { PDFArray, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFString } from 'pdf-lib';
import {
PDFArray,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
rectangle,
} from 'pdf-lib';
export type AddSigningPlaceholderOptions = {
pdf: Buffer;
@ -39,6 +47,12 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
P: pages[0].ref,
});
const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = widget.context.register(xobj);
widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef }));
const widgetRef = doc.context.register(widget);
let widgets = pages[0].node.get(PDFName.of('Annots'));

View File

@ -1,14 +1,39 @@
import { TRPCError } from '@trpc/server';
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { adminProcedure, router } from '../trpc';
import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema';
import {
ZAdminDeleteUserMutationSchema,
ZAdminFindDocumentsQuerySchema,
ZAdminResealDocumentMutationSchema,
ZAdminUpdateProfileMutationSchema,
ZAdminUpdateRecipientMutationSchema,
ZAdminUpdateSiteSettingMutationSchema,
} from './schema';
export const adminRouter = router({
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
const { term, page, perPage } = input;
try {
return await findDocuments({ term, page, perPage });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the documents. Please try again.',
});
}
}),
updateUser: adminProcedure
.input(ZUpdateProfileMutationByAdminSchema)
.input(ZAdminUpdateProfileMutationSchema)
.mutation(async ({ input }) => {
const { id, name, email, roles } = input;
@ -22,8 +47,23 @@ export const adminRouter = router({
}
}),
updateRecipient: adminProcedure
.input(ZAdminUpdateRecipientMutationSchema)
.mutation(async ({ input }) => {
const { id, name, email } = input;
try {
return await updateRecipient({ id, name, email });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update the recipient provided.',
});
}
}),
updateSiteSetting: adminProcedure
.input(ZUpdateSiteSettingMutationSchema)
.input(ZAdminUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { id, enabled, data } = input;
@ -41,4 +81,41 @@ export const adminRouter = router({
});
}
}),
resealDocument: adminProcedure
.input(ZAdminResealDocumentMutationSchema)
.mutation(async ({ input }) => {
const { id } = input;
try {
return await sealDocument({ documentId: id, isResealing: true });
} catch (err) {
console.log('resealDocument error', err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to reseal the document provided.',
});
}
}),
deleteUser: adminProcedure.input(ZAdminDeleteUserMutationSchema).mutation(async ({ input }) => {
const { id, email } = input;
try {
const user = await getUserById({ id });
if (user.email !== email) {
throw new Error('Email does not match');
}
return await deleteUser({ id });
} catch (err) {
console.log(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to delete the specified account. Please try again.',
});
}
}),
});

View File

@ -3,17 +3,48 @@ import z from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
export const ZUpdateProfileMutationByAdminSchema = z.object({
export const ZAdminFindDocumentsQuerySchema = z.object({
term: z.string().optional(),
page: z.number().optional().default(1),
perPage: z.number().optional().default(20),
});
export type TAdminFindDocumentsQuerySchema = z.infer<typeof ZAdminFindDocumentsQuerySchema>;
export const ZAdminUpdateProfileMutationSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
export type TUpdateProfileMutationByAdminSchema = z.infer<
typeof ZUpdateProfileMutationByAdminSchema
export type TAdminUpdateProfileMutationSchema = z.infer<typeof ZAdminUpdateProfileMutationSchema>;
export const ZAdminUpdateRecipientMutationSchema = z.object({
id: z.number().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
});
export type TAdminUpdateRecipientMutationSchema = z.infer<
typeof ZAdminUpdateRecipientMutationSchema
>;
export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
export const ZAdminUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
export type TUpdateSiteSettingMutationSchema = z.infer<typeof ZUpdateSiteSettingMutationSchema>;
export type TAdminUpdateSiteSettingMutationSchema = z.infer<
typeof ZAdminUpdateSiteSettingMutationSchema
>;
export const ZAdminResealDocumentMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminResealDocumentMutationSchema = z.infer<typeof ZAdminResealDocumentMutationSchema>;
export const ZAdminDeleteUserMutationSchema = z.object({
id: z.number().min(1),
email: z.string().email(),
});
export type TAdminDeleteUserMutationSchema = z.infer<typeof ZAdminDeleteUserMutationSchema>;

View File

@ -1,6 +1,8 @@
import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { createUser } from '@documenso/lib/server-only/user/create-user';
@ -21,14 +23,29 @@ export const authRouter = router({
});
}
const { name, email, password, signature } = input;
const { name, email, password, signature, url } = input;
const user = await createUser({ name, email, password, signature });
if (IS_BILLING_ENABLED() && url && url.length < 6) {
throw new AppError(
AppErrorCode.PREMIUM_PROFILE_URL,
'Only subscribers can have a username shorter than 6 characters',
);
}
const user = await createUser({ name, email, password, signature, url });
await sendConfirmationToken({ email: user.email });
return user;
} catch (err) {
console.log(err);
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
throw AppError.parseErrorToTRPCError(error);
}
let message =
'We were unable to create your account. Please review the information you provided and try again.';

View File

@ -21,6 +21,15 @@ export const ZSignUpMutationSchema = z.object({
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().min(1, { message: 'A signature is required.' }),
url: z
.string()
.trim()
.toLowerCase()
.min(1)
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
})
.optional(),
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;

View File

@ -1,5 +1,8 @@
import { TRPCError } from '@trpc/server';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
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';
@ -8,7 +11,9 @@ import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { updatePublicProfile } from '@documenso/lib/server-only/user/update-public-profile';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import {
@ -19,6 +24,7 @@ import {
ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
ZUpdatePublicProfileMutationSchema,
} from './schema';
export const profileRouter = router({
@ -74,6 +80,48 @@ export const profileRouter = router({
}
}),
updatePublicProfile: authenticatedProcedure
.input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { url } = input;
if (IS_BILLING_ENABLED() && url.length <= 6) {
const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id,
}).then((subscriptions) =>
subscriptions.filter((s) => s.status === SubscriptionStatus.ACTIVE),
);
if (subscriptions.length === 0) {
throw new AppError(
AppErrorCode.PREMIUM_PROFILE_URL,
'Only subscribers can have a username shorter than 6 characters',
);
}
}
const user = await updatePublicProfile({
userId: ctx.user.id,
url,
});
return { success: true, url: user.url };
} catch (err) {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
throw AppError.parseErrorToTRPCError(error);
}
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update your public profile. Please review the information you provided and try again.',
});
}
}),
updatePassword: authenticatedProcedure
.input(ZUpdatePasswordMutationSchema)
.mutation(async ({ input, ctx }) => {
@ -159,9 +207,9 @@ export const profileRouter = router({
deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => {
try {
const user = ctx.user;
return await deleteUser(user);
return await deleteUser({
id: ctx.user.id,
});
} catch (err) {
let message = 'We were unable to delete your account. Please try again.';

View File

@ -16,6 +16,17 @@ export const ZUpdateProfileMutationSchema = z.object({
signature: z.string(),
});
export const ZUpdatePublicProfileMutationSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
});
export const ZUpdatePasswordMutationSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,

View File

@ -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 (
<svg
ref={ref}
width={size}
height={size}
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="badge, verified, award">
<path
id="Icon"
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.5457 2.89094C11.5779 1.70302 13.4223 1.70302 14.4545 2.89094L15.2585 3.81628C15.3917 3.96967 15.5947 4.04354 15.7954 4.0117L17.0061 3.81965C18.5603 3.57309 19.9732 4.75869 20.0003 6.33214L20.0214 7.55778C20.0249 7.76096 20.1329 7.94799 20.3071 8.05261L21.358 8.6837C22.7071 9.49389 23.0274 11.3103 22.0368 12.5331L21.2651 13.4855C21.1372 13.6434 21.0997 13.8561 21.1659 14.0482L21.5652 15.2072C22.0779 16.695 21.1557 18.2923 19.6109 18.5922L18.4075 18.8258C18.208 18.8646 18.0426 19.0034 17.9698 19.1931L17.5308 20.3376C16.9672 21.8069 15.234 22.4378 13.8578 21.6745L12.7858 21.08C12.6081 20.9814 12.3921 20.9814 12.2144 21.08L11.1424 21.6745C9.76623 22.4378 8.033 21.8069 7.4694 20.3376L7.03038 19.1931C6.9576 19.0034 6.79216 18.8646 6.59268 18.8258L5.38932 18.5922C3.84448 18.2923 2.92224 16.695 3.43495 15.2072L3.83431 14.0482C3.90052 13.8561 3.86302 13.6434 3.7351 13.4855L2.96343 12.5331C1.97279 11.3103 2.29307 9.49389 3.64218 8.6837L4.69306 8.05261C4.86728 7.94799 4.97526 7.76096 4.97875 7.55778L4.99985 6.33214C5.02694 4.75869 6.43987 3.57309 7.99413 3.81965L9.20481 4.0117C9.40551 4.04354 9.60845 3.96967 9.74173 3.81628L10.5457 2.89094ZM15.7072 11.2071C16.0977 10.8166 16.0977 10.1834 15.7072 9.79289C15.3167 9.40237 14.6835 9.40237 14.293 9.79289L11.5001 12.5858L10.7072 11.7929C10.3167 11.4024 9.68351 11.4024 9.29298 11.7929C8.90246 12.1834 8.90246 12.8166 9.29298 13.2071L10.4394 14.3536C11.0252 14.9393 11.975 14.9393 12.5608 14.3536L15.7072 11.2071Z"
fill={color}
/>
</g>
</svg>
);
},
);
VerifiedIcon.displayName = 'VerifiedIcon';

View File

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],

View File

@ -3,7 +3,8 @@
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';