mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
[Release] v3.1.4-beta (#169)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,6 +30,7 @@ apps/server/dist/*
|
||||
|
||||
#DEFAULT
|
||||
.env
|
||||
.steering
|
||||
data/
|
||||
|
||||
node_modules/
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-docs",
|
||||
"version": "3.1.3-beta",
|
||||
"version": "3.1.4-beta",
|
||||
"description": "Docs for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-api",
|
||||
"version": "3.1.3-beta",
|
||||
"version": "3.1.4-beta",
|
||||
"description": "API for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
@@ -147,6 +147,12 @@ const defaultConfigs = [
|
||||
type: "boolean",
|
||||
group: "auth-providers",
|
||||
},
|
||||
{
|
||||
key: "passwordAuthEnabled",
|
||||
value: "true",
|
||||
type: "boolean",
|
||||
group: "security",
|
||||
},
|
||||
{
|
||||
key: "serverUrl",
|
||||
value: "http://localhost:3333",
|
||||
|
@@ -18,6 +18,15 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemInfo(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const systemInfo = await this.appService.getSystemInfo();
|
||||
return reply.send(systemInfo);
|
||||
} catch (error: any) {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const configs = await this.appService.getAllConfigs();
|
||||
|
@@ -53,6 +53,26 @@ export async function appRoutes(app: FastifyInstance) {
|
||||
appController.getAppInfo.bind(appController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/app/system-info",
|
||||
{
|
||||
schema: {
|
||||
tags: ["App"],
|
||||
operationId: "getSystemInfo",
|
||||
summary: "Get system information",
|
||||
description: "Get system information including storage provider",
|
||||
response: {
|
||||
200: z.object({
|
||||
storageProvider: z.enum(["s3", "filesystem"]).describe("The active storage provider"),
|
||||
s3Enabled: z.boolean().describe("Whether S3 storage is enabled"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
appController.getSystemInfo.bind(appController)
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/app/configs/:key",
|
||||
{
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { isS3Enabled } from "../../config/storage.config";
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { ConfigService } from "../config/service";
|
||||
|
||||
@@ -20,6 +21,13 @@ export class AppService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return {
|
||||
storageProvider: isS3Enabled ? "s3" : "filesystem",
|
||||
s3Enabled: isS3Enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllConfigs() {
|
||||
return prisma.appConfig.findMany({
|
||||
where: {
|
||||
@@ -38,6 +46,17 @@ export class AppService {
|
||||
throw new Error("JWT Secret cannot be updated through this endpoint");
|
||||
}
|
||||
|
||||
if (key === "passwordAuthEnabled") {
|
||||
if (value === "false") {
|
||||
const canDisable = await this.configService.validatePasswordAuthDisable();
|
||||
if (!canDisable) {
|
||||
throw new Error(
|
||||
"Password authentication cannot be disabled. At least one authentication provider must be active."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
@@ -56,6 +75,15 @@ export class AppService {
|
||||
if (updates.some((update) => update.key === "jwtSecret")) {
|
||||
throw new Error("JWT Secret cannot be updated through this endpoint");
|
||||
}
|
||||
const passwordAuthUpdate = updates.find((update) => update.key === "passwordAuthEnabled");
|
||||
if (passwordAuthUpdate && passwordAuthUpdate.value === "false") {
|
||||
const canDisable = await this.configService.validatePasswordAuthDisable();
|
||||
if (!canDisable) {
|
||||
throw new Error(
|
||||
"Password authentication cannot be disabled. At least one authentication provider must be active."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = updates.map((update) => update.key);
|
||||
const existingConfigs = await prisma.appConfig.findMany({
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { ConfigService } from "../config/service";
|
||||
import { UpdateAuthProviderSchema } from "./dto";
|
||||
import { AuthProvidersService } from "./service";
|
||||
import {
|
||||
@@ -39,9 +40,11 @@ const ERROR_MESSAGES = {
|
||||
|
||||
export class AuthProvidersController {
|
||||
private authProvidersService: AuthProvidersService;
|
||||
private configService: ConfigService;
|
||||
|
||||
constructor() {
|
||||
this.authProvidersService = new AuthProvidersService();
|
||||
this.configService = new ConfigService();
|
||||
}
|
||||
|
||||
private buildRequestContext(request: FastifyRequest): RequestContext {
|
||||
@@ -223,13 +226,24 @@ export class AuthProvidersController {
|
||||
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const data = request.body;
|
||||
const data = request.body as any;
|
||||
|
||||
const existingProvider = await this.authProvidersService.getProviderById(id);
|
||||
if (!existingProvider) {
|
||||
return this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (data.enabled === false && existingProvider.enabled === true) {
|
||||
const canDisable = await this.configService.validateAllProvidersDisable();
|
||||
if (!canDisable) {
|
||||
return this.sendErrorResponse(
|
||||
reply,
|
||||
400,
|
||||
"Cannot disable the last authentication provider when password authentication is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
|
||||
|
||||
if (isOfficial) {
|
||||
@@ -300,6 +314,17 @@ export class AuthProvidersController {
|
||||
return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.OFFICIAL_CANNOT_DELETE);
|
||||
}
|
||||
|
||||
if (provider.enabled) {
|
||||
const canDisable = await this.configService.validateAllProvidersDisable();
|
||||
if (!canDisable) {
|
||||
return this.sendErrorResponse(
|
||||
reply,
|
||||
400,
|
||||
"Cannot delete the last authentication provider when password authentication is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.authProvidersService.deleteProvider(id);
|
||||
return this.sendSuccessResponse(reply, undefined, "Provider deleted successfully");
|
||||
} catch (error) {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { ConfigService } from "../config/service";
|
||||
import {
|
||||
CompleteTwoFactorLoginSchema,
|
||||
createResetPasswordSchema,
|
||||
@@ -11,6 +12,7 @@ import { AuthService } from "./service";
|
||||
|
||||
export class AuthController {
|
||||
private authService = new AuthService();
|
||||
private configService = new ConfigService();
|
||||
|
||||
private getClientInfo(request: FastifyRequest) {
|
||||
const realIP = request.headers["x-real-ip"] as string;
|
||||
@@ -169,4 +171,15 @@ export class AuthController {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthConfig(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
return reply.send({
|
||||
passwordAuthEnabled: passwordAuthEnabled === "true",
|
||||
});
|
||||
} catch (error: any) {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -280,4 +280,23 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
},
|
||||
authController.removeAllTrustedDevices.bind(authController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/auth/config",
|
||||
{
|
||||
schema: {
|
||||
tags: ["Authentication"],
|
||||
operationId: "getAuthConfig",
|
||||
summary: "Get Authentication Configuration",
|
||||
description: "Get authentication configuration settings",
|
||||
response: {
|
||||
200: z.object({
|
||||
passwordAuthEnabled: z.boolean().describe("Whether password authentication is enabled"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
authController.getAuthConfig.bind(authController)
|
||||
);
|
||||
}
|
||||
|
@@ -18,6 +18,11 @@ export class AuthService {
|
||||
private trustedDeviceService = new TrustedDeviceService();
|
||||
|
||||
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
if (passwordAuthEnabled === "false") {
|
||||
throw new Error("Password authentication is disabled. Please use an external authentication provider.");
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
@@ -146,6 +151,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string, origin: string) {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
if (passwordAuthEnabled === "false") {
|
||||
throw new Error("Password authentication is disabled. Password reset is not available.");
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUserByEmail(email);
|
||||
if (!user) {
|
||||
return;
|
||||
@@ -171,6 +181,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
if (passwordAuthEnabled === "false") {
|
||||
throw new Error("Password authentication is disabled. Password reset is not available.");
|
||||
}
|
||||
|
||||
const resetRequest = await prisma.passwordReset.findFirst({
|
||||
where: {
|
||||
token,
|
||||
|
@@ -13,6 +13,26 @@ export class ConfigService {
|
||||
return config.value;
|
||||
}
|
||||
|
||||
async setValue(key: string, value: string): Promise<void> {
|
||||
await prisma.appConfig.update({
|
||||
where: { key },
|
||||
data: { value },
|
||||
});
|
||||
}
|
||||
|
||||
async validatePasswordAuthDisable(): Promise<boolean> {
|
||||
const enabledProviders = await prisma.authProvider.findMany({
|
||||
where: { enabled: true },
|
||||
});
|
||||
|
||||
return enabledProviders.length > 0;
|
||||
}
|
||||
|
||||
async validateAllProvidersDisable(): Promise<boolean> {
|
||||
const passwordAuthEnabled = await this.getValue("passwordAuthEnabled");
|
||||
return passwordAuthEnabled === "true";
|
||||
}
|
||||
|
||||
async getGroupConfigs(group: string) {
|
||||
const configs = await prisma.appConfig.findMany({
|
||||
where: { group },
|
||||
|
@@ -167,7 +167,7 @@ export class EmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendShareNotification(to: string, shareLink: string, shareName?: string) {
|
||||
async sendShareNotification(to: string, shareLink: string, shareName?: string, senderName?: string) {
|
||||
const transporter = await this.createTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error("SMTP is not enabled");
|
||||
@@ -178,19 +178,151 @@ export class EmailService {
|
||||
const appName = await this.configService.getValue("appName");
|
||||
|
||||
const shareTitle = shareName || "Files";
|
||||
const sender = senderName || "Someone";
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to,
|
||||
subject: `${appName} - ${shareTitle} shared with you`,
|
||||
html: `
|
||||
<h1>${appName} - Shared Files</h1>
|
||||
<p>Someone has shared "${shareTitle}" with you.</p>
|
||||
<p>Click the link below to access the shared files:</p>
|
||||
<a href="${shareLink}">
|
||||
Access Shared Files
|
||||
</a>
|
||||
<p>Note: This share may have an expiration date or view limit.</p>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${appName} - Shared Files</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
|
||||
<!-- Header -->
|
||||
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
|
||||
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">Shared Files</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px 30px;">
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">Files Shared With You</h2>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
|
||||
<strong style="color: #374151;">${sender}</strong> has shared <strong style="color: #374151;">"${shareTitle}"</strong> with you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${shareLink}" style="display: inline-block; background-color: #22B14C; color: #ffffff; text-decoration: none; padding: 12px 24px; font-weight: 600; font-size: 16px; border: 2px solid #22B14C; border-radius: 8px; transition: all 0.3s ease;">
|
||||
Access Shared Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #22B14C; padding: 16px 20px; margin-top: 32px;">
|
||||
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;">
|
||||
<strong>Important:</strong> This share may have an expiration date or view limit. Access it as soon as possible to ensure availability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||
This email was sent by <strong>${appName}</strong>
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
|
||||
If you didn't expect this email, you can safely ignore it.
|
||||
</p>
|
||||
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
|
||||
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendReverseShareBatchFileNotification(
|
||||
recipientEmail: string,
|
||||
reverseShareName: string,
|
||||
fileCount: number,
|
||||
fileList: string,
|
||||
uploaderName: string
|
||||
) {
|
||||
const transporter = await this.createTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error("SMTP is not enabled");
|
||||
}
|
||||
|
||||
const fromName = await this.configService.getValue("smtpFromName");
|
||||
const fromEmail = await this.configService.getValue("smtpFromEmail");
|
||||
const appName = await this.configService.getValue("appName");
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to: recipientEmail,
|
||||
subject: `${appName} - ${fileCount} file${fileCount > 1 ? "s" : ""} uploaded to "${reverseShareName}"`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${appName} - File Upload Notification</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
|
||||
<!-- Header -->
|
||||
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
|
||||
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">File Upload Notification</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px 30px;">
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">New File Uploaded</h2>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
|
||||
<strong style="color: #374151;">${uploaderName}</strong> has uploaded <strong style="color: #374151;">${fileCount} file${fileCount > 1 ? "s" : ""}</strong> to your reverse share <strong style="color: #374151;">"${reverseShareName}"</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
<div style="background-color: #f9fafb; border-radius: 8px; padding: 16px; margin: 32px 0; border-left: 4px solid #22B14C;">
|
||||
<p style="margin: 0 0 8px 0; color: #374151; font-size: 14px;"><strong>Files (${fileCount}):</strong></p>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||
${fileList
|
||||
.split(", ")
|
||||
.map((file) => `<li style="margin: 4px 0;">${file}</li>`)
|
||||
.join("")}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Info Text -->
|
||||
<div style="text-align: center; margin-top: 32px;">
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
|
||||
You can now access and manage these files through your dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||
This email was sent by <strong>${appName}</strong>
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
|
||||
If you didn't expect this email, you can safely ignore it.
|
||||
</p>
|
||||
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
|
||||
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
@@ -226,8 +226,8 @@ export class FilesystemController {
|
||||
if (isLargeFile) {
|
||||
await this.downloadLargeFile(reply, provider, filePath);
|
||||
} else {
|
||||
const buffer = await provider.downloadFile(tokenData.objectName);
|
||||
reply.send(buffer);
|
||||
const stream = provider.createDecryptedReadStream(tokenData.objectName);
|
||||
reply.send(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,8 +255,14 @@ export class FilesystemController {
|
||||
start: number,
|
||||
end: number
|
||||
) {
|
||||
const buffer = await provider.downloadFile(objectName);
|
||||
const chunk = buffer.slice(start, end + 1);
|
||||
reply.send(chunk);
|
||||
const filePath = provider.getFilePath(objectName);
|
||||
const readStream = fs.createReadStream(filePath, { start, end });
|
||||
const decryptStream = provider.createDecryptStream();
|
||||
|
||||
try {
|
||||
await pipeline(readStream, decryptStream, reply.raw);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { EmailService } from "../email/service";
|
||||
import { FileService } from "../file/service";
|
||||
import { UserService } from "../user/service";
|
||||
import {
|
||||
CreateReverseShareInput,
|
||||
ReverseShareResponseSchema,
|
||||
@@ -41,6 +43,19 @@ const prisma = new PrismaClient();
|
||||
export class ReverseShareService {
|
||||
private reverseShareRepository = new ReverseShareRepository();
|
||||
private fileService = new FileService();
|
||||
private emailService = new EmailService();
|
||||
private userService = new UserService();
|
||||
|
||||
private uploadSessions = new Map<
|
||||
string,
|
||||
{
|
||||
reverseShareId: string;
|
||||
uploaderName: string;
|
||||
uploaderEmail?: string;
|
||||
files: string[];
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
async createReverseShare(data: CreateReverseShareInput, creatorId: string) {
|
||||
const reverseShare = await this.reverseShareRepository.create(data, creatorId);
|
||||
@@ -295,6 +310,8 @@ export class ReverseShareService {
|
||||
size: BigInt(fileData.size),
|
||||
});
|
||||
|
||||
this.addFileToUploadSession(reverseShare, fileData);
|
||||
|
||||
return this.formatFileResponse(file);
|
||||
}
|
||||
|
||||
@@ -345,6 +362,8 @@ export class ReverseShareService {
|
||||
size: BigInt(fileData.size),
|
||||
});
|
||||
|
||||
this.addFileToUploadSession(reverseShare, fileData);
|
||||
|
||||
return this.formatFileResponse(file);
|
||||
}
|
||||
|
||||
@@ -637,6 +656,55 @@ export class ReverseShareService {
|
||||
};
|
||||
}
|
||||
|
||||
private generateSessionKey(reverseShareId: string, uploaderIdentifier: string): string {
|
||||
return `${reverseShareId}-${uploaderIdentifier}`;
|
||||
}
|
||||
|
||||
private async sendBatchFileUploadNotification(reverseShare: any, uploaderName: string, fileNames: string[]) {
|
||||
try {
|
||||
const creator = await this.userService.getUserById(reverseShare.creatorId);
|
||||
const reverseShareName = reverseShare.name || "Unnamed Reverse Share";
|
||||
const fileCount = fileNames.length;
|
||||
const fileList = fileNames.join(", ");
|
||||
|
||||
await this.emailService.sendReverseShareBatchFileNotification(
|
||||
creator.email,
|
||||
reverseShareName,
|
||||
fileCount,
|
||||
fileList,
|
||||
uploaderName
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send reverse share batch file notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private addFileToUploadSession(reverseShare: any, fileData: UploadToReverseShareInput) {
|
||||
const uploaderIdentifier = fileData.uploaderEmail || fileData.uploaderName || "anonymous";
|
||||
const sessionKey = this.generateSessionKey(reverseShare.id, uploaderIdentifier);
|
||||
const uploaderName = fileData.uploaderName || "Someone";
|
||||
|
||||
const existingSession = this.uploadSessions.get(sessionKey);
|
||||
if (existingSession) {
|
||||
clearTimeout(existingSession.timeout);
|
||||
existingSession.files.push(fileData.name);
|
||||
} else {
|
||||
this.uploadSessions.set(sessionKey, {
|
||||
reverseShareId: reverseShare.id,
|
||||
uploaderName,
|
||||
uploaderEmail: fileData.uploaderEmail,
|
||||
files: [fileData.name],
|
||||
timeout: null as any,
|
||||
});
|
||||
}
|
||||
|
||||
const session = this.uploadSessions.get(sessionKey)!;
|
||||
session.timeout = setTimeout(async () => {
|
||||
await this.sendBatchFileUploadNotification(reverseShare, session.uploaderName, session.files);
|
||||
this.uploadSessions.delete(sessionKey);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private formatReverseShareResponse(reverseShare: ReverseShareData) {
|
||||
const result = {
|
||||
id: reverseShare.id,
|
||||
|
@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
|
||||
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { EmailService } from "../email/service";
|
||||
import { UserService } from "../user/service";
|
||||
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
||||
import { IShareRepository, PrismaShareRepository } from "./repository";
|
||||
|
||||
@@ -9,6 +10,7 @@ export class ShareService {
|
||||
constructor(private readonly shareRepository: IShareRepository = new PrismaShareRepository()) {}
|
||||
|
||||
private emailService = new EmailService();
|
||||
private userService = new UserService();
|
||||
|
||||
private formatShareResponse(share: any) {
|
||||
return {
|
||||
@@ -339,11 +341,26 @@ export class ShareService {
|
||||
throw new Error("No recipients found for this share");
|
||||
}
|
||||
|
||||
// Get sender information
|
||||
let senderName = "Someone";
|
||||
try {
|
||||
const sender = await this.userService.getUserById(userId);
|
||||
if (sender.firstName && sender.lastName) {
|
||||
senderName = `${sender.firstName} ${sender.lastName}`;
|
||||
} else if (sender.firstName) {
|
||||
senderName = sender.firstName;
|
||||
} else if (sender.username) {
|
||||
senderName = sender.username;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get sender information for user ${userId}:`, error);
|
||||
}
|
||||
|
||||
const notifiedRecipients: string[] = [];
|
||||
|
||||
for (const recipient of share.recipients) {
|
||||
try {
|
||||
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined);
|
||||
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined, senderName);
|
||||
notifiedRecipients.push(recipient.email);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send email to ${recipient.email}:`, error);
|
||||
|
@@ -32,6 +32,14 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return FilesystemStorageProvider.instance;
|
||||
}
|
||||
|
||||
public createDecryptedReadStream(objectName: string): NodeJS.ReadableStream {
|
||||
const filePath = this.getFilePath(objectName);
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const decryptStream = this.createDecryptStream();
|
||||
|
||||
return fileStream.pipe(decryptStream);
|
||||
}
|
||||
|
||||
private async ensureUploadsDir(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.uploadsDir);
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "نسيت كلمة المرور",
|
||||
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
|
||||
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
|
||||
"pageTitle": "نسيت كلمة المرور"
|
||||
"pageTitle": "نسيت كلمة المرور",
|
||||
"passwordAuthDisabled": "تم تعطيل المصادقة بكلمة المرور. يرجى الاتصال بالمسؤول أو استخدام مزود مصادقة خارجي."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "إنشاء رابط المشاركة",
|
||||
@@ -629,7 +630,7 @@
|
||||
},
|
||||
"status": {
|
||||
"active": "نشط",
|
||||
"inactive": "غير نشط",
|
||||
"inactive": "غير نشط",
|
||||
"expired": "منتهي الصلاحية",
|
||||
"protected": "محمي",
|
||||
"public": "عام"
|
||||
@@ -1130,6 +1131,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "الوثوق بالشهادات الموقعة ذاتياً",
|
||||
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "المصادقة بالكلمة السرية",
|
||||
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1139,7 +1144,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "فشل في تحميل الإعدادات",
|
||||
"updateFailed": "فشل في تحديث الإعدادات"
|
||||
"updateFailed": "فشل في تحديث الإعدادات",
|
||||
"passwordAuthRequiresProvider": "لا يمكن تعطيل المصادقة بالكلمة السرية دون وجود على الأقل موفرين مصادقة مفعلين"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "لا توجد تغييرات للحفظ",
|
||||
@@ -1744,4 +1750,4 @@
|
||||
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
|
||||
"download": "تحميل رمز QR"
|
||||
}
|
||||
}
|
||||
}
|
@@ -313,7 +313,8 @@
|
||||
"title": "Passwort vergessen",
|
||||
"description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen Anweisungen zum Zurücksetzen Ihres Passworts.",
|
||||
"resetInstructions": "Anweisungen zum Zurücksetzen wurden an Ihre E-Mail gesendet",
|
||||
"pageTitle": "Passwort vergessen"
|
||||
"pageTitle": "Passwort vergessen",
|
||||
"passwordAuthDisabled": "Passwortauthentifizierung ist deaktiviert. Bitte kontaktieren Sie Ihren Administrator oder verwenden Sie einen externen Authentifizierungsanbieter."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Freigabe-Link generieren",
|
||||
@@ -628,7 +629,7 @@
|
||||
"viewQrCode": "QR-Code anzeigen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"expired": "Abgelaufen",
|
||||
"protected": "Geschützt",
|
||||
@@ -636,7 +637,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Link kopieren",
|
||||
"editAlias": "Alias bearbeiten",
|
||||
"editAlias": "Alias bearbeiten",
|
||||
"createAlias": "Alias erstellen",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"tls": "STARTTLS (Port 587)",
|
||||
"none": "Keine (Unsicher)"
|
||||
}
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Passwort-Authentifizierung",
|
||||
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Fehler beim Laden der Einstellungen",
|
||||
"updateFailed": "Fehler beim Aktualisieren der Einstellungen"
|
||||
"updateFailed": "Fehler beim Aktualisieren der Einstellungen",
|
||||
"passwordAuthRequiresProvider": "Passwort-basierte Authentifizierung kann nicht deaktiviert werden, wenn kein aktiver Authentifizierungsanbieter vorhanden ist"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Keine Änderungen zum Speichern",
|
||||
@@ -1245,7 +1251,7 @@
|
||||
"editSecurity": "Sicherheit bearbeiten",
|
||||
"editExpiration": "Ablauf bearbeiten",
|
||||
"clickToEnlargeQrCode": "Klicken Sie zum Vergrößern des QR-Codes",
|
||||
"downloadQrCode": "QR-Code herunterladen",
|
||||
"downloadQrCode": "QR-Code herunterladen",
|
||||
"qrCode": "QR-Code"
|
||||
},
|
||||
"shareExpiration": {
|
||||
@@ -1742,4 +1748,4 @@
|
||||
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
|
||||
"download": "QR-Code herunterladen"
|
||||
}
|
||||
}
|
||||
}
|
@@ -313,7 +313,8 @@
|
||||
"title": "Forgot Password",
|
||||
"description": "Enter your email address and we'll send you instructions to reset your password",
|
||||
"resetInstructions": "Reset instructions sent to your email",
|
||||
"pageTitle": "Forgot Password"
|
||||
"pageTitle": "Forgot Password",
|
||||
"passwordAuthDisabled": "Password authentication is disabled. Please contact your administrator or use an external authentication provider."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Generate Share Link",
|
||||
@@ -1131,6 +1132,10 @@
|
||||
"serverUrl": {
|
||||
"title": "Server URL",
|
||||
"description": "Base URL of the Palmr server (e.g.: https://palmr.example.com)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Password Authentication",
|
||||
"description": "Enable or disable password-based authentication"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1140,7 +1145,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load settings",
|
||||
"updateFailed": "Failed to update settings"
|
||||
"updateFailed": "Failed to update settings",
|
||||
"passwordAuthRequiresProvider": "Cannot disable password authentication without having at least one active authentication provider"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "No changes to save",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Recuperar contraseña",
|
||||
"description": "Introduce tu dirección de correo electrónico y te enviaremos instrucciones para restablecer tu contraseña.",
|
||||
"resetInstructions": "Instrucciones de restablecimiento enviadas a tu correo electrónico",
|
||||
"pageTitle": "Recuperar contraseña"
|
||||
"pageTitle": "Recuperar contraseña",
|
||||
"passwordAuthDisabled": "La autenticación por contraseña está deshabilitada. Por favor, contacta a tu administrador o usa un proveedor de autenticación externo."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Generar enlace de compartir",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"tls": "STARTTLS (Puerto 587)",
|
||||
"none": "Ninguno (Inseguro)"
|
||||
}
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticación por Contraseña",
|
||||
"description": "Habilitar o deshabilitar la autenticación basada en contraseña"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Error al cargar la configuración",
|
||||
"updateFailed": "Error al actualizar la configuración"
|
||||
"updateFailed": "Error al actualizar la configuración",
|
||||
"passwordAuthRequiresProvider": "No se puede deshabilitar la autenticación por contraseña sin tener al menos un proveedor de autenticación activo"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "No hay cambios para guardar",
|
||||
@@ -1742,4 +1748,4 @@
|
||||
"description": "Escanea este código QR para acceder al enlace.",
|
||||
"download": "Descargar Código QR"
|
||||
}
|
||||
}
|
||||
}
|
@@ -313,7 +313,8 @@
|
||||
"title": "Mot de Passe Oublié",
|
||||
"description": "Entrez votre adresse email et nous vous enverrons les instructions pour réinitialiser votre mot de passe.",
|
||||
"resetInstructions": "Instructions de réinitialisation envoyées à votre email",
|
||||
"pageTitle": "Mot de Passe Oublié"
|
||||
"pageTitle": "Mot de Passe Oublié",
|
||||
"passwordAuthDisabled": "L'authentification par mot de passe est désactivée. Veuillez contacter votre administrateur ou utiliser un fournisseur d'authentification externe."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Générer un lien de partage",
|
||||
@@ -1131,6 +1132,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Faire Confiance aux Certificats Auto-signés",
|
||||
"description": "Activez cette option pour faire confiance aux certificats SSL/TLS auto-signés (utile pour les environnements de développement)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Authentification par Mot de Passe",
|
||||
"description": "Activer ou désactiver l'authentification basée sur mot de passe"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1140,7 +1145,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Échec du chargement des paramètres",
|
||||
"updateFailed": "Échec de la mise à jour des paramètres"
|
||||
"updateFailed": "Échec de la mise à jour des paramètres",
|
||||
"passwordAuthRequiresProvider": "Impossible de désactiver l'authentification par mot de passe sans avoir au moins un fournisseur d'authentification actif"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Aucun changement à enregistrer",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "पासवर्ड भूल गए",
|
||||
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
|
||||
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
|
||||
"pageTitle": "पासवर्ड भूल गए"
|
||||
"pageTitle": "पासवर्ड भूल गए",
|
||||
"passwordAuthDisabled": "पासवर्ड ऑथेंटिकेशन अक्टिवेटेड है। कृपया अपने एडमिन से संपर्क करें या एक बाहरी ऑथेंटिकेशन प्रोवाइडर का उपयोग करें।"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
|
||||
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "पासवर्ड प्रमाणीकरण",
|
||||
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "सेटिंग्स लोड करने में विफल",
|
||||
"updateFailed": "सेटिंग्स अपडेट करने में विफल"
|
||||
"updateFailed": "सेटिंग्स अपडेट करने में विफल",
|
||||
"passwordAuthRequiresProvider": "कम से कम एक सक्रिय प्रमाणीकरण प्रदाता के बिना पासवर्ड प्रमाणीकरण अक्षम नहीं किया जा सकता"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Parola d'accesso Dimenticata",
|
||||
"description": "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la parola d'accesso.",
|
||||
"resetInstructions": "Istruzioni di reimpostazione inviate alla tua email",
|
||||
"pageTitle": "Parola d'accesso Dimenticata"
|
||||
"pageTitle": "Parola d'accesso Dimenticata",
|
||||
"passwordAuthDisabled": "L'autenticazione tramite password è disabilitata. Contatta il tuo amministratore o utilizza un provider di autenticazione esterno."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Genera link di condivisione",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Accetta Certificati Auto-Firmati",
|
||||
"description": "Abilita questa opzione per accettare certificati SSL/TLS auto-firmati (utile per ambienti di sviluppo)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticazione Password",
|
||||
"description": "Abilita o disabilita l'autenticazione basata su password"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Errore durante il caricamento delle impostazioni",
|
||||
"updateFailed": "Errore durante l'aggiornamento delle impostazioni"
|
||||
"updateFailed": "Errore durante l'aggiornamento delle impostazioni",
|
||||
"passwordAuthRequiresProvider": "Impossibile disabilitare l'autenticazione password senza avere almeno un provider di autenticazione attivo"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Nessuna modifica da salvare",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "パスワードをお忘れですか?",
|
||||
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
|
||||
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
|
||||
"pageTitle": "パスワードをお忘れですか?"
|
||||
"pageTitle": "パスワードをお忘れですか?",
|
||||
"passwordAuthDisabled": "パスワード認証が無効になっています。管理者に連絡するか、外部認証プロバイダーを使用してください。"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "共有リンクを生成",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "自己署名証明書を信頼",
|
||||
"description": "自己署名SSL/TLS証明書を信頼するように設定します(開発環境で便利)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "パスワード認証",
|
||||
"description": "パスワード認証を有効または無効にする"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "設定の読み込みに失敗しました",
|
||||
"updateFailed": "設定の更新に失敗しました"
|
||||
"updateFailed": "設定の更新に失敗しました",
|
||||
"passwordAuthRequiresProvider": "少なくとも1つのアクティブな認証プロバイダーがない場合、パスワード認証を無効にできません"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "保存する変更はありません",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "비밀번호를 잊으셨나요?",
|
||||
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
|
||||
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
|
||||
"pageTitle": "비밀번호를 잊으셨나요?"
|
||||
"pageTitle": "비밀번호를 잊으셨나요?",
|
||||
"passwordAuthDisabled": "비밀번호 인증이 비활성화되어 있습니다. 관리자에게 문의하거나 외부 인증 공급자를 사용하세요."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "공유 링크 생성",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "자체 서명된 인증서 신뢰",
|
||||
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "비밀번호 인증",
|
||||
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "설정을 불러오는데 실패했습니다",
|
||||
"updateFailed": "설정 업데이트에 실패했습니다"
|
||||
"updateFailed": "설정 업데이트에 실패했습니다",
|
||||
"passwordAuthRequiresProvider": "최소 하나의 활성 인증 제공자가 없으면 비밀번호 인증을 비활성화할 수 없습니다"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "저장할 변경 사항이 없습니다",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Wachtwoord Vergeten",
|
||||
"description": "Voer je e-mailadres in en we sturen je instructies om je wachtwoord te resetten.",
|
||||
"resetInstructions": "Reset instructies verzonden naar je e-mail",
|
||||
"pageTitle": "Wachtwoord Vergeten"
|
||||
"pageTitle": "Wachtwoord Vergeten",
|
||||
"passwordAuthDisabled": "Wachtwoordauthenticatie is uitgeschakeld. Neem contact op met uw beheerder of gebruik een externe authenticatieprovider."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Deel-link genereren",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Vertrouw Zelf-Ondertekende Certificaten",
|
||||
"description": "Schakel dit in om zelf-ondertekende SSL/TLS certificaten te vertrouwen (handig voor ontwikkelomgevingen)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Wachtwoord Authenticatie",
|
||||
"description": "Wachtwoord-gebaseerde authenticatie inschakelen of uitschakelen"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Fout bij het laden van instellingen",
|
||||
"updateFailed": "Fout bij het bijwerken van instellingen"
|
||||
"updateFailed": "Fout bij het bijwerken van instellingen",
|
||||
"passwordAuthRequiresProvider": "Wachtwoordauthenticatie kan niet worden uitgeschakeld zonder ten minste één actieve authenticatieprovider"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Geen wijzigingen om op te slaan",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Zapomniałeś hasła?",
|
||||
"description": "Wprowadź swój adres e-mail, a wyślemy Ci instrukcje resetowania hasła",
|
||||
"resetInstructions": "Instrukcje resetowania wysłane na Twój adres e-mail",
|
||||
"pageTitle": "Zapomniałeś hasła?"
|
||||
"pageTitle": "Zapomniałeś hasła?",
|
||||
"passwordAuthDisabled": "Uwierzytelnianie hasłem jest wyłączone. Skontaktuj się z administratorem lub użyj zewnętrznego dostawcy uwierzytelniania."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Generuj link do udostępniania",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Zaufaj certyfikatom samopodpisanym",
|
||||
"description": "Włącz tę opcję, aby zaufać samopodpisanym certyfikatom SSL/TLS (przydatne w środowiskach deweloperskich)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Uwierzytelnianie hasłem",
|
||||
"description": "Włącz lub wyłącz uwierzytelnianie oparte na haśle"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Nie udało się załadować ustawień",
|
||||
"updateFailed": "Nie udało się zaktualizować ustawień"
|
||||
"updateFailed": "Nie udało się zaktualizować ustawień",
|
||||
"passwordAuthRequiresProvider": "Uwierzytelnianie oparte na haśle nie może być wyłączone, jeśli nie ma co najmniej jednego aktywnego dostawcy uwierzytelniania"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Brak zmian do zapisania",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Esqueceu a Senha",
|
||||
"description": "Digite seu endereço de email e enviaremos instruções para redefinir sua senha.",
|
||||
"resetInstructions": "Instruções de redefinição enviadas para seu email",
|
||||
"pageTitle": "Esqueceu a Senha"
|
||||
"pageTitle": "Esqueceu a Senha",
|
||||
"passwordAuthDisabled": "A autenticação por senha está desativada. Por favor, contate seu administrador ou use um provedor de autenticação externo."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Gerar link de compartilhamento",
|
||||
@@ -1136,6 +1137,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Confiar em Certificados Auto-Assinados",
|
||||
"description": "Ative isso para confiar em certificados SSL/TLS auto-assinados (útil para ambientes de desenvolvimento)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticação por Senha",
|
||||
"description": "Ative ou desative a autenticação baseada em senha"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1145,7 +1150,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Falha ao carregar configurações",
|
||||
"updateFailed": "Falha ao atualizar configurações"
|
||||
"updateFailed": "Falha ao atualizar configurações",
|
||||
"passwordAuthRequiresProvider": "Não é possível desabilitar a autenticação por senha sem ter pelo menos um provedor de autenticação ativo"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Nenhuma alteração para salvar",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Забыли пароль",
|
||||
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
|
||||
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
|
||||
"pageTitle": "Забыли пароль"
|
||||
"pageTitle": "Забыли пароль",
|
||||
"passwordAuthDisabled": "Парольная аутентификация отключена. Пожалуйста, свяжитесь с администратором или используйте внешний провайдер аутентификации."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Создать ссылку для обмена",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Доверять самоподписанным сертификатам",
|
||||
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Парольная аутентификация",
|
||||
"description": "Включить или отключить парольную аутентификацию"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Ошибка загрузки настроек",
|
||||
"updateFailed": "Ошибка обновления настроек"
|
||||
"updateFailed": "Ошибка обновления настроек",
|
||||
"passwordAuthRequiresProvider": "Парольную аутентификацию нельзя отключить, если нет хотя бы одного активного поставщика аутентификации"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Изменений для сохранения нет",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "Şifrenizi mi Unuttunuz?",
|
||||
"description": "E-posta adresinizi girin, şifre sıfırlama talimatlarını göndereceğiz.",
|
||||
"resetInstructions": "Şifre sıfırlama talimatları e-posta adresinize gönderildi",
|
||||
"pageTitle": "Şifrenizi mi Unuttunuz?"
|
||||
"pageTitle": "Şifrenizi mi Unuttunuz?",
|
||||
"passwordAuthDisabled": "Şifre doğrulama devre dışı. Lütfen yöneticinize başvurun veya dış doğrulama sağlayıcısı kullanın."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Paylaşım Bağlantısı Oluştur",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Kendinden İmzalı Sertifikalara Güven",
|
||||
"description": "Kendinden imzalı SSL/TLS sertifikalarına güvenmek için bunu etkinleştirin (geliştirme ortamları için kullanışlıdır)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Şifre Doğrulama",
|
||||
"description": "Şifre tabanlı doğrulamayı etkinleştirme veya devre dışı bırakma"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Ayarlar yüklenemedi",
|
||||
"updateFailed": "Ayarlar güncellenemedi"
|
||||
"updateFailed": "Ayarlar güncellenemedi",
|
||||
"passwordAuthRequiresProvider": "En az bir aktif kimlik doğrulama sağlayıcısı olmadan şifre doğrulaması devre dışı bırakılamaz"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Kaydedilecek değişiklik yok",
|
||||
|
@@ -313,7 +313,8 @@
|
||||
"title": "忘记密码?",
|
||||
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
|
||||
"resetInstructions": "密码重置指令已发送到您的电子邮件",
|
||||
"pageTitle": "忘记密码?"
|
||||
"pageTitle": "忘记密码?",
|
||||
"passwordAuthDisabled": "密码认证已禁用。请联系您的管理员或使用外部认证提供商。"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "生成分享链接",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "信任自签名证书",
|
||||
"description": "启用此选项以信任自签名SSL/TLS证书(对开发环境有用)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "密码认证",
|
||||
"description": "启用或禁用基于密码的认证"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载设置失败",
|
||||
"updateFailed": "更新设置失败"
|
||||
"updateFailed": "更新设置失败",
|
||||
"passwordAuthRequiresProvider": "没有至少一个活动认证提供者时,无法禁用密码认证"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "没有需要保存的更改",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-web",
|
||||
"version": "3.1.3-beta",
|
||||
"version": "3.1.4-beta",
|
||||
"description": "Frontend for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
|
||||
@@ -25,9 +26,24 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
const [uploaderEmail, setUploaderEmail] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
const validateFileSize = useCallback(
|
||||
(file: File): string | null => {
|
||||
if (!reverseShare.maxFileSize) return null;
|
||||
@@ -139,7 +155,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
presignedUrl: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> => {
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -148,6 +164,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
file,
|
||||
url: presignedUrl,
|
||||
chunkSize,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
|
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/app/system-info`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
32
apps/web/src/app/api/(proxy)/auth/config/route.ts
Normal file
32
apps/web/src/app/api/(proxy)/auth/config/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/auth/config`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
statusText: apiRes.statusText,
|
||||
});
|
||||
|
||||
apiRes.headers.forEach((value, key) => {
|
||||
res.headers.set(key, value);
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error("Error proxying auth config request:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@@ -8,7 +9,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requestPasswordReset } from "@/http/endpoints";
|
||||
import { getAuthConfig, requestPasswordReset } from "@/http/endpoints";
|
||||
|
||||
export type ForgotPasswordFormData = {
|
||||
email: string;
|
||||
@@ -17,16 +18,39 @@ export type ForgotPasswordFormData = {
|
||||
export function useForgotPassword() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
|
||||
const [authConfigLoading, setAuthConfigLoading] = useState(true);
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.string().email(t("validation.invalidEmail")),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAuthConfig = async () => {
|
||||
try {
|
||||
const response = await getAuthConfig();
|
||||
setPasswordAuthEnabled((response as any).data.passwordAuthEnabled);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch auth config:", error);
|
||||
setPasswordAuthEnabled(true);
|
||||
} finally {
|
||||
setAuthConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAuthConfig();
|
||||
}, []);
|
||||
|
||||
const form = useForm<ForgotPasswordFormData>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||
if (!passwordAuthEnabled) {
|
||||
toast.error(t("errors.passwordAuthDisabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await requestPasswordReset({
|
||||
email: data.email,
|
||||
@@ -46,5 +70,7 @@ export function useForgotPassword() {
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
passwordAuthEnabled,
|
||||
authConfigLoading,
|
||||
};
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||
import { StaticBackgroundLights } from "../login/components/static-background-lights";
|
||||
@@ -10,6 +12,7 @@ import { useForgotPassword } from "./hooks/use-forgot-password";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const forgotPassword = useForgotPassword();
|
||||
const t = useTranslations("ForgotPassword");
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
@@ -22,7 +25,24 @@ export default function ForgotPasswordPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
>
|
||||
<ForgotPasswordHeader />
|
||||
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
|
||||
{forgotPassword.authConfigLoading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : !forgotPassword.passwordAuthEnabled ? (
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">{t("forgotPassword.passwordAuthDisabled")}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/login">
|
||||
{t("forgotPassword.backToLogin")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -6,6 +7,7 @@ import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getEnabledProviders } from "@/http/endpoints";
|
||||
import { createLoginSchema, type LoginFormValues } from "../schemas/schema";
|
||||
import { MultiProviderButtons } from "./multi-provider-buttons";
|
||||
import { PasswordVisibilityToggle } from "./password-visibility-toggle";
|
||||
@@ -15,21 +17,50 @@ interface LoginFormProps {
|
||||
isVisible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
onSubmit: (data: LoginFormValues) => Promise<void>;
|
||||
passwordAuthEnabled: boolean;
|
||||
authConfigLoading: boolean;
|
||||
}
|
||||
|
||||
export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: LoginFormProps) {
|
||||
export function LoginForm({
|
||||
error,
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
onSubmit,
|
||||
passwordAuthEnabled,
|
||||
authConfigLoading,
|
||||
}: LoginFormProps) {
|
||||
const t = useTranslations();
|
||||
const loginSchema = createLoginSchema(t);
|
||||
const [hasEnabledProviders, setHasEnabledProviders] = useState(false);
|
||||
const [providersLoading, setProvidersLoading] = useState(true);
|
||||
|
||||
const loginSchema = createLoginSchema(t, passwordAuthEnabled);
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
password: passwordAuthEnabled ? "" : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
useEffect(() => {
|
||||
const checkProviders = async () => {
|
||||
try {
|
||||
const response = await getEnabledProviders();
|
||||
const data = response.data as any;
|
||||
setHasEnabledProviders(data.success && data.data && data.data.length > 0);
|
||||
} catch (error) {
|
||||
console.error("Error checking providers:", error);
|
||||
setHasEnabledProviders(false);
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkProviders();
|
||||
}, []);
|
||||
|
||||
const renderErrorMessage = () =>
|
||||
error && (
|
||||
<p className="text-destructive text-sm text-center bg-destructive/10 p-2 rounded-md">
|
||||
@@ -84,13 +115,41 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
/>
|
||||
);
|
||||
|
||||
if (authConfigLoading || providersLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!passwordAuthEnabled && hasEnabledProviders) {
|
||||
return (
|
||||
<>
|
||||
{renderErrorMessage()}
|
||||
<MultiProviderButtons showSeparator={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!passwordAuthEnabled && !hasEnabledProviders) {
|
||||
return (
|
||||
<>
|
||||
{renderErrorMessage()}
|
||||
<div className="text-center py-8">
|
||||
<p className="text-destructive text-sm">{t("login.noAuthMethodsAvailable")}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderErrorMessage()}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
{renderEmailOrUsernameField()}
|
||||
{renderPasswordField()}
|
||||
{passwordAuthEnabled && renderPasswordField()}
|
||||
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
|
||||
{isSubmitting ? t("login.signingIn") : t("login.signIn")}
|
||||
</Button>
|
||||
@@ -99,11 +158,13 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
|
||||
<MultiProviderButtons />
|
||||
|
||||
<div className="flex w-full items-center justify-center px-1 mt-2">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
||||
{t("login.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
{passwordAuthEnabled && (
|
||||
<div className="flex w-full items-center justify-center px-1 mt-2">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
||||
{t("login.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -9,7 +9,11 @@ import { useAppInfo } from "@/contexts/app-info-context";
|
||||
import { getEnabledProviders } from "@/http/endpoints";
|
||||
import type { EnabledAuthProvider } from "@/http/endpoints/auth/types";
|
||||
|
||||
export function MultiProviderButtons() {
|
||||
interface MultiProviderButtonsProps {
|
||||
showSeparator?: boolean;
|
||||
}
|
||||
|
||||
export function MultiProviderButtons({ showSeparator = true }: MultiProviderButtonsProps) {
|
||||
const [providers, setProviders] = useState<EnabledAuthProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { firstAccess } = useAppInfo();
|
||||
@@ -67,14 +71,16 @@ export function MultiProviderButtons() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
{showSeparator && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
|
@@ -8,7 +8,7 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { getCurrentUser, login } from "@/http/endpoints";
|
||||
import { getAuthConfig, getCurrentUser, login } from "@/http/endpoints";
|
||||
import { completeTwoFactorLogin } from "@/http/endpoints/auth/two-factor";
|
||||
import type { LoginResponse } from "@/http/endpoints/auth/two-factor/types";
|
||||
import { LoginFormValues } from "../schemas/schema";
|
||||
@@ -31,6 +31,8 @@ export function useLogin() {
|
||||
const [twoFactorUserId, setTwoFactorUserId] = useState<string | null>(null);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
|
||||
const [authConfigLoading, setAuthConfigLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const errorParam = searchParams.get("error");
|
||||
@@ -60,6 +62,22 @@ export function useLogin() {
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAuthConfig = async () => {
|
||||
try {
|
||||
const response = await getAuthConfig();
|
||||
setPasswordAuthEnabled((response as any).data.passwordAuthEnabled);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch auth config:", error);
|
||||
setPasswordAuthEnabled(true);
|
||||
} finally {
|
||||
setAuthConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAuthConfig();
|
||||
}, []);
|
||||
|
||||
const toggleVisibility = () => setIsVisible(!isVisible);
|
||||
|
||||
const onSubmit = async (data: LoginFormValues) => {
|
||||
@@ -67,7 +85,12 @@ export function useLogin() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await login(data);
|
||||
if (!passwordAuthEnabled) {
|
||||
setError(t("errors.passwordAuthDisabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await login(data as any);
|
||||
const loginData = response.data as LoginResponse;
|
||||
|
||||
if (loginData.requiresTwoFactor && loginData.userId) {
|
||||
@@ -77,7 +100,6 @@ export function useLogin() {
|
||||
}
|
||||
|
||||
if (loginData.user) {
|
||||
// Após login bem-sucedido, buscar dados completos do usuário incluindo a imagem
|
||||
try {
|
||||
const userResponse = await getCurrentUser();
|
||||
if (userResponse?.data?.user) {
|
||||
@@ -92,7 +114,6 @@ export function useLogin() {
|
||||
console.warn("Failed to fetch complete user data, using login data:", userErr);
|
||||
}
|
||||
|
||||
// Fallback para dados do login se falhar ao buscar dados completos
|
||||
const { isAdmin, ...userData } = loginData.user;
|
||||
setUser({ ...userData, image: null });
|
||||
setIsAdmin(isAdmin);
|
||||
@@ -129,7 +150,6 @@ export function useLogin() {
|
||||
rememberDevice: rememberDevice,
|
||||
});
|
||||
|
||||
// Após two-factor login bem-sucedido, buscar dados completos do usuário incluindo a imagem
|
||||
try {
|
||||
const userResponse = await getCurrentUser();
|
||||
if (userResponse?.data?.user) {
|
||||
@@ -144,7 +164,6 @@ export function useLogin() {
|
||||
console.warn("Failed to fetch complete user data after 2FA, using response data:", userErr);
|
||||
}
|
||||
|
||||
// Fallback para dados da resposta se falhar ao buscar dados completos
|
||||
const { isAdmin, ...userData } = response.data.user;
|
||||
setUser({ ...userData, image: userData.image ?? null });
|
||||
setIsAdmin(isAdmin);
|
||||
@@ -172,5 +191,7 @@ export function useLogin() {
|
||||
setTwoFactorCode,
|
||||
onTwoFactorSubmit,
|
||||
isSubmitting,
|
||||
passwordAuthEnabled,
|
||||
authConfigLoading,
|
||||
};
|
||||
}
|
||||
|
@@ -53,6 +53,8 @@ export default function LoginPage() {
|
||||
isVisible={login.isVisible}
|
||||
onSubmit={login.onSubmit}
|
||||
onToggleVisibility={login.toggleVisibility}
|
||||
passwordAuthEnabled={login.passwordAuthEnabled}
|
||||
authConfigLoading={login.authConfigLoading}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
@@ -3,10 +3,10 @@ import * as z from "zod";
|
||||
|
||||
type TFunction = ReturnType<typeof useTranslations>;
|
||||
|
||||
export const createLoginSchema = (t: TFunction) =>
|
||||
export const createLoginSchema = (t: TFunction, passwordAuthEnabled: boolean = true) =>
|
||||
z.object({
|
||||
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
|
||||
password: z.string().min(1, t("validation.passwordRequired")),
|
||||
password: passwordAuthEnabled ? z.string().min(1, t("validation.passwordRequired")) : z.string().optional(),
|
||||
});
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
|
@@ -172,8 +172,19 @@ export function useSettings() {
|
||||
}
|
||||
|
||||
await refreshAppInfo();
|
||||
} catch {
|
||||
toast.error(t("settings.errors.updateFailed"));
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.response?.data?.error || error?.message || "";
|
||||
|
||||
if (
|
||||
errorMessage.includes("autenticação por senha") ||
|
||||
errorMessage.includes("provedor de autenticação ativo") ||
|
||||
errorMessage.includes("password authentication") ||
|
||||
errorMessage.includes("authentication provider")
|
||||
) {
|
||||
toast.error(t("settings.errors.passwordAuthRequiresProvider"));
|
||||
} else {
|
||||
toast.error(t("settings.errors.updateFailed"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
@@ -43,6 +44,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
|
||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
|
||||
const generateFileId = useCallback(() => {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
@@ -124,7 +126,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -134,6 +136,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
url,
|
||||
chunkSize,
|
||||
signal: abortController.signal,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress: (progress) => {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||
},
|
||||
@@ -196,7 +199,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
[t, isS3Enabled]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -256,6 +259,20 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
[uploadFile, t, createFileUpload]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
document.addEventListener("dragleave", handleDragLeave);
|
||||
|
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
@@ -87,8 +88,23 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fileUploads.forEach((upload) => {
|
||||
@@ -252,7 +268,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -262,6 +278,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
url,
|
||||
chunkSize,
|
||||
signal: abortController.signal,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress: (progress) => {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||
},
|
||||
|
@@ -7,6 +7,7 @@ import type {
|
||||
CheckUploadAllowedResult,
|
||||
GetAppInfoResult,
|
||||
GetDiskSpaceResult,
|
||||
GetSystemInfoResult,
|
||||
RemoveLogoResult,
|
||||
UploadLogoBody,
|
||||
UploadLogoResult,
|
||||
@@ -20,6 +21,14 @@ export const getAppInfo = <TData = GetAppInfoResult>(options?: AxiosRequestConfi
|
||||
return apiInstance.get(`/api/app/info`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system information including storage provider
|
||||
* @summary Get system information
|
||||
*/
|
||||
export const getSystemInfo = <TData = GetSystemInfoResult>(options?: AxiosRequestConfig): Promise<TData> => {
|
||||
return apiInstance.get(`/api/app/system-info`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a new app logo (admin only)
|
||||
* @summary Upload app logo
|
||||
|
@@ -32,6 +32,11 @@ export interface GetAppInfo200 {
|
||||
firstUserAccess: boolean;
|
||||
}
|
||||
|
||||
export interface GetSystemInfo200 {
|
||||
storageProvider: "s3" | "filesystem";
|
||||
s3Enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveLogo200 {
|
||||
message: string;
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export interface UploadLogoBody {
|
||||
}
|
||||
|
||||
export type GetAppInfoResult = AxiosResponse<GetAppInfo200>;
|
||||
export type GetSystemInfoResult = AxiosResponse<GetSystemInfo200>;
|
||||
export type UploadLogoResult = AxiosResponse<UploadLogo200>;
|
||||
export type RemoveLogoResult = AxiosResponse<RemoveLogo200>;
|
||||
export type CheckHealthResult = AxiosResponse<CheckHealth200>;
|
||||
|
@@ -99,3 +99,9 @@ export const updateProvidersOrder = <TData = UpdateProvidersOrderResult>(
|
||||
): Promise<TData> => {
|
||||
return apiInstance.put(`/api/auth/providers/order`, updateProvidersOrderBody, options);
|
||||
};
|
||||
|
||||
export const getAuthConfig = <TData = { passwordAuthEnabled: boolean }>(
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<TData> => {
|
||||
return apiInstance.get(`/api/auth/config`, options);
|
||||
};
|
||||
|
@@ -7,6 +7,7 @@ export interface ChunkedUploadOptions {
|
||||
onProgress?: (progress: number) => void;
|
||||
onChunkComplete?: (chunkIndex: number, totalChunks: number) => void;
|
||||
signal?: AbortSignal;
|
||||
isS3Enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ChunkedUploadResult {
|
||||
@@ -23,7 +24,7 @@ export class ChunkedUploader {
|
||||
static async uploadFile(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
|
||||
const { file, url, chunkSize, onProgress, onChunkComplete, signal } = options;
|
||||
|
||||
if (!this.shouldUseChunkedUpload(file.size)) {
|
||||
if (!this.shouldUseChunkedUpload(file.size, options.isS3Enabled)) {
|
||||
throw new Error(
|
||||
`File ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)}MB) should not use chunked upload. Use regular upload instead.`
|
||||
);
|
||||
@@ -238,8 +239,13 @@ export class ChunkedUploader {
|
||||
|
||||
/**
|
||||
* Check if file should use chunked upload
|
||||
* Only use chunked upload for filesystem storage, not for S3
|
||||
*/
|
||||
static shouldUseChunkedUpload(fileSize: number): boolean {
|
||||
static shouldUseChunkedUpload(fileSize: number, isS3Enabled?: boolean): boolean {
|
||||
if (isS3Enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const threshold = 100 * 1024 * 1024; // 100MB
|
||||
const shouldUse = fileSize > threshold;
|
||||
|
||||
|
@@ -31,6 +31,7 @@ update_package_json() {
|
||||
update_package_json "apps/web/package.json" "Web App"
|
||||
update_package_json "apps/docs/package.json" "Documentation"
|
||||
update_package_json "apps/server/package.json" "API Server"
|
||||
update_package_json "./package.json" "Monorepo"
|
||||
|
||||
echo "🎉 Version update completed!"
|
||||
echo "📦 All package.json files now have version: $VERSION"
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-monorepo",
|
||||
"version": "3.1-beta",
|
||||
"version": "3.1.4-beta",
|
||||
"description": "Palmr monorepo with Husky configuration",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
|
Reference in New Issue
Block a user