feat: managed user shares (#63)

* Implement managed user shares features:
  - Add recipient restriction settings for shares
  - Add received shares overview page for logged in users
  - Support automatic linking of user accounts to shares based on email address
  - Stop exposing registered/unregistered status of recipient emails to prevent account enumeration
  - Enable folder uploads via drag-and-drop and selection
  - Allow administrators to bypass maximum file expiration limits
  - Swap and align recipient icons, hover tips, and colors in My Shares

* harden user recipient security validations on file downloads
  - Query recipient relations in FileSecurityGuard
  - Validate restrictToRecipients for direct/tokenless file downloads and throw a ForbiddenException
  - Credit to Leonardo for reporting this issue, that led to this patch, before the code was released.

* i18n: extract restrictToRecipients hardcoded string to translations key
  - Use share.restrictedToRecipients key in ShareSecurityGuard and FileSecurityGuard

---------

Co-authored-by: smp46 <me@smp46.me>
This commit is contained in:
Tom van der Laan
2026-08-01 04:51:20 +10:00
committed by smp46
co-authored by smp46
parent 27414e6d97
commit d69281aeff
21 changed files with 533 additions and 37 deletions
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ShareUserRecipient" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"shareId" TEXT NOT NULL,
CONSTRAINT "ShareUserRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShareUserRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ShareSecurity" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"password" TEXT,
"maxViews" INTEGER,
"restrictToRecipients" BOOLEAN NOT NULL DEFAULT false,
"shareId" TEXT,
CONSTRAINT "ShareSecurity_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ShareSecurity" ("createdAt", "id", "maxViews", "password", "shareId") SELECT "createdAt", "id", "maxViews", "password", "shareId" FROM "ShareSecurity";
DROP TABLE "ShareSecurity";
ALTER TABLE "new_ShareSecurity" RENAME TO "ShareSecurity";
CREATE UNIQUE INDEX "ShareSecurity_shareId_key" ON "ShareSecurity"("shareId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "ShareUserRecipient_userId_shareId_key" ON "ShareUserRecipient"("userId", "shareId");
+25 -9
View File
@@ -19,10 +19,11 @@ model User {
ldapDN String? @unique
shareSizeLimit String?
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
shareUserRecipients ShareUserRecipient[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
@@ -98,9 +99,10 @@ model Share {
reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity?
recipients ShareRecipient[]
files File[]
security ShareSecurity?
recipients ShareRecipient[]
userRecipients ShareUserRecipient[]
files File[]
storageProvider String @default("LOCAL")
}
@@ -145,13 +147,27 @@ model ShareSecurity {
id String @id @default(uuid())
createdAt DateTime @default(now())
password String?
maxViews Int?
password String?
maxViews Int?
restrictToRecipients Boolean @default(false)
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model ShareUserRecipient {
id String @id @default(uuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shareId String
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
@@unique([userId, shareId])
}
model Config {
updatedAt DateTime @updatedAt
+5
View File
@@ -127,6 +127,11 @@ export const configVariables = {
defaultValue: "false",
secret: false,
},
enableUserRecipients: {
type: "boolean",
defaultValue: "false",
secret: false,
},
fileRetentionPeriod: {
type: "timespan",
defaultValue: "0 days",
+18 -1
View File
@@ -48,7 +48,11 @@ export class FileSecurityGuard extends ShareSecurityGuard {
const share = await this._prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
include: {
security: true,
userRecipients: { select: { userId: true } },
recipients: { select: { email: true } },
},
});
// If there is no share token the user requests a file directly
@@ -70,6 +74,19 @@ export class FileSecurityGuard extends ShareSecurityGuard {
throw new NotFoundException(this._i18n.t("file.notFound"));
}
if (share.security?.restrictToRecipients) {
const user = await this.authenticateUser(context);
const isCreator = user && share.creatorId === user.id;
const isRecipient = await this.isRecipient(share, user);
if (!isCreator && !isRecipient) {
throw new ForbiddenException(
this._i18n.t("share.restrictedToRecipients"),
"share_restricted_to_recipients",
);
}
}
if (share.security?.password)
throw new ForbiddenException(this._i18n.t("file.passwordProtected"));
+2 -1
View File
@@ -12,5 +12,6 @@
"wrongPassword": "Wrong password",
"tokenRequired": "Share token required",
"privateShare": "Only reverse share creator can access this share",
"maxViewsExceeded": "Maximum views exceeded"
"maxViewsExceeded": "Maximum views exceeded",
"restrictedToRecipients": "This share is restricted to specific recipients. Please log in to access it."
}
@@ -6,4 +6,7 @@ export class MyShareSecurityDTO {
@Expose()
maxViews: number;
@Expose()
restrictToRecipients: boolean;
}
+11 -1
View File
@@ -1,4 +1,10 @@
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Length,
} from "class-validator";
export class ShareSecurityDTO {
@IsString()
@@ -9,4 +15,8 @@ export class ShareSecurityDTO {
@IsNumber()
@IsOptional()
maxViews: number;
@IsBoolean()
@IsOptional()
restrictToRecipients: boolean;
}
+63 -4
View File
@@ -24,6 +24,46 @@ export class ShareSecurityGuard extends JwtGuard {
super(configService);
}
protected async authenticateUser(
context: ExecutionContext,
): Promise<User | undefined> {
await super.canActivate(context);
const request: Request = context.switchToHttp().getRequest();
return request.user as User;
}
protected async isRecipient(
share: {
id: string;
userRecipients: { userId: string }[];
recipients: { email: string }[];
},
user?: User,
): Promise<boolean> {
if (!user) return false;
if (!this.configService.get("share.enableUserRecipients")) return false;
// Already linked as a recipient of this share.
const isLinked = share.userRecipients.some((r) => r.userId === user.id);
if (isLinked) return true;
// Otherwise, if the user's (account-verified) email matches one of the
// share's recipients, grant access and link them
const userEmail = user.email?.toLowerCase();
const isEmailRecipient =
!!userEmail &&
share.recipients.some((r) => r.email.toLowerCase() === userEmail);
if (isEmailRecipient) {
await this.prisma.shareUserRecipient.upsert({
where: { userId_shareId: { userId: user.id, shareId: share.id } },
create: { userId: user.id, shareId: share.id },
update: {},
});
return true;
}
return false;
}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -38,14 +78,17 @@ export class ShareSecurityGuard extends JwtGuard {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true, reverseShare: true },
include: {
security: true,
reverseShare: true,
userRecipients: { select: { userId: true } },
recipients: { select: { email: true } },
},
});
if (!share) throw new NotFoundException(this.i18n.t("share.notFound"));
// Run the JWTGuard to set the user
await super.canActivate(context);
const user = request.user as User;
const user = await this.authenticateUser(context);
// If admin access is enabled and user is admin, allow access
if (
@@ -62,6 +105,22 @@ export class ShareSecurityGuard extends JwtGuard {
throw new NotFoundException(this.i18n.t("share.notFound"));
}
// If user sharing is enabled, check if the authenticated user is a recipient
if (await this.isRecipient(share, user)) {
return true;
}
// If share is restricted to named recipients, block everyone else (excluding the creator)
if (
share.security?.restrictToRecipients &&
(!user || share.creatorId !== user.id)
) {
throw new ForbiddenException(
this.i18n.t("share.restrictedToRecipients"),
"share_restricted_to_recipients",
);
}
if (share.security?.password && !shareToken)
throw new ForbiddenException(
this.i18n.t("file.passwordProtected"),
+11
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -19,6 +20,7 @@ import * as moment from "moment";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
import { AdminShareDTO } from "./dto/adminShare.dto";
import { CreateShareDTO } from "./dto/createShare.dto";
import { MyShareDTO } from "./dto/myShare.dto";
@@ -40,6 +42,7 @@ export class ShareController {
constructor(
private shareService: ShareService,
private jwtService: JwtService,
private config: ConfigService,
) {}
@Get("all")
@@ -56,6 +59,14 @@ export class ShareController {
);
}
@Get("received")
@UseGuards(JwtGuard)
async getReceivedShares(@GetUser() user: User) {
if (!this.config.get("share.enableUserRecipients"))
throw new ForbiddenException("User recipients are not enabled");
return this.shareService.getReceivedShares(user.id);
}
@Get(":id")
@UseGuards(IdValidation, ShareSecurityGuard)
async get(@Param("id") id: string) {
+59
View File
@@ -53,10 +53,32 @@ export class ShareService {
if (!share.security || Object.keys(share.security).length == 0)
share.security = undefined;
if (share.security?.restrictToRecipients && share.security?.password) {
throw new BadRequestException(
"Cannot set a password on a share restricted to recipients.",
);
}
if (share.security?.password) {
share.security.password = await argon.hash(share.security.password);
}
if (
this.configService.get("share.enableUserRecipients") &&
share.security?.restrictToRecipients
) {
if (!share.recipients?.length) {
throw new BadRequestException(
"A share restricted to recipients must have at least one recipient.",
);
}
// Note: we intentionally do NOT check whether the recipient emails belong
// to registered accounts. Doing so would leak which emails have an account
// (account enumeration). Recipients who aren't registered yet simply sign
// up to gain access (see ShareSecurityGuard); if signups are disabled they
// can't access it.
}
let expirationDate: Date;
// If share is created by a reverse share token override the expiration date
@@ -171,6 +193,26 @@ export class ShareService {
);
}
// Auto-link email recipients who are registered users so the share appears in their dashboard
if (this.configService.get("share.enableUserRecipients")) {
const emails = share.recipients.map((r) => r.email);
if (emails.length > 0) {
const matchedUsers = await this.prisma.user.findMany({
where: { email: { in: emails } },
select: { id: true },
});
for (const matchedUser of matchedUsers) {
await this.prisma.shareUserRecipient.upsert({
where: {
userId_shareId: { userId: matchedUser.id, shareId: share.id },
},
create: { userId: matchedUser.id, shareId: share.id },
update: {},
});
}
}
}
const notifyReverseShareCreator = share.reverseShare
? this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
@@ -399,6 +441,22 @@ export class ShareService {
return (await this.prisma.share.findUnique({ where: { id } })).uploadLocked;
}
async getReceivedShares(userId: string) {
return this.prisma.shareUserRecipient.findMany({
where: { userId },
include: {
share: {
include: {
creator: true,
files: true,
security: true,
},
},
},
orderBy: { createdAt: "desc" },
});
}
private transformShare(share: any) {
return {
...share,
@@ -408,6 +466,7 @@ export class ShareService {
security: {
maxViews: share.security?.maxViews,
passwordProtected: !!share.security?.password,
restrictToRecipients: !!share.security?.restrictToRecipients,
},
};
}
@@ -217,6 +217,14 @@ const Header = () => {
link: "/account/reverseShares",
label: t("navbar.links.reverse"),
},
...(config.get("share.enableUserRecipients")
? [
{
link: "/account/received",
label: t("navbar.links.received"),
},
]
: []),
];
const mobileProfileLinks: NavLink[] = [
@@ -1,13 +1,15 @@
import { ActionIcon, Menu } from "@mantine/core";
import Link from "next/link";
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
import { TbArrowLoopLeft, TbLink, TbMailbox } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import { HoverTip } from "../../components/core/HoverTip";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import { useState } from "react";
const NavbarShareMneu = () => {
const t = useTranslate();
const config = useConfig();
const [menuOpened, setMenuOpened] = useState(false);
return (
@@ -32,6 +34,15 @@ const NavbarShareMneu = () => {
>
<FormattedMessage id="navbar.links.reverse" />
</Menu.Item>
{config.get("share.enableUserRecipients") && (
<Menu.Item
component={Link}
href="/account/received"
icon={<TbMailbox />}
>
<FormattedMessage id="navbar.links.received" />
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
@@ -138,6 +138,15 @@ const Body = ({
{currentShare.description || "-"}
</Text>
{currentShare.recipients && currentShare.recipients.length > 0 && (
<Text size="sm">
<b>
<FormattedMessage id="upload.modal.accordion.email.title" />:{" "}
</b>
{currentShare.recipients.join(", ")}
</Text>
)}
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
@@ -45,6 +45,7 @@ const showCreateUploadModal = (
defaultAppUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
enableUserRecipients: boolean;
maxExpiration: Timespan;
defaultExpiration: Timespan;
shareIdLength: number;
@@ -121,6 +122,7 @@ const CreateUploadModalBody = ({
defaultAppUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
enableUserRecipients: boolean;
maxExpiration: Timespan;
defaultExpiration: Timespan;
shareIdLength: number;
@@ -174,10 +176,19 @@ const CreateUploadModalBody = ({
expiration_num: defaultTimespan.value,
expiration_unit: `-${defaultTimespan.unit}` as string,
never_expires: false,
restrictToRecipients: false,
},
validate: yupResolver(validationSchema),
});
const handleRestrictToggle = (checked: boolean) => {
form.setFieldValue("restrictToRecipients", checked);
if (checked) {
// A share can't be both password-protected and restricted to recipients.
form.setFieldValue("password", undefined);
}
};
const onSubmit = form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", t("upload.modal.link.error.taken"));
@@ -223,8 +234,11 @@ const CreateUploadModalBody = ({
recipients: values.recipients,
description: values.description,
security: {
password: values.password || undefined,
password: values.restrictToRecipients
? undefined
: values.password || undefined,
maxViews: values.maxViews || undefined,
restrictToRecipients: values.restrictToRecipients || undefined,
},
},
files,
@@ -417,9 +431,18 @@ const CreateUploadModalBody = ({
return undefined;
}
form.setFieldError("recipients", null);
const newRecipients = form.values.recipients.includes(
query,
)
? form.values.recipients
: [...form.values.recipients, query];
form.setFieldValue("recipients", newRecipients);
return query;
}}
{...form.getInputProps("recipients")}
onChange={(value: string[]) => {
form.setFieldValue("recipients", value);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === "," || e.key === ";") {
e.preventDefault();
@@ -428,10 +451,11 @@ const CreateUploadModalBody = ({
inputValue.match(/^\S+@\S+\.\S+$/) &&
!form.values.recipients.includes(inputValue)
) {
form.setFieldValue("recipients", [
const newRecipients = [
...form.values.recipients,
inputValue,
]);
];
form.setFieldValue("recipients", newRecipients);
}
setEmailSearch("");
} else if (e.key === " ") {
@@ -440,6 +464,18 @@ const CreateUploadModalBody = ({
}
}}
/>
{options.enableUserRecipients && (
<Checkbox
mt="sm"
label={t(
"upload.modal.accordion.email.restrict-to-recipients",
)}
checked={form.values.restrictToRecipients}
onChange={(e) =>
handleRestrictToggle(e.currentTarget.checked)
}
/>
)}
</Accordion.Panel>
</Accordion.Item>
)}
@@ -450,15 +486,19 @@ const CreateUploadModalBody = ({
</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<PasswordInput
variant="filled"
placeholder={t(
"upload.modal.accordion.security.password.placeholder",
)}
label={t("upload.modal.accordion.security.password.label")}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
{!form.values.restrictToRecipients && (
<PasswordInput
variant="filled"
placeholder={t(
"upload.modal.accordion.security.password.placeholder",
)}
label={t(
"upload.modal.accordion.security.password.label",
)}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
)}
<NumberInput
min={1}
type="number"
+22
View File
@@ -7,6 +7,7 @@ export default {
"navbar.links.shares": "My shares",
"navbar.links.reverse": "Reverse shares",
"navbar.links.received": "Received shares",
"navbar.avatar.account": "My account",
"navbar.avatar.admin": "Administration",
@@ -174,6 +175,9 @@ export default {
"account.shares.table.createdAt": "Created on",
"account.shares.table.size": "Size",
"account.shares.table.password-protected": "Password protected",
"account.shares.table.recipients": "Recipients",
"account.shares.table.restricted-to-recipients": "Restricted to recipients only",
"account.shares.table.shared-with-recipients": "Shared with recipients",
"account.shares.table.visitor-count": "{count} of {max}",
"account.shares.table.expiry-never": "Never",
@@ -189,6 +193,15 @@ export default {
// END /account/shares
// /account/received
"account.received-shares.title": "Received shares",
"account.received-shares.title.empty": "No shares received yet",
"account.received-shares.description.empty":
"Shares sent to your email address will appear here.",
"account.received-shares.table.from": "From",
"account.received-shares.button.open": "Open",
// END /account/received
// /account/reverseShares
"account.reverseShares.title": "Reverse shares",
"account.reverseShares.description":
@@ -379,6 +392,8 @@ export default {
"upload.modal.accordion.email.title": "Email recipients",
"upload.modal.accordion.email.placeholder": "Enter email recipients",
"upload.modal.accordion.email.invalid-email": "Invalid email address",
"upload.modal.accordion.email.restrict-to-recipients":
"Restrict access to these recipients only (they must sign in to access it)",
"upload.modal.accordion.security.title": "Security options",
"upload.modal.accordion.security.password.label": "Password protection",
@@ -412,6 +427,10 @@ export default {
"share.error.access-denied.title": "Private share",
"share.error.access-denied.description":
"The current account does not have permission to access this share",
"share.error.restricted.title": "Restricted share",
"share.error.restricted.description":
"This share is restricted to specific recipients. Please log in to access it.",
"share.error.restricted.button": "Log in",
"share.modal.password.title": "Password required",
"share.modal.password.description":
@@ -628,6 +647,9 @@ export default {
"Force reverse shares to be created in simple mode. If disabled, the creator of the reverse share can choose between simple and advanced mode.",
"admin.config.share.allow-admin-access-all-shares.description":
"Allow administrators to access all shares, even if they are password protected, expired or deleted.",
"admin.config.share.enable-user-recipients": "Enable sharing with registered users",
"admin.config.share.enable-user-recipients.description":
"When enabled, shares sent to a registered user's email address will automatically appear in their account. Users can also restrict share access to named recipients only.",
"admin.config.share.file-retention-period": "File retention period",
"admin.config.share.file-retention-period.description":
"How long files are kept after a share expires or gets deleted. Only useful if the 'Allow admin access to all shares' is also enabled. Set to -1 to keep files forever.",
+113
View File
@@ -0,0 +1,113 @@
import {
Box,
Button,
Center,
Group,
Space,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import moment from "moment";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import Meta from "../../components/Meta";
import CenterLoader from "../../components/core/CenterLoader";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import shareService from "../../services/share.service";
const ReceivedShares = () => {
const t = useTranslate();
const router = useRouter();
const config = useConfig();
const [receivedShares, setReceivedShares] = useState<any[]>();
useEffect(() => {
if (!config.get("share.enableUserRecipients")) {
router.replace("/");
return;
}
shareService.getReceivedShares().then((data) => setReceivedShares(data));
}, []);
if (!receivedShares) return <CenterLoader />;
return (
<>
<Meta title={t("account.received-shares.title")} />
<Title mb={30} order={3}>
<FormattedMessage id="account.received-shares.title" />
</Title>
{receivedShares.length === 0 ? (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10}>
<Title order={3}>
<FormattedMessage id="account.received-shares.title.empty" />
</Title>
<Text>
<FormattedMessage id="account.received-shares.description.empty" />
</Text>
<Space h={5} />
</Stack>
</Center>
) : (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table>
<thead>
<tr>
<th>
<FormattedMessage id="account.shares.table.id" />
</th>
<th>
<FormattedMessage id="account.shares.table.name" />
</th>
<th>
<FormattedMessage id="account.received-shares.table.from" />
</th>
<th>
<FormattedMessage id="account.shares.table.expiresAt" />
</th>
<th></th>
</tr>
</thead>
<tbody>
{receivedShares.map(({ share }) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.name}</td>
<td>{share.creator?.username ?? "—"}</td>
<td>
{moment(share.expiration).unix() === 0 ? (
<FormattedMessage id="account.shares.table.expiry-never" />
) : (
moment(share.expiration).format("LLL")
)}
</td>
<td>
<Group position="right">
<Button
component={Link}
href={`/share/${share.id}`}
variant="light"
size="xs"
color="victoria"
>
<FormattedMessage id="account.received-shares.button.open" />
</Button>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)}
</>
);
};
export default ReceivedShares;
+42 -6
View File
@@ -3,6 +3,7 @@ import {
Box,
Button,
Center,
useMantineTheme,
Group,
Space,
Stack,
@@ -21,7 +22,9 @@ import {
TbLink,
TbLock,
TbTrash,
TbUsers,
} from "react-icons/tb";
import { FaUserLock } from "react-icons/fa";
import { FormattedMessage } from "react-intl";
import Meta from "../../components/Meta";
import showShareInformationsModal from "../../components/share/showShareInformationsModal";
@@ -41,6 +44,7 @@ const MyShares = () => {
const config = useConfig();
const { user } = useUser();
const t = useTranslate();
const theme = useMantineTheme();
const [shares, setShares] = useState<MyShare[]>();
@@ -82,6 +86,7 @@ const MyShares = () => {
<th>
<FormattedMessage id="account.shares.table.name" />
</th>
<th>
<FormattedMessage id="account.shares.table.visitors" />
</th>
@@ -92,16 +97,46 @@ const MyShares = () => {
</tr>
</thead>
<tbody>
{shares.map((share) => (
{shares.map((share) => {
return (
<tr key={share.id}>
<td>
<Group spacing="xs">
{share.id}{" "}
{share.security?.passwordProtected && (
<TbLock
color="orange"
title={t("account.shares.table.password-protected")}
/>
<HoverTip label="Password Protected">
<span style={{ display: "inline-flex" }}>
<TbLock
color="orange"
title={t("account.shares.table.password-protected")}
/>
</span>
</HoverTip>
)}
{config.get("share.enableUserRecipients") && (
share.security?.restrictToRecipients ? (
<HoverTip label="Recipients Only">
<span style={{ display: "inline-flex" }}>
<FaUserLock
color={theme.colors.gray[6]}
title={t(
"account.shares.table.restricted-to-recipients",
)}
/>
</span>
</HoverTip>
) : share.recipients?.length ? (
<HoverTip label="Sent to Recipients">
<span style={{ display: "inline-flex" }}>
<TbUsers
color={theme.colors.gray[6]}
title={t(
"account.shares.table.shared-with-recipients",
)}
/>
</span>
</HoverTip>
) : null
)}
</Group>
</td>
@@ -225,7 +260,8 @@ const MyShares = () => {
</Group>
</td>
</tr>
))}
);
})}
</tbody>
</Table>
</Box>
+37 -1
View File
@@ -1,4 +1,13 @@
import { ActionIcon, Box, Group, Text, Title } from "@mantine/core";
import {
ActionIcon,
Box,
Button,
Center,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { useModals } from "@mantine/modals";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -32,6 +41,7 @@ const Share = ({ shareId }: { shareId: string }) => {
const modals = useModals();
const router = useRouter();
const [share, setShare] = useState<ShareType>();
const [isRestricted, setIsRestricted] = useState(false);
const { user } = useUser();
const config = useConfig();
const t = useTranslate();
@@ -126,6 +136,11 @@ const Share = ({ shareId }: { shareId: string }) => {
"go-home",
);
}
} else if (
e.response.status == 403 &&
error == "share_restricted_to_recipients"
) {
setIsRestricted(true);
} else if (e.response.status == 403 && error == "private_share") {
showErrorModal(
modals,
@@ -151,6 +166,27 @@ const Share = ({ shareId }: { shareId: string }) => {
getFiles();
}, []);
if (isRestricted) {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing="md">
<Title order={3}>
<FormattedMessage id="share.error.restricted.title" />
</Title>
<Text color="dimmed" align="center">
<FormattedMessage id="share.error.restricted.description" />
</Text>
<Button
component={Link}
href={`/auth/signIn?redirect=/share/${shareId}`}
>
<FormattedMessage id="share.error.restricted.button" />
</Button>
</Stack>
</Center>
);
}
return (
<>
<Meta
+1
View File
@@ -172,6 +172,7 @@ const Upload = ({
"share.allowUnauthenticatedShares",
),
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
enableUserRecipients: config.get("share.enableUserRecipients"),
maxExpiration: user?.isAdmin
? { value: 0, unit: "days" }
: config.get("share.maxExpiration"),
+5
View File
@@ -73,6 +73,10 @@ const getMyShares = async (): Promise<MyShare[]> => {
return (await api.get("shares")).data;
};
const getReceivedShares = async (): Promise<any[]> => {
return (await api.get("shares/received")).data;
};
const getShareToken = async (id: string, password?: string) => {
if (!isValidId(id)) throw new Error("Invalid ID");
await api.post(`/shares/${id}/token`, { password });
@@ -201,6 +205,7 @@ export default {
doesFileSupportPreview,
isShareTextFile,
getMyShares,
getReceivedShares,
isShareIdAvailable,
downloadFile,
removeFile,
+4 -1
View File
@@ -49,7 +49,8 @@ export type ShareMetaData = {
export type MyShare = Omit<Share, "hasPassword"> & {
views: number;
createdAt: Date;
security?: MyShareSecurity;
recipients: string[];
security: MyShareSecurity;
};
export type MyReverseShare = {
@@ -64,9 +65,11 @@ export type MyReverseShare = {
export type ShareSecurity = {
maxViews?: number;
password?: string;
restrictToRecipients?: boolean;
};
export type MyShareSecurity = {
passwordProtected: boolean;
maxViews?: number;
restrictToRecipients: boolean;
};