mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into feat/separate-document-page
This commit is contained in:
@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = new Array(10)
|
||||
const backupCodes = Array.from({ length: 10 })
|
||||
.fill(null)
|
||||
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||
|
||||
@ -5,11 +5,16 @@ import { render } from '@documenso/email/render';
|
||||
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendConfirmationEmailProps {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||
const NEXT_PRIVATE_SMTP_FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME;
|
||||
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS;
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
@ -30,10 +35,10 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
throw new Error('Verification token not found for the user');
|
||||
}
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
|
||||
@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
|
||||
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendForgotPasswordOptions {
|
||||
userId: number;
|
||||
}
|
||||
@ -29,8 +31,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
|
||||
}
|
||||
|
||||
const token = user.PasswordResetToken[0].token;
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const resetPasswordLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/reset-password/${token}`;
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
|
||||
|
||||
const template = createElement(ForgotPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
|
||||
@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
|
||||
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendResetPasswordOptions {
|
||||
userId: number;
|
||||
}
|
||||
@ -16,7 +18,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
|
||||
},
|
||||
});
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(ResetPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
|
||||
@ -8,6 +8,7 @@ import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
@ -94,7 +95,7 @@ export const deleteDocument = async ({
|
||||
if (document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
|
||||
@ -18,6 +18,8 @@ import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
@ -94,8 +96,8 @@ export const resendDocument = async ({
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
|
||||
@ -5,6 +5,7 @@ import { render } from '@documenso/email/render';
|
||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
@ -40,12 +41,12 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name, token } = recipient;
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
||||
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@ -4,10 +4,6 @@ import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
@ -15,6 +11,12 @@ import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-em
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
@ -91,8 +93,8 @@ export const sendDocument = async ({
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
|
||||
@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
@ -41,7 +43,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
documentName: document.title,
|
||||
|
||||
@ -5,6 +5,7 @@ import { getToken } from 'next-auth/jwt';
|
||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
|
||||
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
||||
|
||||
/**
|
||||
@ -38,11 +39,11 @@ export default async function handlerFeatureFlagAll(req: Request) {
|
||||
const origin = req.headers.get('origin');
|
||||
|
||||
if (origin) {
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
|
||||
if (origin.startsWith(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
|
||||
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { JWT, getToken } from 'next-auth/jwt';
|
||||
import type { JWT } from 'next-auth/jwt';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
|
||||
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
/**
|
||||
* Evaluate a single feature flag based on the current user if possible.
|
||||
*
|
||||
@ -57,11 +60,11 @@ export default async function handleFeatureFlagGet(req: Request) {
|
||||
const origin = req.headers.get('Origin');
|
||||
|
||||
if (origin) {
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
|
||||
if (origin.startsWith(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
|
||||
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export async function insertTextInPDF(
|
||||
useHandwritingFont = true,
|
||||
): Promise<string> {
|
||||
// Fetch the font file from the public URL.
|
||||
const fontResponse = await fetch(CAVEAT_FONT_PATH);
|
||||
const fontResponse = await fetch(CAVEAT_FONT_PATH());
|
||||
const fontCaveat = await fontResponse.arrayBuffer();
|
||||
|
||||
const pdfDoc = await PDFDocument.load(pdfAsBase64);
|
||||
|
||||
@ -46,7 +46,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscription) {
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
|
||||
@ -12,7 +12,7 @@ export const createTeamBillingPortal = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: CreateTeamBillingPortalOptions) => {
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new Error('Billing is not enabled');
|
||||
}
|
||||
|
||||
|
||||
@ -2,11 +2,11 @@ import type Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
|
||||
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
|
||||
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
@ -57,17 +57,16 @@ export const createTeam = async ({
|
||||
},
|
||||
});
|
||||
|
||||
let isPaymentRequired = IS_BILLING_ENABLED;
|
||||
let isPaymentRequired = IS_BILLING_ENABLED();
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
|
||||
user.Subscription,
|
||||
communityPlanPriceIds,
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
|
||||
prices.map((price) => price.id),
|
||||
);
|
||||
|
||||
isPaymentRequired = !subscriptionsContainsActivePlan(user.Subscription, teamRelatedPriceIds);
|
||||
|
||||
customerId = await createTeamCustomer({
|
||||
name: user.name ?? teamName,
|
||||
email: user.email,
|
||||
|
||||
@ -85,7 +85,7 @@ export const deleteTeamMembers = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscription) {
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
|
||||
@ -42,7 +42,7 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscription) {
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
|
||||
@ -49,7 +49,7 @@ export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOpti
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
teamSubscription = await transferTeamSubscription({
|
||||
user: newOwnerUser,
|
||||
team,
|
||||
|
||||
@ -68,7 +68,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
);
|
||||
|
||||
// Update the user record with a new or existing Stripe customer record.
|
||||
if (IS_BILLING_ENABLED) {
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
try {
|
||||
return await getStripeCustomerByUser(user).then((session) => session.user);
|
||||
} catch (err) {
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetMostRecentVerificationTokenByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getMostRecentVerificationTokenByUserId = async ({
|
||||
userId,
|
||||
}: GetMostRecentVerificationTokenByUserIdOptions) => {
|
||||
return await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,13 +1,20 @@
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
|
||||
export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
||||
type SendConfirmationTokenOptions = { email: string; force?: boolean };
|
||||
|
||||
export const sendConfirmationToken = async ({
|
||||
email,
|
||||
force = false,
|
||||
}: SendConfirmationTokenOptions) => {
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
throw new Error('Email verified');
|
||||
}
|
||||
|
||||
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
!force &&
|
||||
mostRecentToken?.createdAt &&
|
||||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
||||
throw new Error(`Failed to create the verification token`);
|
||||
}
|
||||
|
||||
return sendConfirmationEmail({ userId: user.id });
|
||||
try {
|
||||
await sendConfirmationEmail({ userId: user.id });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to send the confirmation email`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user