feat: add more options to reverse shares (#495)
* feat(reverse-share): optional simplified interface for reverse sharing. issue #155. * chore: Remove useless form validation. * feat: Share Ready modal adds a prompt that an email has been sent to the reverse share creator. * fix: Simplified reverse shared interface elements lack spacing when not logged in. * fix: Share Ready modal prompt contrast is too low in dark mode. * feat: add public access options to reverse share. * feat: remember reverse share simplified and publicAccess options in cookies. * style: npm run format. * chore(i18n): Improve translation. Co-authored-by: Elias Schneider <login@eliasschneider.com> Update frontend/src/i18n/translations/en-US.ts Co-authored-by: Elias Schneider <login@eliasschneider.com> Update frontend/src/i18n/translations/en-US.ts Co-authored-by: Elias Schneider <login@eliasschneider.com> chore(i18n): Improve translation. * chore: Improved variable naming. * chore(i18n): Improve translation. x2. * fix(backend/shares): Misjudged the permission of the share of the reverse share.
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_ReverseShare" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"shareExpiration" DATETIME NOT NULL,
|
||||||
|
"maxShareSize" TEXT NOT NULL,
|
||||||
|
"sendEmailNotification" BOOLEAN NOT NULL,
|
||||||
|
"remainingUses" INTEGER NOT NULL,
|
||||||
|
"simplified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"creatorId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token" FROM "ReverseShare";
|
||||||
|
DROP TABLE "ReverseShare";
|
||||||
|
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
||||||
|
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_ReverseShare" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"shareExpiration" DATETIME NOT NULL,
|
||||||
|
"maxShareSize" TEXT NOT NULL,
|
||||||
|
"sendEmailNotification" BOOLEAN NOT NULL,
|
||||||
|
"remainingUses" INTEGER NOT NULL,
|
||||||
|
"simplified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"publicAccess" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"creatorId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token" FROM "ReverseShare";
|
||||||
|
DROP TABLE "ReverseShare";
|
||||||
|
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
||||||
|
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -103,6 +103,8 @@ model ReverseShare {
|
|||||||
maxShareSize String
|
maxShareSize String
|
||||||
sendEmailNotification Boolean
|
sendEmailNotification Boolean
|
||||||
remainingUses Int
|
remainingUses Int
|
||||||
|
simplified Boolean @default(false)
|
||||||
|
publicAccess Boolean @default(true)
|
||||||
|
|
||||||
creatorId String
|
creatorId String
|
||||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import * as moment from "moment";
|
|||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
import { ShareService } from "src/share/share.service";
|
import { ShareService } from "src/share/share.service";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private _shareService: ShareService,
|
private _shareService: ShareService,
|
||||||
private _prisma: PrismaService,
|
private _prisma: PrismaService,
|
||||||
|
_config: ConfigService,
|
||||||
) {
|
) {
|
||||||
super(_shareService, _prisma);
|
super(_shareService, _prisma, _config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ export class CreateReverseShareDTO {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(1000)
|
@Max(1000)
|
||||||
maxUseCount: number;
|
maxUseCount: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
simplified: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
publicAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export class ReverseShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
simplified: boolean;
|
||||||
|
|
||||||
from(partial: Partial<ReverseShareDTO>) {
|
from(partial: Partial<ReverseShareDTO>) {
|
||||||
return plainToClass(ReverseShareDTO, partial, {
|
return plainToClass(ReverseShareDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export class ReverseShareService {
|
|||||||
remainingUses: data.maxUseCount,
|
remainingUses: data.maxUseCount,
|
||||||
maxShareSize: data.maxShareSize,
|
maxShareSize: data.maxShareSize,
|
||||||
sendEmailNotification: data.sendEmailNotification,
|
sendEmailNotification: data.sendEmailNotification,
|
||||||
|
simplified: data.simplified,
|
||||||
|
publicAccess: data.publicAccess,
|
||||||
creatorId,
|
creatorId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
19
backend/src/share/dto/shareComplete.dto.ts
Normal file
19
backend/src/share/dto/shareComplete.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
import { ShareDTO } from "./share.dto";
|
||||||
|
|
||||||
|
export class CompletedShareDTO extends ShareDTO {
|
||||||
|
@Expose()
|
||||||
|
notifyReverseShareCreator?: boolean;
|
||||||
|
|
||||||
|
from(partial: Partial<CompletedShareDTO>) {
|
||||||
|
return plainToClass(CompletedShareDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fromList(partial: Partial<CompletedShareDTO>[]) {
|
||||||
|
return partial.map((part) =>
|
||||||
|
plainToClass(CompletedShareDTO, part, { excludeExtraneousValues: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -9,13 +8,19 @@ import { Request } from "express";
|
|||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ShareService } from "src/share/share.service";
|
import { ShareService } from "src/share/share.service";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareSecurityGuard implements CanActivate {
|
export class ShareSecurityGuard extends JwtGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
) {}
|
configService: ConfigService,
|
||||||
|
) {
|
||||||
|
super(configService);
|
||||||
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
@@ -31,7 +36,7 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { security: true },
|
include: { security: true, reverseShare: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -53,6 +58,19 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
"share_token_required",
|
"share_token_required",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Run the JWTGuard to set the user
|
||||||
|
await super.canActivate(context);
|
||||||
|
const user = request.user as User;
|
||||||
|
|
||||||
|
// Only the creator and reverse share creator can access the reverse share if it's not public
|
||||||
|
if (share.reverseShare && !share.reverseShare.publicAccess
|
||||||
|
&& share.creatorId !== user?.id
|
||||||
|
&& share.reverseShare.creatorId !== user?.id)
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Only reverse share creator can access this share",
|
||||||
|
"private_share",
|
||||||
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
|||||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
|
import { CompletedShareDTO } from "./dto/shareComplete.dto";
|
||||||
@Controller("shares")
|
@Controller("shares")
|
||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -86,7 +87,7 @@ export class ShareController {
|
|||||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||||
const { reverse_share_token } = request.cookies;
|
const { reverse_share_token } = request.cookies;
|
||||||
return new ShareDTO().from(
|
return new CompletedShareDTO().from(
|
||||||
await this.shareService.complete(id, reverse_share_token),
|
await this.shareService.complete(id, reverse_share_token),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,11 +159,12 @@ export class ShareService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const notifyReverseShareCreator = share.reverseShare
|
||||||
share.reverseShare &&
|
? this.config.get("smtp.enabled") &&
|
||||||
this.config.get("smtp.enabled") &&
|
|
||||||
share.reverseShare.sendEmailNotification
|
share.reverseShare.sendEmailNotification
|
||||||
) {
|
: undefined;
|
||||||
|
|
||||||
|
if (notifyReverseShareCreator) {
|
||||||
await this.emailService.sendMailToReverseShareCreator(
|
await this.emailService.sendMailToReverseShareCreator(
|
||||||
share.reverseShare.creator.email,
|
share.reverseShare.creator.email,
|
||||||
share.id,
|
share.id,
|
||||||
@@ -180,10 +181,15 @@ export class ShareService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.share.update({
|
const updatedShare = await this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { uploadLocked: true },
|
data: { uploadLocked: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updatedShare,
|
||||||
|
notifyReverseShareCreator,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertComplete(id: string) {
|
async revertComplete(id: string) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getExpirationPreview } from "../../../utils/date.util";
|
|||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
import FileSizeInput from "../FileSizeInput";
|
import FileSizeInput from "../FileSizeInput";
|
||||||
import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
|
import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
|
||||||
|
import { getCookie, setCookie } from "cookies-next";
|
||||||
|
|
||||||
const showCreateReverseShareModal = (
|
const showCreateReverseShareModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
@@ -61,10 +62,16 @@ const Body = ({
|
|||||||
sendEmailNotification: false,
|
sendEmailNotification: false,
|
||||||
expiration_num: 1,
|
expiration_num: 1,
|
||||||
expiration_unit: "-days",
|
expiration_unit: "-days",
|
||||||
|
simplified: !!(getCookie("reverse-share.simplified") ?? false),
|
||||||
|
publicAccess: !!(getCookie("reverse-share.public-access") ?? true),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.onSubmit(async (values) => {
|
const onSubmit = form.onSubmit(async (values) => {
|
||||||
|
// remember simplified and publicAccess in cookies
|
||||||
|
setCookie("reverse-share.simplified", values.simplified);
|
||||||
|
setCookie("reverse-share.public-access", values.publicAccess);
|
||||||
|
|
||||||
const expirationDate = moment().add(
|
const expirationDate = moment().add(
|
||||||
form.values.expiration_num,
|
form.values.expiration_num,
|
||||||
form.values.expiration_unit.replace(
|
form.values.expiration_unit.replace(
|
||||||
@@ -91,6 +98,8 @@ const Body = ({
|
|||||||
values.maxShareSize,
|
values.maxShareSize,
|
||||||
values.maxUseCount,
|
values.maxUseCount,
|
||||||
values.sendEmailNotification,
|
values.sendEmailNotification,
|
||||||
|
values.simplified,
|
||||||
|
values.publicAccess,
|
||||||
)
|
)
|
||||||
.then(({ link }) => {
|
.then(({ link }) => {
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
@@ -210,7 +219,28 @@ const Body = ({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Switch
|
||||||
|
mt="xs"
|
||||||
|
labelPosition="left"
|
||||||
|
label={t("account.reverseShares.modal.simplified")}
|
||||||
|
description={t(
|
||||||
|
"account.reverseShares.modal.simplified.description",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("simplified", {
|
||||||
|
type: "checkbox",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
mt="xs"
|
||||||
|
labelPosition="left"
|
||||||
|
label={t("account.reverseShares.modal.public-access")}
|
||||||
|
description={t(
|
||||||
|
"account.reverseShares.modal.public-access.description",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("publicAccess", {
|
||||||
|
type: "checkbox",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<Button mt="md" type="submit">
|
<Button mt="md" type="submit">
|
||||||
<FormattedMessage id="common.button.create" />
|
<FormattedMessage id="common.button.create" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import { FormattedMessage } from "react-intl";
|
|||||||
import useTranslate, {
|
import useTranslate, {
|
||||||
translateOutsideContext,
|
translateOutsideContext,
|
||||||
} from "../../../hooks/useTranslate.hook";
|
} from "../../../hooks/useTranslate.hook";
|
||||||
import { Share } from "../../../types/share.type";
|
import { CompletedShare } from "../../../types/share.type";
|
||||||
import CopyTextField from "../CopyTextField";
|
import CopyTextField from "../CopyTextField";
|
||||||
|
|
||||||
const showCompletedUploadModal = (
|
const showCompletedUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
share: Share,
|
share: CompletedShare,
|
||||||
appUrl: string,
|
appUrl: string,
|
||||||
) => {
|
) => {
|
||||||
const t = translateOutsideContext();
|
const t = translateOutsideContext();
|
||||||
@@ -25,7 +25,7 @@ const showCompletedUploadModal = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
const Body = ({ share, appUrl }: { share: CompletedShare; appUrl: string }) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@@ -35,6 +35,19 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
|||||||
return (
|
return (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<CopyTextField link={link} />
|
<CopyTextField link={link} />
|
||||||
|
{share.notifyReverseShareCreator === true && (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color:
|
||||||
|
theme.colorScheme === "dark"
|
||||||
|
? theme.colors.gray[3]
|
||||||
|
: theme.colors.dark[4],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t("upload.modal.completed.notified-reverse-share-creator")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { FileUpload } from "../../../types/File.type";
|
|||||||
import { CreateShare } from "../../../types/share.type";
|
import { CreateShare } from "../../../types/share.type";
|
||||||
import { getExpirationPreview } from "../../../utils/date.util";
|
import { getExpirationPreview } from "../../../utils/date.util";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const showCreateUploadModal = (
|
const showCreateUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
@@ -41,12 +42,26 @@ const showCreateUploadModal = (
|
|||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
maxExpirationInHours: number;
|
maxExpirationInHours: number;
|
||||||
|
simplified: boolean;
|
||||||
},
|
},
|
||||||
files: FileUpload[],
|
files: FileUpload[],
|
||||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
|
||||||
) => {
|
) => {
|
||||||
const t = translateOutsideContext();
|
const t = translateOutsideContext();
|
||||||
|
|
||||||
|
if (options.simplified) {
|
||||||
|
return modals.openModal({
|
||||||
|
title: t("upload.modal.title"),
|
||||||
|
children: (
|
||||||
|
<SimplifiedCreateUploadModalModal
|
||||||
|
options={options}
|
||||||
|
files={files}
|
||||||
|
uploadCallback={uploadCallback}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: t("upload.modal.title"),
|
title: t("upload.modal.title"),
|
||||||
children: (
|
children: (
|
||||||
@@ -59,6 +74,23 @@ const showCreateUploadModal = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateLink = () =>
|
||||||
|
Buffer.from(Math.random().toString(), "utf8")
|
||||||
|
.toString("base64")
|
||||||
|
.substring(10, 17);
|
||||||
|
|
||||||
|
const generateAvailableLink = async (times = 10): Promise<string> => {
|
||||||
|
if (times <= 0) {
|
||||||
|
throw new Error("Could not generate available link");
|
||||||
|
}
|
||||||
|
const _link = generateLink();
|
||||||
|
if (!(await shareService.isShareIdAvailable(_link))) {
|
||||||
|
return await generateAvailableLink(times - 1);
|
||||||
|
} else {
|
||||||
|
return _link;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const CreateUploadModalBody = ({
|
const CreateUploadModalBody = ({
|
||||||
uploadCallback,
|
uploadCallback,
|
||||||
files,
|
files,
|
||||||
@@ -78,9 +110,7 @@ const CreateUploadModalBody = ({
|
|||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
|
const generatedLink = generateLink();
|
||||||
.toString("base64")
|
|
||||||
.substr(10, 7);
|
|
||||||
|
|
||||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||||
|
|
||||||
@@ -202,14 +232,7 @@ const CreateUploadModalBody = ({
|
|||||||
<Button
|
<Button
|
||||||
style={{ flex: "0 0 auto" }}
|
style={{ flex: "0 0 auto" }}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => form.setFieldValue("link", generateLink())}
|
||||||
form.setFieldValue(
|
|
||||||
"link",
|
|
||||||
Buffer.from(Math.random().toString(), "utf8")
|
|
||||||
.toString("base64")
|
|
||||||
.substr(10, 7),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FormattedMessage id="common.button.generate" />
|
<FormattedMessage id="common.button.generate" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -429,4 +452,108 @@ const CreateUploadModalBody = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SimplifiedCreateUploadModalModal = ({
|
||||||
|
uploadCallback,
|
||||||
|
files,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
files: FileUpload[];
|
||||||
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void;
|
||||||
|
options: {
|
||||||
|
isUserSignedIn: boolean;
|
||||||
|
isReverseShare: boolean;
|
||||||
|
appUrl: string;
|
||||||
|
allowUnauthenticatedShares: boolean;
|
||||||
|
enableEmailRecepients: boolean;
|
||||||
|
maxExpirationInHours: number;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||||
|
|
||||||
|
const validationSchema = yup.object().shape({
|
||||||
|
name: yup
|
||||||
|
.string()
|
||||||
|
.transform((value) => value || undefined)
|
||||||
|
.min(3, t("common.error.too-short", { length: 3 }))
|
||||||
|
.max(30, t("common.error.too-long", { length: 30 })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: undefined,
|
||||||
|
description: undefined,
|
||||||
|
},
|
||||||
|
validate: yupResolver(validationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.onSubmit(async (values) => {
|
||||||
|
const link = await generateAvailableLink().catch(() => {
|
||||||
|
toast.error(t("upload.modal.link.error.taken"));
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadCallback(
|
||||||
|
{
|
||||||
|
id: link,
|
||||||
|
name: values.name,
|
||||||
|
expiration: "never",
|
||||||
|
recipients: [],
|
||||||
|
description: values.description,
|
||||||
|
security: {
|
||||||
|
password: undefined,
|
||||||
|
maxViews: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
modals.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||||
|
<Alert
|
||||||
|
withCloseButton
|
||||||
|
onClose={() => setShowNotSignedInAlert(false)}
|
||||||
|
icon={<TbAlertCircle size={16} />}
|
||||||
|
title={t("upload.modal.not-signed-in")}
|
||||||
|
color="yellow"
|
||||||
|
>
|
||||||
|
<FormattedMessage id="upload.modal.not-signed-in-description" />
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<Stack align="stretch">
|
||||||
|
<Stack align="stretch">
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
placeholder={t(
|
||||||
|
"upload.modal.accordion.name-and-description.name.placeholder",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
variant="filled"
|
||||||
|
placeholder={t(
|
||||||
|
"upload.modal.accordion.name-and-description.description.placeholder",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("description")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Button type="submit" data-autofocus>
|
||||||
|
<FormattedMessage id="common.button.share" />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default showCreateUploadModal;
|
export default showCreateUploadModal;
|
||||||
|
|||||||
@@ -199,6 +199,14 @@ export default {
|
|||||||
"account.reverseShares.modal.send-email.description":
|
"account.reverseShares.modal.send-email.description":
|
||||||
"Send an email notification when a share is created with this reverse share link.",
|
"Send an email notification when a share is created with this reverse share link.",
|
||||||
|
|
||||||
|
"account.reverseShares.modal.simplified": "Simple mode",
|
||||||
|
"account.reverseShares.modal.simplified.description":
|
||||||
|
"Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
|
||||||
|
|
||||||
|
"account.reverseShares.modal.public-access": "Public access",
|
||||||
|
"account.reverseShares.modal.public-access.description":
|
||||||
|
"Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||||
|
|
||||||
"account.reverseShares.modal.max-use.label": "Max uses",
|
"account.reverseShares.modal.max-use.label": "Max uses",
|
||||||
"account.reverseShares.modal.max-use.description":
|
"account.reverseShares.modal.max-use.description":
|
||||||
"The maximum amount of times this URL can be used to create a share.",
|
"The maximum amount of times this URL can be used to create a share.",
|
||||||
@@ -342,6 +350,7 @@ export default {
|
|||||||
"upload.modal.completed.expires-on":
|
"upload.modal.completed.expires-on":
|
||||||
"This share will expire on {expiration}.",
|
"This share will expire on {expiration}.",
|
||||||
"upload.modal.completed.share-ready": "Share ready",
|
"upload.modal.completed.share-ready": "Share ready",
|
||||||
|
"upload.modal.completed.notified-reverse-share-creator": "We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
|
||||||
|
|
||||||
// END /upload
|
// END /upload
|
||||||
|
|
||||||
@@ -355,6 +364,8 @@ export default {
|
|||||||
"share.error.not-found.title": "Share not found",
|
"share.error.not-found.title": "Share not found",
|
||||||
"share.error.not-found.description":
|
"share.error.not-found.description":
|
||||||
"The share you're looking for doesn't exist.",
|
"The share you're looking for doesn't exist.",
|
||||||
|
"share.error.access-denied.title": "Private share",
|
||||||
|
"share.error.access-denied.description": "The current account does not have permission to access this share",
|
||||||
|
|
||||||
"share.modal.password.title": "Password required",
|
"share.modal.password.title": "Password required",
|
||||||
"share.modal.password.description":
|
"share.modal.password.description":
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ export default {
|
|||||||
"account.reverseShares.modal.max-size.label": "共享文件上限",
|
"account.reverseShares.modal.max-size.label": "共享文件上限",
|
||||||
"account.reverseShares.modal.send-email": "发送邮件提醒",
|
"account.reverseShares.modal.send-email": "发送邮件提醒",
|
||||||
"account.reverseShares.modal.send-email.description": "当这个预留共享链接被用于共享时,发送邮件提醒",
|
"account.reverseShares.modal.send-email.description": "当这个预留共享链接被用于共享时,发送邮件提醒",
|
||||||
|
"account.reverseShares.modal.simplified": "简单模式",
|
||||||
|
"account.reverseShares.modal.simplified.description":
|
||||||
|
"让上传者更轻松地与你共享文件,他们仅能自定义共享的名称和描述。",
|
||||||
|
"account.reverseShares.modal.public-access": "公开访问",
|
||||||
|
"account.reverseShares.modal.public-access.description":
|
||||||
|
"让通过这个预留共享创建共享能被公开访问。如果禁用,将只有您和创建者能够访问。",
|
||||||
"account.reverseShares.modal.max-use.label": "最大使用次数",
|
"account.reverseShares.modal.max-use.label": "最大使用次数",
|
||||||
"account.reverseShares.modal.max-use.description": "这个预留共享链接可被用于创建共享的最大使用次数",
|
"account.reverseShares.modal.max-use.description": "这个预留共享链接可被用于创建共享的最大使用次数",
|
||||||
"account.reverseShare.never-expires": "这个预留共享永不过期",
|
"account.reverseShare.never-expires": "这个预留共享永不过期",
|
||||||
@@ -255,6 +261,7 @@ export default {
|
|||||||
"upload.modal.completed.never-expires": "这个共享永不过期",
|
"upload.modal.completed.never-expires": "这个共享永不过期",
|
||||||
"upload.modal.completed.expires-on": "这个共享将过期于 {expiration}.",
|
"upload.modal.completed.expires-on": "这个共享将过期于 {expiration}.",
|
||||||
"upload.modal.completed.share-ready": "共享创建完毕",
|
"upload.modal.completed.share-ready": "共享创建完毕",
|
||||||
|
"upload.modal.completed.notified-reverse-share-creator": "我们已经通知预留共享的创建者。您也可以通过其他方式将该链接手动分享给他们。",
|
||||||
// END /upload
|
// END /upload
|
||||||
// /share/[id]
|
// /share/[id]
|
||||||
"share.title": "共享 {shareId}",
|
"share.title": "共享 {shareId}",
|
||||||
@@ -264,6 +271,8 @@ export default {
|
|||||||
"share.error.removed.title": "共享已删除",
|
"share.error.removed.title": "共享已删除",
|
||||||
"share.error.not-found.title": "共享未找到",
|
"share.error.not-found.title": "共享未找到",
|
||||||
"share.error.not-found.description": "共享文件走丢了",
|
"share.error.not-found.description": "共享文件走丢了",
|
||||||
|
"share.error.access-denied.title": "私有共享",
|
||||||
|
"share.error.access-denied.description": "当前账户没有权限访问此共享",
|
||||||
"share.modal.password.title": "需要密码",
|
"share.modal.password.title": "需要密码",
|
||||||
"share.modal.password.description": "请输入密码来访问此共享",
|
"share.modal.password.description": "请输入密码来访问此共享",
|
||||||
"share.modal.password": "密码",
|
"share.modal.password": "密码",
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ const Share = ({ shareId }: { shareId: string }) => {
|
|||||||
t("share.error.not-found.description"),
|
t("share.error.not-found.description"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (e.response.status == 403 && error == "share_removed") {
|
||||||
|
showErrorModal(
|
||||||
|
modals,
|
||||||
|
t("share.error.access-denied.title"),
|
||||||
|
t("share.error.access-denied.description"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showErrorModal(modals, t("common.error"), t("common.error.unknown"));
|
showErrorModal(modals, t("common.error"), t("common.error.unknown"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ const Share = ({ shareId }: { shareId: string }) => {
|
|||||||
"go-home",
|
"go-home",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (e.response.status == 403 && error == "private_share") {
|
||||||
|
showErrorModal(
|
||||||
|
modals,
|
||||||
|
t("share.error.access-denied.title"),
|
||||||
|
t("share.error.access-denied.description"),
|
||||||
|
);
|
||||||
} else if (error == "share_password_required") {
|
} else if (error == "share_password_required") {
|
||||||
showEnterPasswordModal(modals, getShareToken);
|
showEnterPasswordModal(modals, getShareToken);
|
||||||
} else if (error == "share_token_required") {
|
} else if (error == "share_token_required") {
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ const Share = ({ reverseShareToken }: { reverseShareToken: string }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const [maxShareSize, setMaxShareSize] = useState(0);
|
const [maxShareSize, setMaxShareSize] = useState(0);
|
||||||
|
const [simplified, setSimplified] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shareService
|
shareService
|
||||||
.setReverseShare(reverseShareToken)
|
.setReverseShare(reverseShareToken)
|
||||||
.then((reverseShareTokenData) => {
|
.then((reverseShareTokenData) => {
|
||||||
setMaxShareSize(parseInt(reverseShareTokenData.maxShareSize));
|
setMaxShareSize(parseInt(reverseShareTokenData.maxShareSize));
|
||||||
|
setSimplified(reverseShareTokenData.simplified);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -38,7 +40,13 @@ const Share = ({ reverseShareToken }: { reverseShareToken: string }) => {
|
|||||||
|
|
||||||
if (isLoading) return <LoadingOverlay visible />;
|
if (isLoading) return <LoadingOverlay visible />;
|
||||||
|
|
||||||
return <Upload isReverseShare maxShareSize={maxShareSize} />;
|
return (
|
||||||
|
<Upload
|
||||||
|
isReverseShare
|
||||||
|
maxShareSize={maxShareSize}
|
||||||
|
simplified={simplified}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Share;
|
export default Share;
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ let createdShare: Share;
|
|||||||
const Upload = ({
|
const Upload = ({
|
||||||
maxShareSize,
|
maxShareSize,
|
||||||
isReverseShare = false,
|
isReverseShare = false,
|
||||||
|
simplified,
|
||||||
}: {
|
}: {
|
||||||
maxShareSize?: number;
|
maxShareSize?: number;
|
||||||
isReverseShare: boolean;
|
isReverseShare: boolean;
|
||||||
|
simplified: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@@ -133,6 +135,7 @@ const Upload = ({
|
|||||||
),
|
),
|
||||||
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
|
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
|
||||||
maxExpirationInHours: config.get("share.maxExpiration"),
|
maxExpirationInHours: config.get("share.maxExpiration"),
|
||||||
|
simplified,
|
||||||
},
|
},
|
||||||
files,
|
files,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ const createReverseShare = async (
|
|||||||
maxShareSize: number,
|
maxShareSize: number,
|
||||||
maxUseCount: number,
|
maxUseCount: number,
|
||||||
sendEmailNotification: boolean,
|
sendEmailNotification: boolean,
|
||||||
|
simplified: boolean,
|
||||||
|
publicAccess: boolean,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
await api.post("reverseShares", {
|
await api.post("reverseShares", {
|
||||||
@@ -116,6 +118,8 @@ const createReverseShare = async (
|
|||||||
maxShareSize: maxShareSize.toString(),
|
maxShareSize: maxShareSize.toString(),
|
||||||
maxUseCount,
|
maxUseCount,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
|
simplified,
|
||||||
|
publicAccess,
|
||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export type Share = {
|
|||||||
hasPassword: boolean;
|
hasPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CompletedShare = Share & {
|
||||||
|
/**
|
||||||
|
* undefined means is not reverse share
|
||||||
|
* true means server was send email to reverse share creator
|
||||||
|
* false means server was not send email to reverse share creator
|
||||||
|
* */
|
||||||
|
notifyReverseShareCreator: boolean | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateShare = {
|
export type CreateShare = {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user