Compare commits

...

40 Commits

Author SHA1 Message Date
Daniel Luiz Alves
aab4e6d9df [Release] v3.1.4-beta (#169) 2025-07-21 18:42:28 -03:00
Daniel Luiz Alves
1f097678ce chore: add monorepo version update to the update-versions script 2025-07-21 18:11:19 -03:00
Daniel Luiz Alves
96cb4a04ec chore: update package versions to 3.1.4-beta across all apps 2025-07-21 18:07:03 -03:00
Daniel Luiz Alves
b7c4b37e89 Feat: Implement disable password authentication (#168) 2025-07-21 18:02:39 -03:00
Daniel Luiz Alves
952cf27ecb refactor: streamline authentication and password handling
- Removed unnecessary parameters from the GET request in the auth config route.
- Adjusted import order in the forgot password hook for consistency.
- Cleaned up password validation logic in the login schema for better readability.
2025-07-21 17:59:40 -03:00
Daniel Luiz Alves
765810e4e5 feat: implement disable password authentication configuration and validation
- Added a new configuration option for enabling/disabling password authentication.
- Implemented validation to prevent disabling password authentication if no other authentication providers are active.
- Updated authentication and login services to handle scenarios based on the password authentication setting.
- Enhanced the UI to reflect the password authentication status and provide user feedback accordingly.
- Added translations and error messages for better user experience across multiple languages.
2025-07-21 17:43:54 -03:00
Daniel Luiz Alves
36d09a7679 Feat: Enhance email sharing functionality (#166) 2025-07-21 15:26:13 -03:00
Daniel Luiz Alves
c6d6648942 feat: implement batch file upload notifications in reverse share service
- Added functionality to send email notifications upon batch file uploads to reverse shares.
- Integrated EmailService to handle email sending with a structured HTML template for notifications.
- Enhanced ReverseShareService to manage upload sessions and trigger notifications after file uploads.
2025-07-21 15:24:16 -03:00
Daniel Luiz Alves
54ca7580b0 feat: enhance email sharing functionality with sender information and improved HTML template
- Updated the sendShareNotification method to include senderName as an optional parameter.
- Enhanced the email template with a more structured HTML layout for better presentation.
- Integrated user service to retrieve sender information based on user ID, improving the personalization of share notifications.
2025-07-21 14:11:42 -03:00
Daniel Luiz Alves
4e53d239bb Feat: Add system information endpoint and integrate s3 support (#165) 2025-07-21 11:57:40 -03:00
Daniel Luiz Alves
6491894f0e fix: update dependency in GlobalDropZone to include S3 status 2025-07-21 11:52:09 -03:00
Daniel Luiz Alves
93e05dd913 feat: add system information endpoint and integrate S3 support
- Implemented a new endpoint to retrieve system information, including the active storage provider and S3 status.
- Updated the AppService to fetch system information and return relevant data.
- Integrated system information fetching in the FileUploadSection, GlobalDropZone, and UploadFileModal components to adjust upload behavior based on S3 availability.
- Enhanced chunked upload logic to conditionally use chunked uploads based on the storage provider.
2025-07-21 11:50:13 -03:00
Daniel Luiz Alves
2efe69e50b Feat: improve file download handling with streaming support (#163) 2025-07-21 10:32:29 -03:00
Daniel Luiz Alves
761865a6a3 feat: improve file download handling with streaming support
- Replaced buffer-based file downloads with streaming for large files in FilesystemController.
- Added createDecryptedReadStream method in FilesystemStorageProvider to facilitate streaming decryption.
- Updated chunk download method to use streams, enhancing performance and memory efficiency.
2025-07-21 10:30:59 -03:00
Daniel Luiz Alves
25fed8db61 v3.1.3-beta (#160) 2025-07-18 12:55:29 -03:00
Daniel Luiz Alves
de42e1ca47 chore: bump version to 3.1.3-beta for all packages
- Updated version number in package.json files for apps/docs, apps/server, and apps/web to reflect the new beta release.
2025-07-18 11:43:53 -03:00
Daniel Luiz Alves
138e20d36d fix: update button and status messages for consistency
- Capitalized the "Activate" and "Deactivate" status messages for improved readability.
- Adjusted the button component in the users header to remove unnecessary margin from the icon, enhancing layout consistency.
2025-07-18 11:41:14 -03:00
Daniel Luiz Alves
433610286c Feat: QR Code implementation (#159) 2025-07-18 11:19:02 -03:00
Daniel Luiz Alves
236f94247a feat: add QR code download functionality to share modals
- Integrated QR code generation and download options in both ShareFileModal and ShareMultipleFilesModal.
- Updated UI components to include a download button for QR codes, enhancing user experience.
- Improved icon usage by adding download functionality alongside existing share options.
2025-07-18 11:14:56 -03:00
Daniel Luiz Alves
1a5c1de510 feat: enhance share functionality with QR code support across multiple languages
- Added new translations for QR code interactions in various languages.
- Updated share link details to include options for viewing and downloading QR codes.
- Enhanced user experience by providing clear instructions and descriptions related to QR code usage.
- Improved consistency in UI components for QR code visibility and actions.
2025-07-18 02:09:00 -03:00
Daniel Luiz Alves
6fb55005d4 feat: enhance reverse share functionality with QR code support
- Added QR code viewing and downloading capabilities in the reverse shares section.
- Updated UI components to include QR code options in share details and cards.
- Introduced new state management for handling QR code visibility.
- Enhanced translations for QR code interactions across multiple languages.
2025-07-18 01:50:33 -03:00
Daniel Luiz Alves
4779671323 feat: add QR code functionality for share links
- Introduced a new QrCodeModal component to display and download QR codes for shared links.
- Updated share management to include functionality for viewing QR codes.
- Enhanced the GenerateShareLinkModal to include QR code generation and download options.
- Updated UI components to support QR code viewing and downloading in share details and recent shares sections.
- Added translations and improved user experience for share link generation and QR code interactions.
2025-07-18 01:17:54 -03:00
Daniel Luiz Alves
e7876739e7 docs: enhance encryption documentation and performance considerations
- Added a section on performance implications of filesystem encryption in the architecture documentation.
- Updated the quick-start guide to link to the new performance considerations section, emphasizing the impact of encryption on resource usage and file access strategies.
2025-07-17 18:22:24 -03:00
Daniel Luiz Alves
e699e30af3 chore: add DEFAULT_LANGUAGE environment variable support (#158) 2025-07-17 17:27:51 -03:00
Daniel Luiz Alves
7541a2b085 chore: add DEFAULT_LANGUAGE environment variable support
- Updated docker-compose files to include a commented-out DEFAULT_LANGUAGE variable for setting the default application language.
- Modified the Dockerfile to export NEXT_PUBLIC_DEFAULT_LANGUAGE with a fallback to 'en-US'.
- Enhanced documentation in the quick-start guide to reflect the new DEFAULT_LANGUAGE variable and its usage.
- Updated i18n request handling to support multiple locales based on the DEFAULT_LANGUAGE environment variable.
2025-07-17 17:24:51 -03:00
Daniel Luiz Alves
24aa605973 chore: remove deprecated docker-compose-synology-test.yaml file 2025-07-17 14:22:34 -03:00
Daniel Luiz Alves
fd28445680 chore: standardize package version format to remove 'v' prefix (#154) 2025-07-15 16:59:13 -03:00
Daniel Luiz Alves
19b7448c3a chore: standardize package version format to remove 'v' prefix in docs, server, and web applications 2025-07-15 16:51:52 -03:00
Daniel Luiz Alves
53c39135af fix: fix suspense fallback (#153) 2025-07-15 16:40:41 -03:00
Daniel Luiz Alves
b9147038e6 style(demo): add empty line after "use client" directive 2025-07-15 16:39:37 -03:00
Daniel Luiz Alves
9a0b7f5c55 refactor(demo): extract demo logic into separate component for better maintainability 2025-07-15 16:37:35 -03:00
Daniel Luiz Alves
2a5f9f03ae v3.1.2-beta (#152) 2025-07-15 15:50:28 -03:00
Daniel Luiz Alves
78f6e36fc9 chore: update package versions to v3.1.2-beta for docs, server, and web applications 2025-07-15 15:22:12 -03:00
Daniel Luiz Alves
8e7aadd183 docs(demo): implement live demo functionality and demo page
- Added a Live Demo button to the home page that generates a unique demo ID and token, storing them in session storage.
- Created a new DemoPage component to validate access using the demo ID and token, and to manage demo creation and status checking.
- Introduced BackgroundLights component for visual effects on the demo page.
- Enhanced user experience with loading states and error handling during demo generation.
2025-07-15 15:21:00 -03:00
Daniel Luiz Alves
794a2782ac feat(demo): add DEMO_MODE environment variable and storage limits
- Introduced a new DEMO_MODE environment variable to toggle demo functionality.
- Updated FileController and ReverseShareService to limit user storage to 200MB when DEMO_MODE is enabled.
- Enhanced StorageService to reflect demo storage limits in disk space calculations.
- Added missing authentication providers in the settings form and server start script for better provider management.
2025-07-15 13:45:46 -03:00
Daniel Luiz Alves
383f26e777 Merge branch 'next' of github.com:kyantech/Palmr into next 2025-07-14 17:25:53 -03:00
Daniel Luiz Alves
2db88d3902 chore: add DISABLE_FILESYSTEM_ENCRYPTION option
- Enhanced comments in docker-compose files to clarify the purpose of environment variables, including optional settings for UID, GID, and filesystem encryption.
- Introduced DISABLE_FILESYSTEM_ENCRYPTION variable to allow users to disable file encryption, making the ENCRYPTION_KEY optional.
- Updated documentation in quick-start guide to reflect changes in environment variable usage and security warnings.
2025-07-14 17:25:27 -03:00
Daniel Luiz Alves
5e96633a1e Fix docker-compose.yaml (#147) 2025-07-11 12:34:24 -03:00
Daniel Luiz Alves
6c80ad8b2a Fix docker-compose.yaml (#146) 2025-07-11 12:33:09 -03:00
GeorgH93
96bd39eb25 Fix docker-compose.yaml
Move PALMR_UID, PALMR_GID and SECURE_SITE into environment, to fix the compose file
2025-07-11 16:15:17 +02:00
100 changed files with 2743 additions and 585 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ apps/server/dist/*
#DEFAULT
.env
.steering
data/
node_modules/

View File

@@ -133,10 +133,12 @@ set -e
echo "Starting Palmr Application..."
echo "Storage Mode: \${ENABLE_S3:-false}"
echo "Secure Site: \${SECURE_SITE:-false}"
echo "Encryption: \${DISABLE_FILESYSTEM_ENCRYPTION:-false}"
echo "Database: SQLite"
# Set global environment variables
export DATABASE_URL="file:/app/server/prisma/palmr.db"
export NEXT_PUBLIC_DEFAULT_LANGUAGE=\${DEFAULT_LANGUAGE:-en-US}
# Ensure /app/server directory exists for bind mounts
mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma

View File

@@ -1,5 +0,0 @@

> palmr-docs@3.1-beta lint /Users/daniel/clones/Palmr/apps/docs
> eslint "src/**/*.+(ts|tsx)"

View File

@@ -43,6 +43,16 @@ Palmr. uses **filesystem storage** as the default storage solution, keeping thin
- Excellent performance for local file operations
- Optional S3-compatible storage support for cloud deployments and scalability
#### Performance Considerations with Encryption
By default, filesystem storage uses encryption (AES-256-CBC) to protect files at rest, which adds CPU overhead during uploads (encryption) and downloads (decryption). This can make operations slower and consume more resources, particularly for large files or in resource-constrained environments like containers or low-end VMs.
If performance is a priority and you don't need encryption (e.g., for non-sensitive data or testing), you can disable it by setting the environment variable `DISABLE_FILESYSTEM_ENCRYPTION=true` in your `.env` file or Docker configuration. Note that disabling encryption stores files in plaintext on disk, reducing security.
For optimal performance with encryption enabled, ensure your hardware supports AES-NI acceleration (check with `cat /proc/cpuinfo | grep aes` on Linux).
As an alternative, consider using S3-compatible object storage (e.g., AWS S3 or MinIO), which can offload file storage from the local filesystem and potentially reduce local CPU overhead for encryption/decryption. See [S3 Providers](/docs/3.1-beta/s3-providers) for setup instructions.
### Fastify + Zod + TypeScript
The backend of Palmr. is powered by **Fastify**, **Zod**, and **TypeScript**, creating a robust and type-safe API layer. Fastify is a super-fast Node.js web framework optimized for performance and low overhead, designed to handle lots of concurrent requests with minimal resource usage. Zod provides runtime type validation and schema definition, ensuring all incoming data is properly validated before reaching business logic. TypeScript adds compile-time type safety throughout the entire backend codebase. This combination creates a highly reliable and maintainable backend that prevents bugs and security issues while maintaining excellent performance.

View File

@@ -56,7 +56,9 @@ services:
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
ports:
- "5487:5487" # Web interface
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
@@ -102,9 +104,11 @@ services:
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
- PALMR_UID=1000 # UID for the container processes (default is 1001)
- PALMR_GID=1000 # GID for the container processes (default is 1001)
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
@@ -128,13 +132,19 @@ docker-compose up -d
Configure Palmr. behavior through environment variables:
| Variable | Default | Description |
| ---------------- | ------- | ------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
| `ENCRYPTION_KEY` | - | **Required**: Minimum 32 characters for file encryption |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
| Variable | Default | Description |
| ------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
| `ENCRYPTION_KEY` | - | **Required** (unless encryption disabled): Minimum 32 characters for file encryption |
| `DISABLE_FILESYSTEM_ENCRYPTION` | `false` | Disable file encryption for direct filesystem access |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
| `DEFAULT_LANGUAGE` | `en-US` | Set the default application language (see supported languages in docs [here](/docs/3.1-beta/available-languages)) |
| `PALMR_UID` | `1001` | Set the UID for the container processes (OPTIONAL - default is 1001) |
| `PALMR_GID` | `1001` | Set the GID for the container processes (OPTIONAL - default is 1001) |
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production. This key encrypts your files - losing it makes files permanently inaccessible.
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production when encryption is enabled. This key encrypts your files - losing it makes files permanently inaccessible.
> **🔓 File Encryption Control**: The `DISABLE_FILESYSTEM_ENCRYPTION` variable allows you to store files without encryption for direct filesystem access. When set to `true`, the `ENCRYPTION_KEY` becomes optional. **Important**: Once set, this configuration is permanent for your deployment. Switching between encrypted and unencrypted modes will break file access for existing uploads. Choose your strategy before uploading files. For more details on performance implications of encryption, see [Performance Considerations with Encryption](/docs/3.1-beta/architecture#performance-considerations-with-encryption).
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.1-beta/reverse-proxy-configuration) guide for proper setup.
@@ -144,6 +154,8 @@ Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cry
<KeyGenerator />
> **💡 Pro Tip**: If you're using `DISABLE_FILESYSTEM_ENCRYPTION=true`, you can skip the `ENCRYPTION_KEY` entirely for a simpler setup. However, remember that files will be stored unencrypted on your filesystem.
---
## Accessing Palmr.
@@ -177,6 +189,11 @@ docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
# -e PALMR_UID=1000 # UID for the container processes (default is 1001)
# -e PALMR_GID=1000 # GID for the container processes (default is 1001)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
@@ -184,6 +201,8 @@ docker run -d \
kyantech/palmr:latest
```
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
**Bind Mount:**
```bash
@@ -191,6 +210,11 @@ docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
-e PALMR_UID=1000 # UID for the container processes (default is 1001)
-e PALMR_GID=1000 # GID for the container processes (default is 1001)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v $(pwd)/data:/app/server \

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "v3.1.1-beta",
"version": "3.1.4-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -1,3 +1,5 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import {
@@ -10,6 +12,7 @@ import {
LayoutIcon,
LockIcon,
MousePointer,
RadioIcon,
RocketIcon,
SearchIcon,
TimerIcon,
@@ -79,7 +82,23 @@ function Hero() {
<Link href={docsLink}>Documentation</Link>
</div>
</PulsatingButton>
<RippleButton
onClick={() => {
const demoId = `${Math.random().toString(36).substr(2, 9)}`;
const token = `${Math.random().toString(36).substr(2, 12)}`;
sessionStorage.setItem("demo_token", token);
sessionStorage.setItem("demo_id", demoId);
sessionStorage.setItem("demo_expires", (Date.now() + 5 * 60 * 1000).toString());
window.location.href = `/demo?id=${demoId}&token=${token}`;
}}
>
<div className="flex gap-2 items-center">
<RadioIcon size={18} />
Live Demo
</div>
</RippleButton>
<RippleButton>
<a
href="https://github.com/kyantech/Palmr"

View File

@@ -0,0 +1,225 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Palmtree } from "lucide-react";
import { motion } from "motion/react";
import { BackgroundLights } from "@/components/ui/background-lights";
import { Button } from "@/components/ui/button";
interface DemoStatus {
status: "waiting" | "ready";
url: string | null;
}
interface CreateDemoResponse {
message: string;
url: string | null;
}
function DemoClientInner() {
const searchParams = useSearchParams();
const demoId = searchParams.get("id");
const token = searchParams.get("token");
const [status, setStatus] = useState<DemoStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const validateAccess = () => {
const storedToken = sessionStorage.getItem("demo_token");
const storedId = sessionStorage.getItem("demo_id");
const expiresAt = sessionStorage.getItem("demo_expires");
if (!demoId || !token || !storedToken || !storedId || !expiresAt) {
return false;
}
if (token !== storedToken || demoId !== storedId || Date.now() > parseInt(expiresAt)) {
return false;
}
return true;
};
if (!validateAccess()) {
setError("Unauthorized access. Please use the Live Demo button to access this page.");
setIsLoading(false);
return;
}
const createDemo = async () => {
try {
const response = await fetch("https://palmr-demo-manager.kyantech.com.br/create-demo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
palmr_demo_instance_id: demoId,
}),
});
if (!response.ok) {
throw new Error("Failed to create demo");
}
const data: CreateDemoResponse = await response.json();
console.log("Demo creation response:", data);
} catch (err) {
console.error("Error creating demo:", err);
setError("Failed to create demo. Please try again.");
setIsLoading(false);
}
};
const checkStatus = async () => {
try {
const response = await fetch(`https://palmr-demo-manager.kyantech.com.br/status/${demoId}`);
if (!response.ok) {
throw new Error("Failed to check demo status");
}
const data: DemoStatus = await response.json();
setStatus(data);
if (data.status === "ready" && data.url) {
setIsLoading(false);
}
} catch (err) {
console.error("Error checking status:", err);
setError("Failed to check demo status. Please try again.");
setIsLoading(false);
}
};
createDemo();
const interval = setInterval(checkStatus, 5000); // Check every 5 seconds
checkStatus();
return () => {
clearInterval(interval);
sessionStorage.removeItem("demo_token");
sessionStorage.removeItem("demo_id");
sessionStorage.removeItem("demo_expires");
};
}, [demoId, token]);
const handleGoToDemo = () => {
if (status?.url) {
window.open(status.url, "_blank");
}
window.location.href = "/";
};
if (error) {
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<div className="text-center space-y-6 max-w-md">
<h1 className="text-2xl font-bold text-destructive">Error</h1>
<p className="text-muted-foreground">{error}</p>
<Button
onClick={() => {
sessionStorage.removeItem("demo_token");
sessionStorage.removeItem("demo_id");
sessionStorage.removeItem("demo_expires");
window.location.href = "/";
}}
>
Go Back
</Button>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="flex flex-col items-center gap-6 text-center h-full justify-center">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your demo is being generated, please wait...</h1>
<p className="text-muted-foreground max-w-lg">
This demo will be available for 30 minutes for testing. After that, all data will be permanently deleted
and become inaccessible. You can test Palmr. with a 200MB storage limit.
</p>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="container mx-auto max-w-7xl px-6 flex-grow"
>
<section className="relative flex flex-col items-center justify-center gap-6 m-auto h-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="inline-block max-w-xl text-center justify-center"
>
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<motion.span
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="text-4xl lg:text-3xl font-semibold tracking-tight text-primary"
>
Your demo is ready!
</motion.span>
<motion.span
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6, duration: 0.5 }}
className="text-3xl leading-9 font-semibold tracking-tight"
>
Click the button below to test
</motion.span>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="flex flex-col items-center gap-6"
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 1.2, duration: 0.5 }}
>
<Button onClick={handleGoToDemo} className="flex items-center gap-2 px-8 py-4 text-lg">
<Palmtree className="h-5 w-5" />
Go to Palmr. Demo
</Button>
</motion.div>
</motion.div>
</section>
</motion.div>
</div>
</div>
);
}
export default function DemoClient() {
return <DemoClientInner />;
}

View File

@@ -0,0 +1,13 @@
"use client";
import { Suspense } from "react";
import DemoClient from "./components/demo-client";
export default function DemoPage() {
return (
<Suspense>
<DemoClient />
</Suspense>
);
}

View File

@@ -0,0 +1,33 @@
import { motion } from "motion/react";
export function BackgroundLights() {
return (
<div className="absolute inset-0 -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
className="absolute -top-[20%] -left-[20%] w-[140%] h-[140%] bg-[radial-gradient(circle,rgba(34,197,94,0.15)_0%,transparent_70%)] dark:opacity-100 opacity-50"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
className="absolute -bottom-[20%] -right-[20%] w-[140%] h-[140%] bg-[radial-gradient(circle,rgba(34,197,94,0.15)_0%,transparent_70%)] dark:opacity-100 opacity-50"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: 2.5,
}}
/>
</div>
);
}

View File

@@ -1,5 +0,0 @@

> palmr-api@3.1-beta lint /Users/daniel/clones/Palmr/apps/server
> eslint "src/**/*.+(ts|tsx)"

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "v3.1.1-beta",
"version": "3.1.4-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -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",

View File

@@ -24,7 +24,7 @@ export async function buildApp() {
},
},
logger: {
level: "info",
level: "warn",
},
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024,
connectionTimeout: 0,

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
const envSchema = z.object({
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional().default("palmr-default-encryption-key-2025"),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("false"),
S3_ENDPOINT: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
@@ -13,6 +14,7 @@ const envSchema = z.object({
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
DEMO_MODE: z.union([z.literal("true"), z.literal("false")]).default("false"),
});
export const env = envSchema.parse(process.env);

View File

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

View File

@@ -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",
{

View File

@@ -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({

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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 },

View File

@@ -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>
`,
});
}

View File

@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { CheckFileInput, CheckFileSchema, RegisterFileInput, RegisterFileSchema, UpdateFileSchema } from "./dto";
@@ -55,7 +56,17 @@ export class FileController {
});
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
}
const userFiles = await prisma.file.findMany({
where: { userId },
@@ -127,7 +138,17 @@ export class FileController {
});
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
}
const userFiles = await prisma.file.findMany({
where: { userId },

View File

@@ -92,7 +92,6 @@ export class ChunkManager {
console.log(`Chunk ${chunkIndex} already uploaded, treating as success`);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
// Check if already finalizing to prevent race condition
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
@@ -128,7 +127,6 @@ export class ChunkManager {
);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
// Check if already finalizing to prevent race condition
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
@@ -199,7 +197,7 @@ export class ChunkManager {
}
/**
* Finalize upload by moving temp file to final location and encrypting
* Finalize upload by moving temp file to final location and encrypting (if enabled)
*/
private async finalizeUpload(
chunkInfo: ChunkInfo,
@@ -224,7 +222,7 @@ export class ChunkManager {
const filePath = provider.getFilePath(finalObjectName);
const dir = path.dirname(filePath);
console.log(`Starting encryption and finalization: ${finalObjectName}`);
console.log(`Starting finalization: ${finalObjectName}`);
await fs.promises.mkdir(dir, { recursive: true });
@@ -236,7 +234,6 @@ export class ChunkManager {
});
const encryptStream = provider.createEncryptStream();
// Wait for encryption to complete BEFORE cleaning up temp file
await new Promise<void>((resolve, reject) => {
const startTime = Date.now();
@@ -245,18 +242,17 @@ export class ChunkManager {
.pipe(writeStream)
.on("finish", () => {
const duration = Date.now() - startTime;
console.log(`File encrypted and saved to: ${filePath} in ${duration}ms`);
console.log(`File processed and saved to: ${filePath} in ${duration}ms`);
resolve();
})
.on("error", (error) => {
console.error("Error during encryption:", error);
console.error("Error during processing:", error);
reject(error);
});
});
console.log(`File successfully uploaded and encrypted: ${finalObjectName}`);
console.log(`File successfully uploaded and processed: ${finalObjectName}`);
// Clean up temp file AFTER encryption is complete
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);

View File

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

View File

@@ -1,6 +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,
@@ -40,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);
@@ -294,6 +310,8 @@ export class ReverseShareService {
size: BigInt(fileData.size),
});
this.addFileToUploadSession(reverseShare, fileData);
return this.formatFileResponse(file);
}
@@ -344,6 +362,8 @@ export class ReverseShareService {
size: BigInt(fileData.size),
});
this.addFileToUploadSession(reverseShare, fileData);
return this.formatFileResponse(file);
}
@@ -513,7 +533,18 @@ export class ReverseShareService {
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
}
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
}
const userFiles = await prisma.file.findMany({
where: { userId: creatorId },
select: { size: true },
@@ -625,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,

View File

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

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import { promisify } from "util";
import { PrismaClient } from "@prisma/client";
import { env } from "../../env";
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service";
@@ -21,6 +22,30 @@ export class StorageService {
return Number.isNaN(parsed) ? 0 : parsed;
}
private _parseSize(value: string): number {
if (!value) return 0;
const cleanValue = value.trim().toLowerCase();
const numericMatch = cleanValue.match(/^(\d+(?:\.\d+)?)/);
if (!numericMatch) return 0;
const numericValue = parseFloat(numericMatch[1]);
if (Number.isNaN(numericValue)) return 0;
if (cleanValue.includes("t")) {
return Math.round(numericValue * 1024 * 1024 * 1024 * 1024);
} else if (cleanValue.includes("g")) {
return Math.round(numericValue * 1024 * 1024 * 1024);
} else if (cleanValue.includes("m")) {
return Math.round(numericValue * 1024 * 1024);
} else if (cleanValue.includes("k")) {
return Math.round(numericValue * 1024);
} else {
return Math.round(numericValue);
}
}
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
try {
const { stdout, stderr } = await execAsync(command);
@@ -54,16 +79,61 @@ export class StorageService {
}
} else {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
if (command.includes("-B1")) {
total = this._safeParseInt(size);
available = this._safeParseInt(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
if (command.includes("findmnt")) {
if (lines.length >= 1) {
const parts = lines[0].trim().split(/\s+/);
if (parts.length >= 2) {
const [availStr, sizeStr] = parts;
available = this._parseSize(availStr);
total = this._parseSize(sizeStr);
}
}
} else if (command.includes("stat -f")) {
let blockSize = 0;
let totalBlocks = 0;
let freeBlocks = 0;
for (const line of lines) {
if (line.includes("Block size:")) {
blockSize = this._safeParseInt(line.split(":")[1].trim());
} else if (line.includes("Total blocks:")) {
totalBlocks = this._safeParseInt(line.split(":")[1].trim());
} else if (line.includes("Free blocks:")) {
freeBlocks = this._safeParseInt(line.split(":")[1].trim());
}
}
if (blockSize > 0 && totalBlocks > 0) {
total = totalBlocks * blockSize;
available = freeBlocks * blockSize;
} else {
return null;
}
} else if (command.includes("--output=")) {
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 2) {
const [availStr, sizeStr] = parts;
available = this._safeParseInt(availStr) * 1024;
total = this._safeParseInt(sizeStr) * 1024;
}
}
} else {
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
if (command.includes("-B1")) {
total = this._safeParseInt(size);
available = this._safeParseInt(avail);
} else if (command.includes("-h")) {
total = this._parseSize(size);
available = this._parseSize(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
@@ -72,18 +142,13 @@ export class StorageService {
if (total > 0 && available >= 0) {
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
} catch {
return null;
}
}
/**
* Gets detailed mount information for debugging
*/
private async _getMountInfo(path: string): Promise<{ filesystem: string; mountPoint: string; type: string } | null> {
try {
if (!fs.existsSync("/proc/mounts")) {
@@ -109,16 +174,11 @@ export class StorageService {
}
return bestMatch;
} catch (error) {
console.warn(`Could not get mount info for ${path}:`, error);
} catch {
return null;
}
}
/**
* Detects if a path is a bind mount or mount point by checking /proc/mounts
* Returns the actual filesystem path for bind mounts
*/
private async _detectMountPoint(path: string): Promise<string | null> {
try {
if (!fs.existsSync("/proc/mounts")) {
@@ -133,9 +193,8 @@ export class StorageService {
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [, mountPoint] = parts;
if (parts.length >= 3) {
const [device, mountPoint, filesystem] = parts;
if (path.startsWith(mountPoint) && mountPoint.length > bestMatchLength) {
bestMatch = mountPoint;
bestMatchLength = mountPoint.length;
@@ -148,24 +207,16 @@ export class StorageService {
}
return null;
} catch (error) {
console.warn(`Could not detect mount point for ${path}:`, error);
} catch {
return null;
}
}
/**
* Gets filesystem information for a specific path, with bind mount detection
*/
private async _getFileSystemInfo(
path: string
): Promise<{ total: number; available: number; mountPoint?: string } | null> {
try {
const mountInfo = await this._getMountInfo(path);
if (mountInfo && mountInfo.mountPoint !== "/") {
console.log(`📁 Bind mount detected: ${path}${mountInfo.filesystem} (${mountInfo.type})`);
}
const mountPoint = await this._detectMountPoint(path);
const targetPath = mountPoint || path;
@@ -174,7 +225,18 @@ export class StorageService {
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${targetPath}"`, `df "${targetPath}"`]
: [`df -B1 "${targetPath}"`, `df -k "${targetPath}"`, `df "${targetPath}"`];
: [
`df -B1 "${targetPath}"`,
`df -k "${targetPath}"`,
`df "${targetPath}"`,
`df -h "${targetPath}"`,
`df -T "${targetPath}"`,
`stat -f "${targetPath}"`,
`findmnt -n -o AVAIL,SIZE "${targetPath}"`,
`findmnt -n -o AVAIL,SIZE,TARGET "${targetPath}"`,
`df -P "${targetPath}"`,
`df --output=avail,size "${targetPath}"`,
];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command);
@@ -187,25 +249,54 @@ export class StorageService {
}
return null;
} catch (error) {
console.warn(`Error getting filesystem info for ${path}:`, error);
} catch {
return null;
}
}
private async _detectSynologyVolumes(): Promise<string[]> {
try {
if (!fs.existsSync("/proc/mounts")) {
return [];
}
const mountsContent = await fs.promises.readFile("/proc/mounts", "utf8");
const lines = mountsContent.split("\n").filter((line) => line.trim());
const synologyPaths: string[] = [];
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [, mountPoint] = parts;
if (mountPoint.match(/^\/volume\d+$/)) {
synologyPaths.push(mountPoint);
}
}
}
return synologyPaths;
} catch {
return [];
}
}
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
const basePaths = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server/temp-uploads", "/app/server/temp-chunks", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
const synologyPaths = await this._detectSynologyVolumes();
const pathsToTry = [...basePaths, ...synologyPaths];
for (const pathToCheck of pathsToTry) {
if (pathToCheck.includes("uploads")) {
if (pathToCheck.includes("uploads") || pathToCheck.includes("temp-")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
} catch {
continue;
}
}
@@ -214,12 +305,8 @@ export class StorageService {
continue;
}
// Use the new filesystem detection method
const result = await this._getFileSystemInfo(pathToCheck);
if (result) {
if (result.mountPoint) {
console.log(`✅ Storage resolved via bind mount: ${result.mountPoint}`);
}
return { total: result.total, available: result.available };
}
}
@@ -237,52 +324,94 @@ export class StorageService {
uploadAllowed: boolean;
}> {
try {
const isDemoMode = env.DEMO_MODE === "true";
if (isAdmin) {
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (isDemoMode) {
const demoMaxStorage = 200 * 1024 * 1024;
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
if (!diskInfo) {
console.error("❌ Could not determine disk space - system configuration issue");
throw new Error("Unable to determine actual disk space - system configuration issue");
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
} else {
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (!diskInfo) {
throw new Error("Unable to determine actual disk space - system configuration issue");
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
return {
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1,
};
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
return {
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
};
} else if (userId) {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
if (isDemoMode) {
const demoMaxStorage = 200 * 1024 * 1024;
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
} else {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
return {
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
}
}
throw new Error("User ID is required for non-admin users");
} catch (error) {
console.error("Error getting disk space:", error);
console.error("Error getting disk space:", error);
throw new Error(
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
);

View File

@@ -8,12 +8,12 @@ import { pipeline } from "stream/promises";
import { directoriesConfig, getTempFilePath } from "../config/directories.config";
import { env } from "../env";
import { StorageProvider } from "../types/storage";
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
export class FilesystemStorageProvider implements StorageProvider {
private static instance: FilesystemStorageProvider;
private uploadsDir: string;
private encryptionKey = env.ENCRYPTION_KEY;
private isEncryptionDisabled = env.DISABLE_FILESYSTEM_ENCRYPTION === "true";
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
@@ -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);
@@ -66,6 +74,15 @@ export class FilesystemStorageProvider implements StorageProvider {
}
public createEncryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
transform(chunk, encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
@@ -101,6 +118,15 @@ export class FilesystemStorageProvider implements StorageProvider {
}
public createDecryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
transform(chunk, encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
let iv: Buffer | null = null;
let decipher: crypto.Decipher | null = null;
@@ -213,32 +239,26 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
private encryptFileBuffer(buffer: Buffer): Buffer {
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const encrypted = Buffer.concat([iv, cipher.update(buffer), cipher.final()]);
return encrypted;
}
async downloadFile(objectName: string): Promise<Buffer> {
const filePath = this.getFilePath(objectName);
const encryptedBuffer = await fs.readFile(filePath);
const fileBuffer = await fs.readFile(filePath);
if (encryptedBuffer.length > 16) {
if (this.isEncryptionDisabled) {
return fileBuffer;
}
if (fileBuffer.length > 16) {
try {
return this.decryptFileBuffer(encryptedBuffer);
return this.decryptFileBuffer(fileBuffer);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(encryptedBuffer);
return this.decryptFileLegacy(fileBuffer);
}
}
return this.decryptFileLegacy(encryptedBuffer);
return this.decryptFileLegacy(fileBuffer);
}
private decryptFileBuffer(encryptedBuffer: Buffer): Buffer {

View File

@@ -101,7 +101,9 @@ async function startServer() {
}
console.log(`🌴 Palmr server running on port 3333 🌴`);
console.log(`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : "Local Filesystem (Encrypted)"}`);
console.log(
`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : `Local Filesystem ${env.DISABLE_FILESYSTEM_ENCRYPTION === "true" ? "(Unencrypted)" : "(Encrypted)"}`}`
);
console.log(`🔐 Auth Providers: ${authProviders}`);
console.log("\n📚 API Documentation:");

View File

@@ -1 +1,2 @@
API_BASE_URL=http:localhost:3333
API_BASE_URL=http:localhost:3333
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US

View File

@@ -1,5 +0,0 @@

> palmr-web@3.1-beta lint /Users/daniel/clones/Palmr/apps/web
> eslint "src/**/*.+(ts|tsx)"

View File

@@ -313,7 +313,8 @@
"title": "نسيت كلمة المرور",
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
"pageTitle": "نسيت كلمة المرور"
"pageTitle": "نسيت كلمة المرور",
"passwordAuthDisabled": "تم تعطيل المصادقة بكلمة المرور. يرجى الاتصال بالمسؤول أو استخدام مزود مصادقة خارجي."
},
"generateShareLink": {
"generateTitle": "إنشاء رابط المشاركة",
@@ -327,7 +328,12 @@
"copyButton": "نسخ الرابط",
"success": "تم إنشاء الرابط بنجاح",
"error": "فشل في إنشاء الرابط",
"copied": "تم نسخ الرابط إلى الحافظة"
"copied": "تم نسخ الرابط إلى الحافظة",
"readyDescription": "رابط المشاركة الخاص بك جاهز. يمكنك مسح رمز QR مباشرة، أو تنزيله للاستخدام لاحقًا، أو نسخ الرابط أدناه.",
"tabs": {
"link": "الرابط",
"qrcode": "رمز QR"
}
},
"home": {
"description": "البديل مفتوح المصدر لـ WeTransfer. شارك ملفاتك بأمان، دون تتبع أو قيود.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
"categoryBadge": "{category} ({count} أيقونات)"
},
"imageEdit": {
"title": "تعديل الصورة",
"rotate": "تدوير",
"zoom": "تكبير/تصغير",
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
},
"login": {
"welcome": "مرحبا بك",
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
@@ -613,7 +625,8 @@
"createLink": "إنشاء رابط",
"delete": "حذف",
"copyLinkTitle": "نسخ الرابط",
"createLinkCTA": "إنشاء رابط استلام"
"createLinkCTA": "إنشاء رابط استلام",
"viewQrCode": "عرض رمز QR"
},
"status": {
"active": "نشط",
@@ -629,7 +642,8 @@
"viewDetails": "عرض التفاصيل",
"edit": "تحرير",
"delete": "حذف",
"viewFiles": "الملفات المستلمة"
"viewFiles": "الملفات المستلمة",
"viewQrCode": "عرض رمز QR"
},
"empty": {
"title": "لم يتم إنشاء روابط استلام",
@@ -1117,6 +1131,10 @@
"smtpTrustSelfSigned": {
"title": "الوثوق بالشهادات الموقعة ذاتياً",
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
},
"passwordAuthEnabled": {
"title": "المصادقة بالكلمة السرية",
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
}
},
"buttons": {
@@ -1126,7 +1144,8 @@
},
"errors": {
"loadFailed": "فشل في تحميل الإعدادات",
"updateFailed": "فشل في تحديث الإعدادات"
"updateFailed": "فشل في تحديث الإعدادات",
"passwordAuthRequiresProvider": "لا يمكن تعطيل المصادقة بالكلمة السرية دون وجود على الأقل موفرين مصادقة مفعلين"
},
"messages": {
"noChanges": "لا توجد تغييرات للحفظ",
@@ -1232,7 +1251,10 @@
"invalidDate": "تاريخ غير صحيح",
"loadError": "فشل في تحميل تفاصيل المشاركة",
"editSecurity": "تحرير الأمان",
"editExpiration": "تحرير انتهاء الصلاحية"
"editExpiration": "تحرير انتهاء الصلاحية",
"clickToEnlargeQrCode": "انقر لتكبير رمز QR",
"downloadQrCode": "تحميل رمز QR",
"qrCode": "رمز QR"
},
"shareExpiration": {
"neverExpires": "لا تنتهي صلاحيته أبداً",
@@ -1419,7 +1441,8 @@
"copyLink": "نسخ الرابط",
"notifyRecipients": "إشعار المستقبلين",
"delete": "حذف",
"downloadShareFiles": "قم بتنزيل جميع الملفات"
"downloadShareFiles": "قم بتنزيل جميع الملفات",
"viewQrCode": "عرض رمز QR"
},
"bulkActions": {
"delete": "حذف",
@@ -1722,10 +1745,9 @@
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"imageEdit": {
"title": "تعديل الصورة",
"rotate": "تدوير",
"zoom": كبير/تصغير",
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
"qrCodeModal": {
"title": "مشاركة رمز QR",
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
"download": حميل رمز QR"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Link kopieren",
"success": "Link erfolgreich generiert",
"error": "Fehler beim Generieren des Links",
"copied": "Link in die Zwischenablage kopiert"
"copied": "Link in die Zwischenablage kopiert",
"readyDescription": "Ihr Freigabe-Link ist bereit. Sie können den QR-Code direkt scannen, ihn für die spätere Verwendung herunterladen oder den Link unten kopieren.",
"tabs": {
"link": "Link",
"qrcode": "QR-Code"
}
},
"home": {
"description": "Die Open-Source-Alternative zu WeTransfer. Teilen Sie Dateien sicher, ohne Tracking oder Einschränkungen.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} Symbole aus {libraryCount} Bibliotheken",
"categoryBadge": "{category} ({count} Symbole)"
},
"imageEdit": {
"title": "Bild bearbeiten",
"rotate": "Drehen",
"zoom": "Zoom",
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
},
"login": {
"welcome": "Willkommen zu",
"signInToContinue": "Melden Sie sich an, um fortzufahren",
@@ -613,7 +625,8 @@
"createLink": "Link erstellen",
"delete": "Löschen",
"copyLinkTitle": "Link kopieren",
"createLinkCTA": "Empfangslink erstellen"
"createLinkCTA": "Empfangslink erstellen",
"viewQrCode": "QR-Code anzeigen"
},
"status": {
"active": "Aktiv",
@@ -629,7 +642,8 @@
"viewDetails": "Details anzeigen",
"edit": "Bearbeiten",
"delete": "Löschen",
"viewFiles": "Empfangene Dateien"
"viewFiles": "Empfangene Dateien",
"viewQrCode": "QR-Code anzeigen"
},
"empty": {
"title": "Keine Empfangslinks erstellt",
@@ -1115,6 +1129,10 @@
"tls": "STARTTLS (Port 587)",
"none": "Keine (Unsicher)"
}
},
"passwordAuthEnabled": {
"title": "Passwort-Authentifizierung",
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
}
},
"buttons": {
@@ -1124,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Ungültiges Datum",
"loadError": "Fehler beim Laden der Freigabe-Details",
"editSecurity": "Sicherheit bearbeiten",
"editExpiration": "Ablauf bearbeiten"
"editExpiration": "Ablauf bearbeiten",
"clickToEnlargeQrCode": "Klicken Sie zum Vergrößern des QR-Codes",
"downloadQrCode": "QR-Code herunterladen",
"qrCode": "QR-Code"
},
"shareExpiration": {
"neverExpires": "Läuft nie ab",
@@ -1417,7 +1439,8 @@
"copyLink": "Link Kopieren",
"notifyRecipients": "Empfänger Benachrichtigen",
"delete": "Löschen",
"downloadShareFiles": "Laden Sie alle Dateien herunter"
"downloadShareFiles": "Laden Sie alle Dateien herunter",
"viewQrCode": "QR-Code anzeigen"
},
"bulkActions": {
"delete": "Löschen",
@@ -1720,10 +1743,9 @@
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"imageEdit": {
"title": "Bild bearbeiten",
"rotate": "Drehen",
"zoom": "Zoom",
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
"qrCodeModal": {
"title": "QR-Code teilen",
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
"download": "QR-Code herunterladen"
}
}

View File

@@ -313,21 +313,27 @@
"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",
"updateTitle": "Update Share Link",
"generateDescription": "Generate a link to share your files",
"updateDescription": "Update the alias for this share link",
"aliasPlaceholder": "Enter alias",
"linkReady": "Your share link is ready:",
"generateDescription": "Generate a custom link for this share. You can customize the URL to make it more memorable.",
"updateDescription": "Update the custom link for this share. You can customize the URL to make it more memorable.",
"aliasPlaceholder": "Custom ID for link",
"linkReady": "Your share link is ready. You can copy it now.",
"readyDescription": "Your share link is ready. You can scan the QR code directly, download it for later use, or copy the link below.",
"generateButton": "Generate Link",
"updateButton": "Update Link",
"copyButton": "Copy Link",
"success": "Link generated successfully",
"error": "Failed to generate link",
"copied": "Link copied to clipboard"
"copied": "Link copied to clipboard",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "The open-source alternative to WeTransfer. Share files securely, without tracking or limitations.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} icons from {libraryCount} libraries",
"categoryBadge": "{category} ({count} icons)"
},
"imageEdit": {
"title": "Edit Image",
"rotate": "Rotate",
"zoom": "Zoom",
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
},
"login": {
"welcome": "Welcome to",
"signInToContinue": "Sign in to continue",
@@ -400,12 +412,6 @@
"navigation": {
"dashboard": "Dashboard"
},
"imageEdit": {
"title": "Edit Image",
"rotate": "Rotate",
"zoom": "Zoom",
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
},
"profile": {
"password": {
"title": "Change Password",
@@ -448,6 +454,11 @@
},
"pageTitle": "Profile"
},
"qrCodeModal": {
"title": "Share QR Code",
"description": "Scan this QR code to access the link.",
"download": "Download QR Code"
},
"quickAccess": {
"files": {
"title": "My Files",
@@ -613,6 +624,7 @@
"expired": "Expired",
"expires": "Expires",
"viewDetails": "View details",
"viewQrCode": "View QR Code",
"copyLink": "Copy Link",
"openInNewTab": "Open in New Tab",
"editLink": "Edit Link",
@@ -635,7 +647,8 @@
"viewDetails": "View Details",
"edit": "Edit",
"delete": "Delete",
"viewFiles": "Received Files"
"viewFiles": "Received Files",
"viewQrCode": "View QR Code"
},
"empty": {
"title": "No receive links created",
@@ -1119,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": {
@@ -1128,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",
@@ -1206,34 +1224,36 @@
},
"shareDetails": {
"title": "Share Details",
"subtitle": "Detailed information about this share",
"subtitle": "View and manage details for this share",
"basicInfo": "Basic Information",
"name": "Name",
"description": "Description",
"noDescription": "No description provided",
"untitled": "Untitled",
"shareLink": "Share Link",
"editLink": "Edit Link",
"generateLink": "Generate Link",
"noLink": "No link generated yet",
"copyLink": "Copy link",
"openLink": "Open in new tab",
"linkCopied": "Link copied to clipboard",
"views": "Views",
"dates": "Dates",
"security": "Security",
"files": "Files",
"recipients": "Recipients",
"views": "Views",
"created": "Created",
"expires": "Expires",
"never": "Never",
"security": "Security",
"editSecurity": "Edit Security",
"editExpiration": "Edit Expiration",
"untitled": "Untitled Share",
"noDescription": "No description",
"notAvailable": "N/A",
"invalidDate": "Invalid date",
"passwordProtected": "Password Protected",
"publicAccess": "Public Access",
"maxViews": "Max Views:",
"files": "Files",
"recipients": "Recipients",
"notAvailable": "N/A",
"invalidDate": "Invalid date",
"noLink": "No link generated",
"generateLink": "Generate Link",
"editLink": "Edit Link",
"copyLink": "Copy Link",
"openLink": "Open Link",
"editSecurity": "Edit Security",
"editExpiration": "Edit Expiration",
"qrCode": "QR Code",
"downloadQrCode": "Download QR Code",
"clickToEnlargeQrCode": "Click to enlarge QR Code",
"loadError": "Failed to load share details"
},
"shareExpiration": {
@@ -1419,6 +1439,7 @@
"generateLink": "Generate Link",
"editLink": "Edit Link",
"copyLink": "Copy Link",
"viewQrCode": "View QR Code",
"notifyRecipients": "Notify Recipients",
"downloadShareFiles": "Download All Files",
"delete": "Delete"
@@ -1688,8 +1709,8 @@
"status": {
"title": "Confirm Status Change",
"confirmation": "Are you sure you want to {action} user {firstName} {lastName}?",
"activate": "activate",
"deactivate": "deactivate",
"activate": "Activate",
"deactivate": "Deactivate",
"user": "User"
},
"header": {

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Copiar enlace",
"success": "Enlace generado exitosamente",
"error": "Error al generar enlace",
"copied": "Enlace copiado al portapapeles"
"copied": "Enlace copiado al portapapeles",
"readyDescription": "Tu enlace de compartir está listo. Puedes escanear el código QR directamente, descargarlo para usarlo más tarde, o copiar el enlace a continuación.",
"tabs": {
"link": "Enlace",
"qrcode": "Código QR"
}
},
"home": {
"description": "La alternativa de código abierto a WeTransfer. Comparte archivos de forma segura, sin rastreo ni limitaciones.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} iconos de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} iconos)"
},
"imageEdit": {
"title": "Editar Imagen",
"rotate": "Rotar",
"zoom": "Zoom",
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
},
"login": {
"welcome": "Bienvenido a",
"signInToContinue": "Inicia sesión para continuar",
@@ -613,7 +625,8 @@
"createLink": "Crear Enlace",
"delete": "Eliminar",
"copyLinkTitle": "Copiar enlace",
"createLinkCTA": "Crear Enlace de Recepción"
"createLinkCTA": "Crear Enlace de Recepción",
"viewQrCode": "Ver Código QR"
},
"status": {
"active": "Activo",
@@ -629,7 +642,8 @@
"viewDetails": "Ver Detalles",
"edit": "Editar",
"delete": "Eliminar",
"viewFiles": "Archivos Recibidos"
"viewFiles": "Archivos Recibidos",
"viewQrCode": "Ver Código QR"
},
"empty": {
"title": "Ningún enlace de recepción creado",
@@ -1115,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": {
@@ -1124,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Fecha inválida",
"loadError": "Error al cargar detalles del compartir",
"editSecurity": "Editar Seguridad",
"editExpiration": "Editar Expiración"
"editExpiration": "Editar Expiración",
"clickToEnlargeQrCode": "Haz clic para ampliar el Código QR",
"downloadQrCode": "Descargar Código QR",
"qrCode": "Código QR"
},
"shareExpiration": {
"neverExpires": "Nunca Expira",
@@ -1417,7 +1439,8 @@
"copyLink": "Copiar Enlace",
"notifyRecipients": "Notificar Destinatarios",
"delete": "Eliminar",
"downloadShareFiles": "Descargar todos los archivos"
"downloadShareFiles": "Descargar todos los archivos",
"viewQrCode": "Ver Código QR"
},
"bulkActions": {
"delete": "Eliminar",
@@ -1720,10 +1743,9 @@
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"imageEdit": {
"title": "Editar Imagen",
"rotate": "Rotar",
"zoom": "Zoom",
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
"qrCodeModal": {
"title": "Compartir Código QR",
"description": "Escanea este código QR para acceder al enlace.",
"download": "Descargar Código QR"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Copier le lien",
"success": "Lien généré avec succès",
"error": "Échec de la génération du lien",
"copied": "Lien copié dans le presse-papiers"
"copied": "Lien copié dans le presse-papiers",
"readyDescription": "Votre lien de partage est prêt. Vous pouvez scanner le QR code directement, le télécharger pour une utilisation ultérieure, ou copier le lien ci-dessous.",
"tabs": {
"link": "Lien",
"qrcode": "QR Code"
}
},
"home": {
"description": "L'alternative open-source à WeTransfer. Partagez des fichiers en toute sécurité, sans suivi ni limitations.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} icônes de {libraryCount} bibliothèques",
"categoryBadge": "{category} ({count} icônes)"
},
"imageEdit": {
"title": "Modifier l'Image",
"rotate": "Tourner",
"zoom": "Zoom",
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
},
"login": {
"welcome": "Bienvenue à",
"signInToContinue": "Connectez-vous pour continuer",
@@ -613,7 +625,8 @@
"createLink": "Créer un Lien",
"delete": "Supprimer",
"copyLinkTitle": "Copier le lien",
"createLinkCTA": "Créer un Lien de Réception"
"createLinkCTA": "Créer un Lien de Réception",
"viewQrCode": "Voir le QR Code"
},
"status": {
"active": "Actif",
@@ -629,7 +642,8 @@
"viewDetails": "Voir les Détails",
"edit": "Modifier",
"delete": "Supprimer",
"viewFiles": "Fichiers Reçus"
"viewFiles": "Fichiers Reçus",
"viewQrCode": "Voir le QR Code"
},
"empty": {
"title": "Aucun lien de réception créé",
@@ -1118,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": {
@@ -1127,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Date invalide",
"loadError": "Échec du chargement des détails du partage",
"editSecurity": "Modifier la Sécurité",
"editExpiration": "Modifier l'Expiration"
"editExpiration": "Modifier l'Expiration",
"clickToEnlargeQrCode": "Cliquez pour agrandir le Code QR",
"downloadQrCode": "Télécharger le Code QR",
"qrCode": "Code QR"
},
"shareExpiration": {
"neverExpires": "N'expire Jamais",
@@ -1417,7 +1439,8 @@
"copyLink": "Copier le Lien",
"notifyRecipients": "Notifier les Destinataires",
"delete": "Supprimer",
"downloadShareFiles": "Télécharger tous les fichiers"
"downloadShareFiles": "Télécharger tous les fichiers",
"viewQrCode": "Voir le QR Code"
},
"bulkActions": {
"delete": "Supprimer",
@@ -1720,10 +1743,9 @@
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"imageEdit": {
"title": "Modifier l'Image",
"rotate": "Tourner",
"zoom": "Zoom",
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
"qrCodeModal": {
"title": "Code QR de Partage",
"description": "Scannez ce code QR pour accéder au lien.",
"download": "Télécharger le Code QR"
}
}

View File

@@ -313,7 +313,8 @@
"title": "पासवर्ड भूल गए",
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
"pageTitle": "पासवर्ड भूल गए"
"pageTitle": "पासवर्ड भूल गए",
"passwordAuthDisabled": "पासवर्ड ऑथेंटिकेशन अक्टिवेटेड है। कृपया अपने एडमिन से संपर्क करें या एक बाहरी ऑथेंटिकेशन प्रोवाइडर का उपयोग करें।"
},
"generateShareLink": {
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
@@ -327,7 +328,12 @@
"copyButton": "लिंक कॉपी करें",
"success": "लिंक सफलतापूर्वक उत्पन्न हुआ",
"error": "लिंक उत्पन्न करने में विफल",
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया"
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया",
"readyDescription": "आपका साझाकरण लिंक तैयार है। आप डायरेक्ट रूप से QR कोड स्कैन कर सकते हैं, इसे बाद में उपयोग के लिए डाउनलोड कर सकते हैं, या नीचे लिंक कॉपी कर सकते हैं।",
"tabs": {
"link": "लिंक",
"qrcode": "QR कोड"
}
},
"home": {
"description": "WeTransfer का ओपन-सोर्स विकल्प। फाइलें सुरक्षित रूप से साझा करें, बिना ट्रैकिंग या सीमाओं के।",
@@ -355,6 +361,12 @@
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
"categoryBadge": "{category} ({count} आइकन)"
},
"imageEdit": {
"title": "छवि संपादित करें",
"rotate": "घुमाएं",
"zoom": "ज़ूम",
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
},
"login": {
"welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें",
@@ -613,7 +625,8 @@
"createLink": "लिंक बनाएं",
"delete": "हटाएं",
"copyLinkTitle": "लिंक कॉपी करें",
"createLinkCTA": "प्राप्ति लिंक बनाएं"
"createLinkCTA": "प्राप्ति लिंक बनाएं",
"viewQrCode": "QR कोड देखें"
},
"status": {
"active": "सक्रिय",
@@ -629,7 +642,8 @@
"viewDetails": "विवरण देखें",
"edit": "संपादित करें",
"delete": "हटाएं",
"viewFiles": "प्राप्त फ़ाइलें"
"viewFiles": "प्राप्त फ़ाइलें",
"viewQrCode": "QR कोड देखें"
},
"empty": {
"title": "कोई प्राप्ति लिंक नहीं बनाया गया",
@@ -1115,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
},
"passwordAuthEnabled": {
"title": "पासवर्ड प्रमाणीकरण",
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
}
},
"buttons": {
@@ -1124,7 +1142,8 @@
},
"errors": {
"loadFailed": "सेटिंग्स लोड करने में विफल",
"updateFailed": "सेटिंग्स अपडेट करने में विफल"
"updateFailed": "सेटिंग्स अपडेट करने में विफल",
"passwordAuthRequiresProvider": "कम से कम एक सक्रिय प्रमाणीकरण प्रदाता के बिना पासवर्ड प्रमाणीकरण अक्षम नहीं किया जा सकता"
},
"messages": {
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
@@ -1230,7 +1249,10 @@
"invalidDate": "अमान्य तिथि",
"loadError": "साझाकरण विवरण लोड करने में विफल",
"editSecurity": "सुरक्षा संपादित करें",
"editExpiration": "समाप्ति संपादित करें"
"editExpiration": "समाप्ति संपादित करें",
"clickToEnlargeQrCode": "QR कोड को बड़ा करने के लिए क्लिक करें",
"downloadQrCode": "QR कोड डाउनलोड करें",
"qrCode": "QR कोड"
},
"shareExpiration": {
"neverExpires": "कभी समाप्त नहीं होता",
@@ -1417,7 +1439,8 @@
"copyLink": "लिंक कॉपी करें",
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
"delete": "हटाएं",
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें"
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें",
"viewQrCode": "QR कोड देखें"
},
"bulkActions": {
"delete": "हटाएं",
@@ -1720,10 +1743,9 @@
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"imageEdit": {
"title": "छवि संपादित करें",
"rotate": "घुमाएं",
"zoom": "ज़ूम",
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
"qrCodeModal": {
"title": "QR कोड साझा करें",
"description": "इस QR कोड को स्कैन करके लिंक तक पहुंच सकते हैं।",
"download": "QR कोड डाउनलोड करें"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Copia link",
"success": "Link generato con successo",
"error": "Errore nella generazione del link",
"copied": "Link copiato negli appunti"
"copied": "Link copiato negli appunti",
"readyDescription": "Il tuo link di condivisione è pronto. Puoi scansionare il codice QR direttamente, scaricarlo per un uso successivo, o copiare il link qui sotto.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "L'alternativa open-source a WeTransfer. Condividi file in sicurezza, senza tracciamento o limitazioni.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} icone da {libraryCount} librerie",
"categoryBadge": "{category} ({count} icone)"
},
"imageEdit": {
"title": "Modifica Immagine",
"rotate": "Ruota",
"zoom": "Zoom",
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
},
"login": {
"welcome": "Benvenuto in",
"signInToContinue": "Accedi per continuare",
@@ -613,7 +625,8 @@
"createLink": "Crea Link",
"delete": "Elimina",
"copyLinkTitle": "Copia link",
"createLinkCTA": "Crea Link di Ricezione"
"createLinkCTA": "Crea Link di Ricezione",
"viewQrCode": "Visualizza QR Code"
},
"status": {
"active": "Attivo",
@@ -629,7 +642,8 @@
"viewDetails": "Vedi Dettagli",
"edit": "Modifica",
"delete": "Elimina",
"viewFiles": "File Ricevuti"
"viewFiles": "File Ricevuti",
"viewQrCode": "Visualizza QR Code"
},
"empty": {
"title": "Nessun link di ricezione creato",
@@ -1115,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": {
@@ -1124,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Data non valida",
"loadError": "Errore nel caricamento dei dettagli della condivisione",
"editSecurity": "Modifica Sicurezza",
"editExpiration": "Modifica Scadenza"
"editExpiration": "Modifica Scadenza",
"clickToEnlargeQrCode": "Clicca per ingrandire il QR Code",
"downloadQrCode": "Scarica QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "Non Scade Mai",
@@ -1417,7 +1439,8 @@
"copyLink": "Copia Link",
"notifyRecipients": "Notifica Destinatari",
"delete": "Elimina",
"downloadShareFiles": "Scarica tutti i file"
"downloadShareFiles": "Scarica tutti i file",
"viewQrCode": "Visualizza QR Code"
},
"bulkActions": {
"delete": "Elimina",
@@ -1720,10 +1743,9 @@
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"imageEdit": {
"title": "Modifica Immagine",
"rotate": "Ruota",
"zoom": "Zoom",
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
"qrCodeModal": {
"title": "Condividi QR Code",
"description": "Scansiona questo codice QR per accedere al link.",
"download": "Scarica QR Code"
}
}

View File

@@ -313,7 +313,8 @@
"title": "パスワードをお忘れですか?",
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
"pageTitle": "パスワードをお忘れですか?"
"pageTitle": "パスワードをお忘れですか?",
"passwordAuthDisabled": "パスワード認証が無効になっています。管理者に連絡するか、外部認証プロバイダーを使用してください。"
},
"generateShareLink": {
"generateTitle": "共有リンクを生成",
@@ -327,7 +328,12 @@
"copyButton": "リンクをコピー",
"success": "リンクが正常に生成されました",
"error": "リンクの生成に失敗しました",
"copied": "リンクがクリップボードにコピーされました"
"copied": "リンクがクリップボードにコピーされました",
"readyDescription": "共有リンクが準備できました。QRコードを直接スキャンして、後で使用するためにダウンロードするか、リンクをコピーしてください。",
"tabs": {
"link": "リンク",
"qrcode": "QRコード"
}
},
"home": {
"description": "WeTransferのオープンソース代替です。トラッキングや制限なしに安全にファイルを共有します。",
@@ -355,6 +361,12 @@
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
"categoryBadge": "{category}{count}個のアイコン)"
},
"imageEdit": {
"title": "画像を編集",
"rotate": "回転",
"zoom": "ズーム",
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
},
"login": {
"welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください",
@@ -613,7 +625,8 @@
"createLink": "リンクを作成",
"delete": "削除",
"copyLinkTitle": "リンクをコピー",
"createLinkCTA": "受信リンクを作成"
"createLinkCTA": "受信リンクを作成",
"viewQrCode": "QRコードを表示"
},
"status": {
"active": "有効",
@@ -629,7 +642,8 @@
"viewDetails": "詳細を表示",
"edit": "編集",
"delete": "削除",
"viewFiles": "受信済みファイル"
"viewFiles": "受信済みファイル",
"viewQrCode": "QRコードを表示"
},
"empty": {
"title": "受信リンクが作成されていません",
@@ -1115,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "自己署名証明書を信頼",
"description": "自己署名SSL/TLS証明書を信頼するように設定します開発環境で便利"
},
"passwordAuthEnabled": {
"title": "パスワード認証",
"description": "パスワード認証を有効または無効にする"
}
},
"buttons": {
@@ -1124,7 +1142,8 @@
},
"errors": {
"loadFailed": "設定の読み込みに失敗しました",
"updateFailed": "設定の更新に失敗しました"
"updateFailed": "設定の更新に失敗しました",
"passwordAuthRequiresProvider": "少なくとも1つのアクティブな認証プロバイダーがない場合、パスワード認証を無効にできません"
},
"messages": {
"noChanges": "保存する変更はありません",
@@ -1230,7 +1249,10 @@
"invalidDate": "無効な日付",
"loadError": "共有詳細の読み込みに失敗しました",
"editSecurity": "セキュリティを編集",
"editExpiration": "期限を編集"
"editExpiration": "期限を編集",
"clickToEnlargeQrCode": "QRコードを拡大",
"downloadQrCode": "QRコードをダウンロード",
"qrCode": "QRコード"
},
"shareExpiration": {
"neverExpires": "期限なし",
@@ -1417,7 +1439,8 @@
"copyLink": "リンクコピー",
"notifyRecipients": "受信者に通知",
"delete": "削除",
"downloadShareFiles": "すべてのファイルをダウンロードします"
"downloadShareFiles": "すべてのファイルをダウンロードします",
"viewQrCode": "QRコードを表示"
},
"bulkActions": {
"delete": "削除",
@@ -1720,10 +1743,9 @@
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"imageEdit": {
"title": "画像を編集",
"rotate": "回転",
"zoom": "ズーム",
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
"qrCodeModal": {
"title": "QRコードを共有",
"description": "このQRコードをスキャンしてリンクにアクセスしてください。",
"download": "QRコードをダウンロード"
}
}

View File

@@ -313,7 +313,8 @@
"title": "비밀번호를 잊으셨나요?",
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
"pageTitle": "비밀번호를 잊으셨나요?"
"pageTitle": "비밀번호를 잊으셨나요?",
"passwordAuthDisabled": "비밀번호 인증이 비활성화되어 있습니다. 관리자에게 문의하거나 외부 인증 공급자를 사용하세요."
},
"generateShareLink": {
"generateTitle": "공유 링크 생성",
@@ -327,7 +328,12 @@
"copyButton": "링크 복사",
"success": "링크가 성공적으로 생성되었습니다",
"error": "링크 생성에 실패했습니다",
"copied": "링크가 클립보드에 복사되었습니다"
"copied": "링크가 클립보드에 복사되었습니다",
"readyDescription": "공유 링크가 준비되었습니다. QR 코드를 직접 스캔하여 링크에 접근하거나, 나중에 사용하기 위해 다운로드하거나, 아래 링크를 복사할 수 있습니다.",
"tabs": {
"link": "링크",
"qrcode": "QR 코드"
}
},
"home": {
"description": "WeTransfer의 오픈소스 대안입니다. 추적이나 제한 없이 파일을 안전하게 공유하세요.",
@@ -355,6 +361,12 @@
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
"categoryBadge": "{category} ({count}개의 아이콘)"
},
"imageEdit": {
"title": "이미지 편집",
"rotate": "회전",
"zoom": "확대/축소",
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
},
"login": {
"welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요",
@@ -613,7 +625,8 @@
"createLink": "링크 생성",
"delete": "삭제",
"copyLinkTitle": "링크 복사",
"createLinkCTA": "수신 링크 생성"
"createLinkCTA": "수신 링크 생성",
"viewQrCode": "QR 코드 보기"
},
"status": {
"active": "활성",
@@ -629,7 +642,8 @@
"viewDetails": "상세 보기",
"edit": "편집",
"delete": "삭제",
"viewFiles": "받은 파일"
"viewFiles": "받은 파일",
"viewQrCode": "QR 코드 보기"
},
"empty": {
"title": "생성된 수신 링크 없음",
@@ -1115,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "자체 서명된 인증서 신뢰",
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
},
"passwordAuthEnabled": {
"title": "비밀번호 인증",
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
}
},
"buttons": {
@@ -1124,7 +1142,8 @@
},
"errors": {
"loadFailed": "설정을 불러오는데 실패했습니다",
"updateFailed": "설정 업데이트에 실패했습니다"
"updateFailed": "설정 업데이트에 실패했습니다",
"passwordAuthRequiresProvider": "최소 하나의 활성 인증 제공자가 없으면 비밀번호 인증을 비활성화할 수 없습니다"
},
"messages": {
"noChanges": "저장할 변경 사항이 없습니다",
@@ -1230,7 +1249,10 @@
"invalidDate": "잘못된 날짜",
"loadError": "공유 세부 정보 로드에 실패했습니다",
"editSecurity": "보안 편집",
"editExpiration": "만료 편집"
"editExpiration": "만료 편집",
"clickToEnlargeQrCode": "QR 코드 확대",
"downloadQrCode": "QR 코드 다운로드",
"qrCode": "QR 코드"
},
"shareExpiration": {
"neverExpires": "만료되지 않음",
@@ -1417,7 +1439,8 @@
"copyLink": "링크 복사",
"notifyRecipients": "받는 사람에게 알림",
"delete": "삭제",
"downloadShareFiles": "모든 파일을 다운로드하십시오"
"downloadShareFiles": "모든 파일을 다운로드하십시오",
"viewQrCode": "QR 코드 보기"
},
"bulkActions": {
"delete": "삭제",
@@ -1720,10 +1743,9 @@
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"imageEdit": {
"title": "이미지 편집",
"rotate": "회전",
"zoom": "확대/축소",
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
"qrCodeModal": {
"title": "QR 코드 공유",
"description": "이 QR 코드를 스캔하여 링크에 접근할 수 있습니다.",
"download": "QR 코드 다운로드"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Link kopiëren",
"success": "Link succesvol gegenereerd",
"error": "Fout bij het genereren van link",
"copied": "Link gekopieerd naar klembord"
"copied": "Link gekopieerd naar klembord",
"readyDescription": "Uw deel-link is klaar. U kunt de QR-code direct scannen, downloaden voor later gebruik, of de link hieronder kopiëren.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "Het open-source alternatief voor WeTransfer. Deel bestanden veilig, zonder tracking of beperkingen.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} pictogrammen van {libraryCount} bibliotheken",
"categoryBadge": "{category} ({count} pictogrammen)"
},
"imageEdit": {
"title": "Afbeelding bewerken",
"rotate": "Draai",
"zoom": "Vergroot",
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
},
"login": {
"welcome": "Welkom bij",
"signInToContinue": "Log in om door te gaan",
@@ -613,7 +625,8 @@
"createLink": "Link Aanmaken",
"delete": "Verwijderen",
"copyLinkTitle": "Link kopiëren",
"createLinkCTA": "Ontvangstlink Aanmaken"
"createLinkCTA": "Ontvangstlink Aanmaken",
"viewQrCode": "QR Code Bekijken"
},
"status": {
"active": "Actief",
@@ -629,7 +642,8 @@
"viewDetails": "Details Bekijken",
"edit": "Bewerken",
"delete": "Verwijderen",
"viewFiles": "Ontvangen Bestanden"
"viewFiles": "Ontvangen Bestanden",
"viewQrCode": "QR Code Bekijken"
},
"empty": {
"title": "Geen ontvangstlinks aangemaakt",
@@ -1115,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": {
@@ -1124,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Ongeldige datum",
"loadError": "Fout bij laden van delen details",
"editSecurity": "Beveiliging Bewerken",
"editExpiration": "Vervaldatum Bewerken"
"editExpiration": "Vervaldatum Bewerken",
"clickToEnlargeQrCode": "Klik om QR Code te vergroten",
"downloadQrCode": "QR Code Downloaden",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "Verloopt Nooit",
@@ -1417,7 +1439,8 @@
"copyLink": "Link Kopiëren",
"notifyRecipients": "Ontvangers Informeren",
"delete": "Verwijderen",
"downloadShareFiles": "Download alle bestanden"
"downloadShareFiles": "Download alle bestanden",
"viewQrCode": "QR Code Bekijken"
},
"bulkActions": {
"delete": "Verwijderen",
@@ -1720,10 +1743,9 @@
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"imageEdit": {
"title": "Afbeelding bewerken",
"rotate": "Draai",
"zoom": "Vergroot",
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
"qrCodeModal": {
"title": "QR Code Delen",
"description": "Scan deze QR-code om toegang te krijgen tot de link.",
"download": "QR Code Downloaden"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Skopiuj link",
"success": "Link wygenerowany pomyślnie",
"error": "Nie udało się wygenerować linku",
"copied": "Link skopiowany do schowka"
"copied": "Link skopiowany do schowka",
"readyDescription": "Twój link do udostępniania jest gotowy. Możesz skanować kod QR bezpośrednio, pobrać go do późniejszego użycia lub skopiować link poniżej.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "Otwartoźródłowa alternatywa dla WeTransfer. Udostępniaj pliki bezpiecznie, bez śledzenia i ograniczeń.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} ikon z {libraryCount} bibliotek",
"categoryBadge": "{category} ({count} ikon)"
},
"imageEdit": {
"title": "Edytuj obraz",
"rotate": "Obróć",
"zoom": "Powiększ",
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
},
"login": {
"welcome": "Witaj w",
"signInToContinue": "Zaloguj się, aby kontynuować",
@@ -613,7 +625,8 @@
"createLink": "Utwórz link",
"delete": "Usuń",
"copyLinkTitle": "Skopiuj link",
"createLinkCTA": "Utwórz link do odbierania"
"createLinkCTA": "Utwórz link do odbierania",
"viewQrCode": "Wyświetl kod QR"
},
"status": {
"active": "Aktywny",
@@ -629,7 +642,8 @@
"viewDetails": "Wyświetl szczegóły",
"edit": "Edytuj",
"delete": "Usuń",
"viewFiles": "Odebrane pliki"
"viewFiles": "Odebrane pliki",
"viewQrCode": "Wyświetl kod QR"
},
"empty": {
"title": "Brak utworzonych linków do odbierania",
@@ -1115,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": {
@@ -1124,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",
@@ -1230,7 +1249,10 @@
"recipients": "Odbiorcy",
"notAvailable": "N/A",
"invalidDate": "Nieprawidłowa data",
"loadError": "Nie udało się załadować szczegółów udostępnienia"
"loadError": "Nie udało się załadować szczegółów udostępnienia",
"clickToEnlargeQrCode": "Kliknij, aby powiększyć kod QR",
"downloadQrCode": "Pobierz kod QR",
"qrCode": "Kod QR"
},
"shareExpiration": {
"title": "Ustawienia wygaśnięcia udostępnienia",
@@ -1417,7 +1439,8 @@
"copyLink": "Skopiuj link",
"notifyRecipients": "Powiadom odbiorców",
"delete": "Usuń",
"downloadShareFiles": "Pobierz wszystkie pliki"
"downloadShareFiles": "Pobierz wszystkie pliki",
"viewQrCode": "Wyświetl kod QR"
},
"bulkActions": {
"delete": "Usuń",
@@ -1720,10 +1743,9 @@
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"imageEdit": {
"title": "Edytuj obraz",
"rotate": "Obróć",
"zoom": "Powiększ",
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
"qrCodeModal": {
"title": "Udostępnij kod QR",
"description": "Skanuj ten kod QR, aby uzyskać dostęp do linku.",
"download": "Pobierz kod QR"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Copiar link",
"success": "Link gerado com sucesso",
"error": "Erro ao gerar link",
"copied": "Link copiado para a área de transferência"
"copied": "Link copiado para a área de transferência",
"readyDescription": "Seu link de compartilhamento está pronto. Você pode escanear o código QR diretamente, baixá-lo para uso posterior ou copiar o link abaixo.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} ícones de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} ícones)"
},
"imageEdit": {
"title": "Editar imagem",
"rotate": "Girar",
"zoom": "Ampliar",
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
},
"login": {
"welcome": "Bem-vindo ao",
"signInToContinue": "Faça login para continuar",
@@ -613,7 +625,8 @@
"createLink": "Criar Link",
"delete": "Excluir",
"copyLinkTitle": "Copiar link",
"createLinkCTA": "Criar Link de Recebimento"
"createLinkCTA": "Criar Link de Recebimento",
"viewQrCode": "Visualizar QR Code"
},
"status": {
"active": "Ativo",
@@ -629,7 +642,8 @@
"viewDetails": "Ver Detalhes",
"edit": "Editar",
"delete": "Excluir",
"viewFiles": "Arquivos Recebidos"
"viewFiles": "Arquivos Recebidos",
"viewQrCode": "[TO_TRANSLATE] View QR Code"
},
"empty": {
"title": "Nenhum link de recebimento criado",
@@ -1123,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": {
@@ -1132,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Data inválida",
"loadError": "Falha ao carregar detalhes do compartilhamento",
"editSecurity": "Editar Segurança",
"editExpiration": "Editar Expiração"
"editExpiration": "Editar Expiração",
"clickToEnlargeQrCode": "Clique para ampliar o QR Code",
"downloadQrCode": "Baixar QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "Nunca Expira",
@@ -1425,7 +1447,8 @@
"copyLink": "Copiar Link",
"notifyRecipients": "Notificar Destinatários",
"delete": "Excluir",
"downloadShareFiles": "Baixar todos os arquivos"
"downloadShareFiles": "Baixar todos os arquivos",
"viewQrCode": "Visualizar QR Code"
}
},
"storageUsage": {
@@ -1720,10 +1743,9 @@
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "O nome de usuário não pode conter espaços"
},
"imageEdit": {
"title": "Editar imagem",
"rotate": "Girar",
"zoom": "Ampliar",
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
"qrCodeModal": {
"title": "Compartilhar QR Code",
"description": "Escaneie este código QR para acessar o link.",
"download": "Baixar QR Code"
}
}

View File

@@ -313,7 +313,8 @@
"title": "Забыли пароль",
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
"pageTitle": "Забыли пароль"
"pageTitle": "Забыли пароль",
"passwordAuthDisabled": "Парольная аутентификация отключена. Пожалуйста, свяжитесь с администратором или используйте внешний провайдер аутентификации."
},
"generateShareLink": {
"generateTitle": "Создать ссылку для обмена",
@@ -327,7 +328,12 @@
"copyButton": "Копировать ссылку",
"success": "Ссылка успешно создана",
"error": "Ошибка при создании ссылки",
"copied": "Ссылка скопирована в буфер обмена"
"copied": "Ссылка скопирована в буфер обмена",
"readyDescription": "Ваша ссылка для обмена готова. Вы можете сканировать QR-код напрямую, скачать его для последующего использования или скопировать ссылку ниже.",
"tabs": {
"link": "Ссылка",
"qrcode": "QR-код"
}
},
"home": {
"description": "Открытая альтернатива WeTransfer. Делитесь файлами безопасно, без отслеживания и ограничений.",
@@ -355,6 +361,12 @@
"stats": "{iconCount} иконок из {libraryCount} библиотек",
"categoryBadge": "{category} ({count} иконок)"
},
"imageEdit": {
"title": "Редактировать изображение",
"rotate": "Повернуть",
"zoom": "Увеличить",
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
},
"login": {
"welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить",
@@ -613,7 +625,8 @@
"createLink": "Создать ссылку",
"delete": "Удалить",
"copyLinkTitle": "Копировать ссылку",
"createLinkCTA": "Создать ссылку для получения"
"createLinkCTA": "Создать ссылку для получения",
"viewQrCode": "Просмотр QR-кода"
},
"status": {
"active": "Активно",
@@ -629,7 +642,8 @@
"viewDetails": "Просмотр деталей",
"edit": "Редактировать",
"delete": "Удалить",
"viewFiles": "Полученные файлы"
"viewFiles": "Полученные файлы",
"viewQrCode": "Просмотр QR-кода"
},
"empty": {
"title": "Нет созданных ссылок для получения",
@@ -1115,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "Доверять самоподписанным сертификатам",
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
},
"passwordAuthEnabled": {
"title": "Парольная аутентификация",
"description": "Включить или отключить парольную аутентификацию"
}
},
"buttons": {
@@ -1124,7 +1142,8 @@
},
"errors": {
"loadFailed": "Ошибка загрузки настроек",
"updateFailed": "Ошибка обновления настроек"
"updateFailed": "Ошибка обновления настроек",
"passwordAuthRequiresProvider": "Парольную аутентификацию нельзя отключить, если нет хотя бы одного активного поставщика аутентификации"
},
"messages": {
"noChanges": "Изменений для сохранения нет",
@@ -1230,7 +1249,10 @@
"invalidDate": "Неверная дата",
"loadError": "Ошибка загрузки деталей общего доступа",
"editSecurity": "Изменить безопасность",
"editExpiration": "Изменить срок действия"
"editExpiration": "Изменить срок действия",
"clickToEnlargeQrCode": "Нажмите, чтобы увеличить QR-код",
"downloadQrCode": "Скачать QR-код",
"qrCode": "QR-код"
},
"shareExpiration": {
"neverExpires": "Никогда не истекает",
@@ -1417,7 +1439,8 @@
"copyLink": "Скопировать Ссылку",
"notifyRecipients": "Уведомить Получателей",
"delete": "Удалить",
"downloadShareFiles": "Загрузите все файлы"
"downloadShareFiles": "Загрузите все файлы",
"viewQrCode": "Просмотр QR-кода"
},
"bulkActions": {
"delete": "Удалить",
@@ -1720,10 +1743,9 @@
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"imageEdit": {
"title": "Редактировать изображение",
"rotate": "Повернуть",
"zoom": "Увеличить",
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
"qrCodeModal": {
"title": "Поделиться QR-кодом",
"description": "Отсканируйте этот QR-код, чтобы получить доступ к ссылке.",
"download": "Скачать QR-код"
}
}

View File

@@ -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",
@@ -327,7 +328,12 @@
"copyButton": "Bağlantıyı Kopyala",
"success": "Bağlantı başarıyla oluşturuldu",
"error": "Bağlantı oluşturulamadı",
"copied": "Bağlantı panoya kopyalandı"
"copied": "Bağlantı panoya kopyalandı",
"readyDescription": "Paylaşım bağlantınız hazır. QR kodu doğrudan tarayabilir, daha sonra kullanmak için indirebilir veya aşağıdaki bağlantıyı kopyalayabilirsiniz.",
"tabs": {
"link": "Bağlantı",
"qrcode": "QR Kodu"
}
},
"home": {
"description": "WeTransfer'e açık kaynaklı alternatif. Takip veya kısıtlama olmadan dosyalarınızı güvenle paylaşın.",
@@ -355,6 +361,12 @@
"stats": "{libraryCount} kütüphaneden {iconCount} simge",
"categoryBadge": "{category} ({count} simge)"
},
"imageEdit": {
"title": "Resmi Düzenle",
"rotate": "Döndür",
"zoom": "Yakınlaştır",
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
},
"login": {
"welcome": "Hoş geldiniz'e",
"signInToContinue": "Devam etmek için oturum açın",
@@ -613,7 +625,8 @@
"createLink": "Bağlantı Oluştur",
"delete": "Sil",
"copyLinkTitle": "Bağlantıyı kopyala",
"createLinkCTA": "Alma Bağlantısı Oluştur"
"createLinkCTA": "Alma Bağlantısı Oluştur",
"viewQrCode": "QR Kodu Görüntüle"
},
"status": {
"active": "Aktif",
@@ -629,7 +642,8 @@
"viewDetails": "Detayları Görüntüle",
"edit": "Düzenle",
"delete": "Sil",
"viewFiles": "Alınan Dosyalar"
"viewFiles": "Alınan Dosyalar",
"viewQrCode": "QR Kodu Görüntüle"
},
"empty": {
"title": "Alma bağlantısı oluşturulmadı",
@@ -1115,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": {
@@ -1124,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",
@@ -1230,7 +1249,10 @@
"invalidDate": "Geçersiz tarih",
"loadError": "Paylaşım detaylarını yükleme başarısız",
"editSecurity": "Güvenlik Düzenle",
"editExpiration": "Son Kullanma Düzenle"
"editExpiration": "Son Kullanma Düzenle",
"clickToEnlargeQrCode": "QR Kodu Büyüt",
"downloadQrCode": "QR Kodu İndir",
"qrCode": "QR Kodu"
},
"shareExpiration": {
"neverExpires": "Asla Sona Ermez",
@@ -1417,7 +1439,8 @@
"copyLink": "Bağlantıyı Kopyala",
"notifyRecipients": "Alıcıları Bilgilendir",
"delete": "Sil",
"downloadShareFiles": "Tüm dosyaları indirin"
"downloadShareFiles": "Tüm dosyaları indirin",
"viewQrCode": "QR Kodu Görüntüle"
},
"bulkActions": {
"delete": "Sil",
@@ -1720,10 +1743,9 @@
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"imageEdit": {
"title": "Resmi Düzenle",
"rotate": "Döndür",
"zoom": "Yakınlaştır",
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
"qrCodeModal": {
"title": "QR Kodu Paylaş",
"description": "Bu QR kodu tarayarak bağlantıya erişebilirsiniz.",
"download": "QR Kodu İndir"
}
}

View File

@@ -313,7 +313,8 @@
"title": "忘记密码?",
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
"resetInstructions": "密码重置指令已发送到您的电子邮件",
"pageTitle": "忘记密码?"
"pageTitle": "忘记密码?",
"passwordAuthDisabled": "密码认证已禁用。请联系您的管理员或使用外部认证提供商。"
},
"generateShareLink": {
"generateTitle": "生成分享链接",
@@ -327,7 +328,12 @@
"copyButton": "复制链接",
"success": "链接生成成功",
"error": "链接生成失败",
"copied": "链接已复制到剪贴板"
"copied": "链接已复制到剪贴板",
"readyDescription": "您的分享链接已准备就绪:",
"tabs": {
"link": "链接",
"qrcode": "QR Code"
}
},
"home": {
"description": "WeTransfer的开源替代方案。安全分享文件无需跟踪或限制。",
@@ -355,6 +361,12 @@
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
"categoryBadge": "{category}{count} 个图标)"
},
"imageEdit": {
"title": "编辑图片",
"rotate": "旋转",
"zoom": "缩放",
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
},
"login": {
"welcome": "欢迎您",
"signInToContinue": "请登录以继续",
@@ -613,7 +625,8 @@
"createLink": "创建链接",
"delete": "删除",
"copyLinkTitle": "复制链接",
"createLinkCTA": "创建接收链接"
"createLinkCTA": "创建接收链接",
"viewQrCode": "查看QR Code"
},
"status": {
"active": "活动",
@@ -629,7 +642,8 @@
"viewDetails": "查看详情",
"edit": "编辑",
"delete": "删除",
"viewFiles": "已接收文件"
"viewFiles": "已接收文件",
"viewQrCode": "查看QR Code"
},
"empty": {
"title": "未创建接收链接",
@@ -1115,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "信任自签名证书",
"description": "启用此选项以信任自签名SSL/TLS证书对开发环境有用"
},
"passwordAuthEnabled": {
"title": "密码认证",
"description": "启用或禁用基于密码的认证"
}
},
"buttons": {
@@ -1124,7 +1142,8 @@
},
"errors": {
"loadFailed": "加载设置失败",
"updateFailed": "更新设置失败"
"updateFailed": "更新设置失败",
"passwordAuthRequiresProvider": "没有至少一个活动认证提供者时,无法禁用密码认证"
},
"messages": {
"noChanges": "没有需要保存的更改",
@@ -1230,7 +1249,10 @@
"description": "描述",
"linkCopied": "链接已复制到剪贴板",
"editSecurity": "编辑安全",
"editExpiration": "编辑过期"
"editExpiration": "编辑过期",
"clickToEnlargeQrCode": "点击放大QR Code",
"downloadQrCode": "下载QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "永不过期",
@@ -1417,7 +1439,8 @@
"copyLink": "复制链接",
"notifyRecipients": "通知收件人",
"delete": "删除",
"downloadShareFiles": "下载所有文件"
"downloadShareFiles": "下载所有文件",
"viewQrCode": "查看QR Code"
},
"bulkActions": {
"delete": "删除",
@@ -1720,10 +1743,9 @@
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"imageEdit": {
"title": "编辑图片",
"rotate": "旋转",
"zoom": "缩放",
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
"qrCodeModal": {
"title": "分享QR Code",
"description": "扫描此QR Code以访问链接。",
"download": "下载QR Code"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "v3.1.1-beta",
"version": "3.1.4-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -70,6 +70,7 @@
"react-hook-form": "^7.59.0",
"react-icons": "^5.5.0",
"react-image-crop": "^11.0.10",
"react-qr-code": "^2.0.18",
"react-qr-reader": "3.0.0-beta-1",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
@@ -98,4 +99,4 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.3"
}
}
}

View File

@@ -128,6 +128,9 @@ importers:
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@19.1.0)
react-qr-code:
specifier: ^2.0.18
version: 2.0.18(react@19.1.0)
react-qr-reader:
specifier: 3.0.0-beta-1
version: 3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -2544,6 +2547,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qr.js@0.0.0:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@@ -2591,6 +2597,11 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-qr-code@2.0.18:
resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
peerDependencies:
react: '*'
react-qr-reader@3.0.0-beta-1:
resolution: {integrity: sha512-5HeFH9x/BlziRYQYGK2AeWS9WiKYZtGGMs9DXy3bcySTX3C9UJL9EwcPnWw8vlf7JP4FcrAlr1SnZ5nsWLQGyw==}
peerDependencies:
@@ -5451,6 +5462,8 @@ snapshots:
punycode@2.3.1: {}
qr.js@0.0.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
@@ -5491,6 +5504,12 @@ snapshots:
react-is@16.13.1: {}
react-qr-code@2.0.18(react@19.1.0):
dependencies:
prop-types: 15.8.1
qr.js: 0.0.0
react: 19.1.0
react-qr-reader@3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@zxing/browser': 0.0.7(@zxing/library@0.18.6)

View File

@@ -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,
});

View File

@@ -1,7 +1,9 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { version } from "../../../../../../package.json";
import packageJson from "../../../../../../package.json";
const { version } = packageJson;
export function TransparentFooter() {
const t = useTranslations();

View File

@@ -11,6 +11,7 @@ import {
IconLink,
IconLock,
IconLockOpen,
IconQrcode,
IconToggleLeft,
IconToggleRight,
IconTrash,
@@ -38,6 +39,7 @@ interface ReverseShareCardProps {
onGenerateLink: (reverseShare: ReverseShare) => void;
onViewDetails: (reverseShare: ReverseShare) => void;
onViewFiles: (reverseShare: ReverseShare) => void;
onViewQrCode?: (reverseShare: ReverseShare) => void;
onUpdateReverseShare?: (id: string, data: any) => Promise<any>;
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<any>;
@@ -51,6 +53,7 @@ export function ReverseShareCard({
onGenerateLink,
onViewDetails,
onViewFiles,
onViewQrCode,
onUpdateReverseShare,
onToggleActive,
onUpdatePassword,
@@ -230,6 +233,18 @@ export function ReverseShareCard({
</div>
<div className="flex items-center gap-1">
{hasAlias && onViewQrCode && (
<Button
variant="outline"
size="sm"
className="h-6 w-6 p-0 hover:bg-background/80 rounded-sm"
onClick={() => onViewQrCode(reverseShare)}
title={t("reverseShares.card.viewQrCode")}
>
<IconQrcode className="h-3 w-3" />
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -239,7 +254,6 @@ export function ReverseShareCard({
>
<IconEye className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
@@ -257,6 +271,11 @@ export function ReverseShareCard({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onViewDetails(reverseShare)}>
<IconEye className="h-4 w-4" />
{t("reverseShares.card.viewDetails")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopyLink(reverseShare)}>
<IconCopy className="h-4 w-4" />
{t("reverseShares.card.copyLink")}
@@ -286,6 +305,13 @@ export function ReverseShareCard({
{t("reverseShares.actions.viewFiles")}
</DropdownMenuItem>
{hasAlias && onViewQrCode && (
<DropdownMenuItem onClick={() => onViewQrCode(reverseShare)}>
<IconQrcode className="h-4 w-4" />
{t("reverseShares.actions.viewQrCode")}
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-destructive" onClick={() => onDelete(reverseShare)}>
<IconTrash className="h-4 w-4" />
{t("reverseShares.card.delete")}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import {
IconCopy,
IconDownload,
IconEdit,
IconLink,
IconLock,
@@ -11,6 +12,7 @@ import {
IconToggleRight,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,6 +44,7 @@ interface ReverseShareDetailsModalProps {
onCopyLink?: (reverseShare: ReverseShare) => void;
onToggleActive?: (id: string, isActive: boolean) => Promise<void>;
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<void>;
onViewQrCode?: (reverseShare: ReverseShare) => void;
refreshTrigger?: number;
onSuccess?: () => void;
}
@@ -55,10 +58,12 @@ export function ReverseShareDetailsModal({
onCopyLink,
onToggleActive,
onUpdatePassword,
onViewQrCode,
onSuccess,
}: ReverseShareDetailsModalProps) {
const t = useTranslations();
const [pendingChanges, setPendingChanges] = useState<Record<string, any>>({});
const [isDownloading, setIsDownloading] = useState(false);
const {
showAliasModal,
@@ -140,46 +145,119 @@ export function ReverseShareDetailsModal({
isActive={reverseShare.isActive}
/>
{/* Informações Básicas */}
<div className="space-y-3">
<h3 className="text-base font-medium text-foreground border-b pb-2">
{t("reverseShares.modals.details.basicInfo")}
</h3>
<div className="grid grid-cols-2 gap-4">
{/* Informações Básicas */}
<div className="space-y-3">
<h3 className="text-base font-medium text-foreground border-b pb-2">
{t("reverseShares.modals.details.basicInfo")}
</h3>
<EditableField
label={t("reverseShares.form.name.label")}
value={getDisplayValue(reverseShare, "name", pendingChanges)}
onSave={(value) => handleUpdateField("name", value)}
placeholder={t("reverseShares.card.untitled")}
disabled={!onUpdateReverseShare}
/>
<EditableField
label={t("reverseShares.form.name.label")}
value={getDisplayValue(reverseShare, "name", pendingChanges)}
onSave={(value) => handleUpdateField("name", value)}
placeholder={t("reverseShares.card.untitled")}
disabled={!onUpdateReverseShare}
/>
<EditableField
label={t("reverseShares.labels.description")}
value={getDisplayValue(reverseShare, "description", pendingChanges)}
onSave={(value) => handleUpdateField("description", value)}
placeholder={t("reverseShares.card.noDescription")}
disabled={!onUpdateReverseShare}
/>
<EditableField
label={t("reverseShares.labels.description")}
value={getDisplayValue(reverseShare, "description", pendingChanges)}
onSave={(value) => handleUpdateField("description", value)}
placeholder={t("reverseShares.card.noDescription")}
disabled={!onUpdateReverseShare}
/>
<EditableField
label={t("reverseShares.labels.pageLayout")}
value={getDisplayValue(reverseShare, "pageLayout", pendingChanges)}
onSave={(value) => handleUpdateField("pageLayout", value)}
type="select"
options={[
{ value: "DEFAULT", label: t("reverseShares.labels.layoutOptions.default") },
{ value: "WETRANSFER", label: t("reverseShares.labels.layoutOptions.wetransfer") },
]}
disabled={!onUpdateReverseShare}
renderValue={(value) => (
<Badge variant="secondary" className="bg-purple-500/20 text-purple-700 border-purple-200">
{value === "WETRANSFER"
? t("reverseShares.labels.layoutOptions.wetransfer")
: t("reverseShares.labels.layoutOptions.default")}
</Badge>
)}
/>
<EditableField
label={t("reverseShares.labels.pageLayout")}
value={getDisplayValue(reverseShare, "pageLayout", pendingChanges)}
onSave={(value) => handleUpdateField("pageLayout", value)}
type="select"
options={[
{ value: "DEFAULT", label: t("reverseShares.labels.layoutOptions.default") },
{ value: "WETRANSFER", label: t("reverseShares.labels.layoutOptions.wetransfer") },
]}
disabled={!onUpdateReverseShare}
renderValue={(value) => (
<Badge variant="secondary" className="bg-purple-500/20 text-purple-700 border-purple-200">
{value === "WETRANSFER"
? t("reverseShares.labels.layoutOptions.wetransfer")
: t("reverseShares.labels.layoutOptions.default")}
</Badge>
)}
/>
</div>
{/* QR Code */}
{reverseShareLink && (
<div className="space-y-3">
<div className="flex items-center gap-2 border-b pb-2">
<h3
className="text-base font-medium text-foreground cursor-pointer"
onClick={() => onViewQrCode && onViewQrCode(reverseShare)}
>
{t("qrCodeModal.title")}
</h3>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => {
const svg = document.getElementById("reverse-share-details-qr-code");
if (!svg) return;
setIsDownloading(true);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const padding = 20;
canvas.width = 200 + padding * 2;
canvas.height = 200 + padding * 2;
if (ctx) {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
img.onload = () => {
ctx.drawImage(img, padding, padding, 200, 200);
const link = document.createElement("a");
link.download = `${reverseShare?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "reverse-share"}-qr-code.png`;
link.href = canvas.toDataURL("image/png");
link.click();
setIsDownloading(false);
};
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
} else {
setIsDownloading(false);
}
}}
disabled={isDownloading}
title={t("qrCodeModal.download")}
>
<IconDownload className="h-3 w-3" />
</Button>
</div>
<div className="flex flex-col items-start justify-start">
<div
className="p-2 bg-white rounded-lg cursor-pointer hover:opacity-80 transition-opacity duration-300"
onClick={() => onViewQrCode && onViewQrCode(reverseShare)}
title={t("reverseShares.actions.viewQrCode")}
>
<QRCode
id="reverse-share-details-qr-code"
value={reverseShareLink}
size={100}
level="H"
fgColor="#000000"
bgColor="#FFFFFF"
/>
</div>
</div>
</div>
)}
</div>
{/* Link de Compartilhamento */}

View File

@@ -10,6 +10,7 @@ interface ReverseSharesCardsContainerProps {
onGenerateLink: (reverseShare: ReverseShare) => void;
onViewDetails: (reverseShare: ReverseShare) => void;
onViewFiles: (reverseShare: ReverseShare) => void;
onViewQrCode?: (reverseShare: ReverseShare) => void;
onCreateReverseShare: () => void;
onUpdateReverseShare?: (id: string, data: any) => Promise<any>;
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
@@ -24,6 +25,7 @@ export function ReverseSharesCardsContainer({
onGenerateLink,
onViewDetails,
onViewFiles,
onViewQrCode,
onCreateReverseShare,
onUpdateReverseShare,
onToggleActive,
@@ -45,6 +47,7 @@ export function ReverseSharesCardsContainer({
onGenerateLink={onGenerateLink}
onViewDetails={onViewDetails}
onViewFiles={onViewFiles}
onViewQrCode={onViewQrCode}
onUpdateReverseShare={onUpdateReverseShare}
onToggleActive={onToggleActive}
onUpdatePassword={onUpdatePassword}

View File

@@ -1,3 +1,4 @@
import { QrCodeModal } from "@/components/modals/qr-code-modal";
import type { CreateReverseShareBody, UpdateReverseShareBody } from "@/http/endpoints/reverse-shares/types";
import { ReverseShare } from "../hooks/use-reverse-shares";
import { CreateReverseShareModal } from "./create-reverse-share-modal";
@@ -20,14 +21,17 @@ interface ReverseSharesModalsProps {
reverseShareToGenerateLink: ReverseShare | null;
reverseShareToDelete: ReverseShare | null;
reverseShareToViewFiles: ReverseShare | null;
reverseShareToViewQrCode: ReverseShare | null;
isDeleting: boolean;
onCloseViewDetails: () => void;
onCloseGenerateLink: () => void;
onCloseDeleteModal: () => void;
onCloseViewFiles: () => void;
onCloseViewQrCode: () => void;
onConfirmDelete: (reverseShare: ReverseShare) => Promise<void>;
onCreateAlias: (reverseShareId: string, alias: string) => Promise<void>;
onCopyLink: (reverseShare: ReverseShare) => void;
onViewQrCode: (reverseShare: ReverseShare) => void;
onUpdateReverseShareData?: (id: string, data: any) => Promise<any>;
onUpdatePassword?: (id: string, data: { hasPassword: boolean; password?: string }) => Promise<any>;
onToggleActive?: (id: string, isActive: boolean) => Promise<any>;
@@ -48,14 +52,17 @@ export function ReverseSharesModals({
reverseShareToGenerateLink,
reverseShareToDelete,
reverseShareToViewFiles,
reverseShareToViewQrCode,
isDeleting,
onCloseViewDetails,
onCloseGenerateLink,
onCloseDeleteModal,
onCloseViewFiles,
onCloseViewQrCode,
onConfirmDelete,
onCreateAlias,
onCopyLink,
onViewQrCode,
onUpdateReverseShareData,
onUpdatePassword,
onToggleActive,
@@ -103,6 +110,7 @@ export function ReverseSharesModals({
onCopyLink={onCopyLink}
onUpdatePassword={onUpdatePassword}
onToggleActive={onToggleActive}
onViewQrCode={onViewQrCode}
/>
<ReceivedFilesModal
@@ -112,6 +120,17 @@ export function ReverseSharesModals({
onRefresh={onRefreshData}
refreshReverseShare={refreshReverseShare}
/>
<QrCodeModal
isOpen={!!reverseShareToViewQrCode}
onClose={onCloseViewQrCode}
shareLink={
reverseShareToViewQrCode?.alias?.alias
? `${typeof window !== "undefined" ? window.location.origin : ""}/r/${reverseShareToViewQrCode.alias.alias}`
: ""
}
shareName={reverseShareToViewQrCode?.name || "Reverse Share"}
/>
</>
);
}

View File

@@ -30,6 +30,7 @@ export function useReverseShares() {
const [reverseShareToDelete, setReverseShareToDelete] = useState<ReverseShare | null>(null);
const [reverseShareToEdit, setReverseShareToEdit] = useState<ReverseShare | null>(null);
const [reverseShareToViewFiles, setReverseShareToViewFiles] = useState<ReverseShare | null>(null);
const [reverseShareToViewQrCode, setReverseShareToViewQrCode] = useState<ReverseShare | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
@@ -277,6 +278,7 @@ export function useReverseShares() {
reverseShareToDelete,
reverseShareToEdit,
reverseShareToViewFiles,
reverseShareToViewQrCode,
isDeleting,
isCreateModalOpen,
isCreating,
@@ -288,6 +290,7 @@ export function useReverseShares() {
setReverseShareToDelete,
setReverseShareToEdit,
setReverseShareToViewFiles,
setReverseShareToViewQrCode,
setIsCreateModalOpen,
handleCopyLink,
handleDeleteReverseShare,

View File

@@ -23,6 +23,7 @@ export default function ReverseSharesPage() {
reverseShareToDelete,
reverseShareToEdit,
reverseShareToViewFiles,
reverseShareToViewQrCode,
isDeleting,
isCreateModalOpen,
isCreating,
@@ -37,6 +38,7 @@ export default function ReverseSharesPage() {
setReverseShareToDelete,
setReverseShareToEdit,
setReverseShareToViewFiles,
setReverseShareToViewQrCode,
handleCreateAlias,
handleUpdatePassword,
handleUpdateReverseShareData,
@@ -77,6 +79,7 @@ export default function ReverseSharesPage() {
onGenerateLink={setReverseShareToGenerateLink}
onViewDetails={setReverseShareToViewDetails}
onViewFiles={setReverseShareToViewFiles}
onViewQrCode={setReverseShareToViewQrCode}
onCreateReverseShare={() => setIsCreateModalOpen(true)}
onUpdateReverseShare={handleUpdateReverseShareData}
onToggleActive={handleToggleActive}
@@ -99,14 +102,17 @@ export default function ReverseSharesPage() {
reverseShareToViewDetails={reverseShareToViewDetails}
reverseShareToDelete={reverseShareToDelete}
reverseShareToViewFiles={reverseShareToViewFiles}
reverseShareToViewQrCode={reverseShareToViewQrCode}
isDeleting={isDeleting}
onCloseGenerateLink={() => setReverseShareToGenerateLink(null)}
onCloseViewDetails={() => setReverseShareToViewDetails(null)}
onCloseDeleteModal={() => setReverseShareToDelete(null)}
onCloseViewFiles={() => setReverseShareToViewFiles(null)}
onCloseViewQrCode={() => setReverseShareToViewQrCode(null)}
onConfirmDelete={handleDeleteReverseShare}
onCreateAlias={handleCreateAlias}
onCopyLink={handleCopyLink}
onViewQrCode={setReverseShareToViewQrCode}
onUpdateReverseShareData={handleUpdateReverseShareData}
onUpdatePassword={handleUpdatePassword}
onToggleActive={handleToggleActive}

View File

@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
import { CreateShareModal } from "@/components/modals/create-share-modal";
import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation-modal";
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
import { QrCodeModal } from "@/components/modals/qr-code-modal";
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
@@ -30,6 +31,11 @@ export function SharesModals({
onSuccess();
};
const getShareLink = (share: any) => {
if (!share?.alias?.alias) return "";
return `${window.location.origin}/s/${share.alias.alias}`;
};
return (
<>
<CreateShareModal isOpen={isCreateModalOpen} onClose={onCloseCreateModal} onSuccess={handleShareSuccess} />
@@ -51,6 +57,13 @@ export function SharesModals({
onEditFile={fileManager.handleRename}
/>
<QrCodeModal
isOpen={!!shareManager.shareToViewQrCode}
onClose={() => shareManager.setShareToViewQrCode(null)}
shareLink={getShareLink(shareManager.shareToViewQrCode)}
shareName={shareManager.shareToViewQrCode?.name || "Share"}
/>
<DeleteConfirmationModal
isOpen={!!shareManager.sharesToDelete}
onClose={() => shareManager.setSharesToDelete(null)}

View File

@@ -20,6 +20,7 @@ export function SharesTableContainer({ shares, onCopyLink, onCreateShare, shareM
onManageFiles={shareManager.setShareToManageFiles}
onManageRecipients={shareManager.setShareToManageRecipients}
onNotifyRecipients={shareManager.handleNotifyRecipients}
onViewQrCode={shareManager.setShareToViewQrCode}
onViewDetails={shareManager.setShareToViewDetails}
setClearSelectionCallback={shareManager.setClearSelectionCallback}
/>

View 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;
}

View 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 });
}
}

View File

@@ -62,6 +62,7 @@ export function RecentShares({ shares, shareManager, onOpenCreateModal, onCopyLi
onManageFiles={shareManager.setShareToManageFiles}
onManageRecipients={shareManager.setShareToManageRecipients}
onNotifyRecipients={shareManager.handleNotifyRecipients}
onViewQrCode={shareManager.setShareToViewQrCode}
onViewDetails={shareManager.setShareToViewDetails}
setClearSelectionCallback={shareManager.setClearSelectionCallback}
/>

View File

@@ -7,6 +7,7 @@ import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation
import { FileActionsModals } from "@/components/modals/file-actions-modals";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
import { QrCodeModal } from "@/components/modals/qr-code-modal";
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
@@ -25,6 +26,11 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
onSuccess();
};
const getShareLink = (share: any) => {
if (!share?.alias?.alias) return "";
return `${window.location.origin}/s/${share.alias.alias}`;
};
return (
<>
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
@@ -144,6 +150,13 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
onGenerate={shareManager.handleGenerateLink}
onSuccess={onSuccess}
/>
<QrCodeModal
isOpen={!!shareManager.shareToViewQrCode}
onClose={() => shareManager.setShareToViewQrCode(null)}
shareLink={getShareLink(shareManager.shareToViewQrCode)}
shareName={shareManager.shareToViewQrCode?.name || "Share"}
/>
</>
);
}

View File

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

View File

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

View File

@@ -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>
)}
</>
);
}

View File

@@ -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) => (

View File

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

View File

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

View File

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

View File

@@ -104,8 +104,10 @@ export function EditProviderForm({
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
{ pattern: "kinde.com", scopes: ["openid", "profile", "email"] },
{ pattern: "zitadel.com", scopes: ["openid", "profile", "email"] },
{ pattern: "pocketid", scopes: ["openid", "profile", "email"] },
];
for (const { pattern, scopes } of providerPatterns) {

View File

@@ -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"));
}
}
};

View File

@@ -24,7 +24,7 @@ export function UsersHeader({ onCreateUser }: UsersHeaderProps) {
<h1 className="text-2xl font-bold">{t("users.header.title")}</h1>
</div>
<Button className="font-semibold" onClick={onCreateUser}>
<IconUserPlus size={18} className="mr-2" />
<IconUserPlus size={18} />
{t("users.header.addUser")}
</Button>
</div>

View File

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

View File

@@ -65,7 +65,7 @@ export function LanguageSwitcher() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.entries(languages).map(([code, name]) => {
const isCurrentLocale = locale === code.split("-")[0];
const isCurrentLocale = locale === code;
return (
<DropdownMenuItem

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { IconCopy } from "@tabler/icons-react";
import { IconCopy, IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -33,6 +34,7 @@ export function GenerateShareLinkModal({
const [isLoading, setIsLoading] = useState(false);
const [generatedLink, setGeneratedLink] = useState("");
const [isEdit, setIsEdit] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
useEffect(() => {
if (shareId && share?.alias?.alias) {
@@ -68,9 +70,56 @@ export function GenerateShareLinkModal({
toast.success(t("generateShareLink.copied"));
};
const downloadQRCode = () => {
setIsDownloading(true);
// Get the SVG element
const svg = document.getElementById("share-link-qr-code");
if (!svg) {
setIsDownloading(false);
return;
}
// Create a canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set dimensions (with some padding)
const padding = 20;
canvas.width = 256 + padding * 2;
canvas.height = 256 + padding * 2;
// Fill white background
if (ctx) {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Convert SVG to data URL
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
img.onload = () => {
// Draw the image in the center of the canvas with padding
ctx.drawImage(img, padding, padding, 256, 256);
// Create a download link
const link = document.createElement("a");
link.download = `${share?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "share"}-qr-code.png`;
link.href = canvas.toDataURL("image/png");
link.click();
setIsDownloading(false);
};
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
} else {
setIsDownloading(false);
}
};
return (
<Dialog open={!!shareId} onOpenChange={() => onClose()}>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit ? t("generateShareLink.updateTitle") : t("generateShareLink.generateTitle")}
@@ -88,23 +137,55 @@ export function GenerateShareLinkModal({
/>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("generateShareLink.linkReady")}</p>
<Input readOnly value={generatedLink} />
<div className="space-y-6">
<p className="text-sm text-muted-foreground text-left">
{t("generateShareLink.readyDescription", {
defaultValue:
"Your share link is ready. You can scan the QR code directly, download it for later use, or copy the link below.",
})}
</p>
<div className="flex flex-col items-center justify-center">
<div className="p-4 bg-white rounded-lg">
<QRCode
id="share-link-qr-code"
value={generatedLink}
size={200}
level="H"
fgColor="#000000"
bgColor="#FFFFFF"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex space-x-2">
<Input readOnly value={generatedLink} className="flex-1" />
<Button
variant="outline"
size="icon"
onClick={handleCopyLink}
title={t("generateShareLink.copyButton")}
>
<IconCopy className="h-4 w-4" />
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={downloadQRCode} disabled={isDownloading}>
<IconDownload className="h-4 w-4" />
{t("qrCodeModal.download")}
</Button>
</DialogFooter>
</div>
)}
<DialogFooter>
{!generatedLink ? (
{!generatedLink && (
<DialogFooter>
<Button disabled={!alias || isLoading} onClick={handleGenerate}>
{isEdit ? t("generateShareLink.updateButton") : t("generateShareLink.generateButton")}
</Button>
) : (
<Button onClick={handleCopyLink}>
<IconCopy className="h-4 w-4" />
{t("generateShareLink.copyButton")}
</Button>
)}
</DialogFooter>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,13 @@
export { QrCodeModal } from "./qr-code-modal";
export { UploadFileModal } from "./upload-file-modal";
export { CreateShareModal } from "./create-share-modal";
export { ShareSecurityModal } from "./share-security-modal";
export { ShareFileModal } from "./share-file-modal";
export { ShareMultipleFilesModal } from "./share-multiple-files-modal";
export { ShareDetailsModal } from "./share-details-modal";
export { FilePreviewModal } from "./file-preview-modal";
export { GenerateShareLinkModal } from "./generate-share-link-modal";
export { ImageEditModal } from "./image-edit-modal";
export { DeleteConfirmationModal } from "./delete-confirmation-modal";
export { BulkDownloadModal } from "./bulk-download-modal";
export { ShareExpirationModal } from "./share-expiration-modal";

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface QrCodeModalProps {
isOpen: boolean;
onClose: () => void;
shareLink: string;
shareName: string;
}
export function QrCodeModal({ isOpen, onClose, shareLink, shareName }: QrCodeModalProps) {
const t = useTranslations();
const [isDownloading, setIsDownloading] = useState(false);
const downloadQRCode = () => {
setIsDownloading(true);
// Get the SVG element
const svg = document.getElementById("share-qr-code");
if (!svg) {
setIsDownloading(false);
return;
}
// Create a canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set dimensions (with some padding)
const padding = 20;
canvas.width = 256 + padding * 2;
canvas.height = 256 + padding * 2;
// Fill white background
if (ctx) {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Convert SVG to data URL
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
img.onload = () => {
// Draw the image in the center of the canvas with padding
ctx.drawImage(img, padding, padding, 256, 256);
// Create a download link
const link = document.createElement("a");
link.download = `${shareName.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-qr-code.png`;
link.href = canvas.toDataURL("image/png");
link.click();
setIsDownloading(false);
};
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
} else {
setIsDownloading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("qrCodeModal.title", { defaultValue: "Share QR Code" })}</DialogTitle>
<DialogDescription>
{t("qrCodeModal.description", { defaultValue: "Scan this QR code to access the shared files." })}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center justify-center">
<div className="p-4 bg-white rounded-lg">
<QRCode id="share-qr-code" value={shareLink} size={256} level="H" fgColor="#000000" bgColor="#FFFFFF" />
</div>
<p className="mt-4 text-sm text-muted-foreground text-center max-w-full break-all">{shareLink}</p>
</div>
<DialogFooter className="sm:justify-between flex-row">
<Button variant="outline" onClick={onClose} className="mt-2 sm:mt-0">
{t("common.close")}
</Button>
<Button onClick={downloadQRCode} className="mt-2 sm:mt-0" disabled={isDownloading}>
<IconDownload className="h-4 w-4" />
{t("qrCodeModal.download", { defaultValue: "Download QR Code" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
IconCheck,
IconCopy,
IconDownload,
IconEdit,
IconExternalLink,
IconLock,
@@ -13,6 +14,7 @@ import {
} from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -30,6 +32,7 @@ import { Loader } from "@/components/ui/loader";
import { getShare } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
import { GenerateShareLinkModal } from "./generate-share-link-modal";
import { QrCodeModal } from "./qr-code-modal";
import { ShareExpirationModal } from "./share-expiration-modal";
import { ShareSecurityModal } from "./share-security-modal";
@@ -86,6 +89,8 @@ export function ShareDetailsModal({
const [showLinkModal, setShowLinkModal] = useState(false);
const [showSecurityModal, setShowSecurityModal] = useState(false);
const [showExpirationModal, setShowExpirationModal] = useState(false);
const [showQrCodeModal, setShowQrCodeModal] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const loadShareDetails = useCallback(async () => {
@@ -209,6 +214,53 @@ export function ShareDetailsModal({
}
};
const downloadQRCode = () => {
setIsDownloading(true);
// Get the SVG element
const svg = document.getElementById("share-details-qr-code");
if (!svg) {
setIsDownloading(false);
return;
}
// Create a canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set dimensions (with some padding)
const padding = 20;
canvas.width = 200 + padding * 2;
canvas.height = 200 + padding * 2;
// Fill white background
if (ctx) {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Convert SVG to data URL
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
img.onload = () => {
// Draw the image in the center of the canvas with padding
ctx.drawImage(img, padding, padding, 200, 200);
// Create a download link
const link = document.createElement("a");
link.download = `${share?.name?.replace(/[^a-z0-9]/gi, "-").toLowerCase() || "share"}-qr-code.png`;
link.href = canvas.toDataURL("image/png");
link.click();
setIsDownloading(false);
};
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
} else {
setIsDownloading(false);
}
};
const handleLinkGenerated = async () => {
setShowLinkModal(false);
await loadShareDetails();
@@ -258,7 +310,7 @@ export function ShareDetailsModal({
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 bg-muted/30 rounded-lg">
<p className="text-lg font-semibold text-green-600">{share.viewCount || 0}</p>
<p className="text-lg font-semibold text-green-600">{share.views || 0}</p>
<p className="text-xs text-muted-foreground">{t("shareDetails.views")}</p>
</div>
<div className="text-center p-2 bg-muted/30 rounded-lg">
@@ -271,105 +323,148 @@ export function ShareDetailsModal({
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2 border-b pb-2">
<h3 className="text-base font-medium text-foreground">{t("shareDetails.basicInfo")}</h3>
<div className="grid grid-cols-2 gap-4">
{/* Basic Information */}
<div className="space-y-3">
<div className="flex items-center gap-2 border-b pb-2">
<h3 className="text-base font-medium text-foreground">{t("shareDetails.basicInfo")}</h3>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-sm font-medium text-muted-foreground">{t("shareDetails.name")}</label>
{onUpdateName && !isEditingName && (
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEdit("name", displayName || "")}
>
<IconEdit className="h-3 w-3" />
</Button>
)}
</div>
{isEditingName ? (
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 flex-1 text-sm"
onClick={(e) => e.stopPropagation()}
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-green-600 hover:text-green-700"
onClick={saveEdit}
>
<IconCheck className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-600 hover:text-red-700"
onClick={cancelEdit}
>
<IconX className="h-3 w-3" />
</Button>
</div>
) : (
<span className="text-sm font-medium block">{displayName || t("shareDetails.untitled")}</span>
)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-sm font-medium text-muted-foreground">
{t("shareDetails.description")}
</label>
{onUpdateDescription && !isEditingDescription && (
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEdit("description", displayDescription || "")}
>
<IconEdit className="h-3 w-3" />
</Button>
)}
</div>
{isEditingDescription ? (
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 flex-1 text-sm"
placeholder={t("shareDetails.noDescription")}
onClick={(e) => e.stopPropagation()}
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-green-600 hover:text-green-700"
onClick={saveEdit}
>
<IconCheck className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-600 hover:text-red-700"
onClick={cancelEdit}
>
<IconX className="h-3 w-3" />
</Button>
</div>
) : (
<span className="text-sm block">{displayDescription || t("shareDetails.noDescription")}</span>
)}
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-sm font-medium text-muted-foreground">{t("shareDetails.name")}</label>
{onUpdateName && !isEditingName && (
{/* QR Code */}
{shareLink && (
<div className="space-y-3">
<div className="flex items-center gap-2 border-b pb-2">
<h3
className="text-base font-medium text-foreground cursor-pointer"
onClick={() => setShowQrCodeModal(true)}
>
{t("shareDetails.qrCode", { defaultValue: "QR Code" })}
</h3>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEdit("name", displayName || "")}
onClick={downloadQRCode}
disabled={isDownloading}
title={t("shareDetails.downloadQrCode")}
>
<IconEdit className="h-3 w-3" />
</Button>
)}
</div>
{isEditingName ? (
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 flex-1 text-sm"
onClick={(e) => e.stopPropagation()}
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-green-600 hover:text-green-700"
onClick={saveEdit}
>
<IconCheck className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-600 hover:text-red-700"
onClick={cancelEdit}
>
<IconX className="h-3 w-3" />
<IconDownload className="h-3 w-3" />
</Button>
</div>
) : (
<span className="text-sm font-medium block">{displayName || t("shareDetails.untitled")}</span>
)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-sm font-medium text-muted-foreground">
{t("shareDetails.description")}
</label>
{onUpdateDescription && !isEditingDescription && (
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEdit("description", displayDescription || "")}
<div className="flex flex-col items-start justify-start ">
<div
className="p-2 bg-white rounded-lg cursor-pointer hover:opacity-80 transition-opacity duration-300"
onClick={() => setShowQrCodeModal(true)}
title={t("shareDetails.clickToEnlargeQrCode", { defaultValue: "Click to enlarge QR Code" })}
>
<IconEdit className="h-3 w-3" />
</Button>
)}
</div>
{isEditingDescription ? (
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 flex-1 text-sm"
placeholder={t("shareDetails.noDescription")}
onClick={(e) => e.stopPropagation()}
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-green-600 hover:text-green-700"
onClick={saveEdit}
>
<IconCheck className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-600 hover:text-red-700"
onClick={cancelEdit}
>
<IconX className="h-3 w-3" />
</Button>
<QRCode
id="share-details-qr-code"
value={shareLink}
size={100}
level="H"
fgColor="#000000"
bgColor="#FFFFFF"
/>
</div>
</div>
) : (
<span className="text-sm block">{displayDescription || t("shareDetails.noDescription")}</span>
)}
</div>
</div>
)}
</div>
<div className="space-y-3">
@@ -556,16 +651,16 @@ export function ShareDetailsModal({
</DialogFooter>
</DialogContent>
</Dialog>
{showLinkModal && onGenerateLink && (
{showLinkModal && shareId && (
<GenerateShareLinkModal
shareId={shareId}
share={share}
onClose={() => setShowLinkModal(false)}
onGenerate={onGenerateLink}
onSuccess={handleLinkGenerated}
onGenerate={onGenerateLink || (() => Promise.resolve())}
/>
)}
{showSecurityModal && (
{showSecurityModal && shareId && onUpdateSecurity && (
<ShareSecurityModal
shareId={shareId}
share={share}
@@ -573,7 +668,7 @@ export function ShareDetailsModal({
onSuccess={handleSecurityUpdated}
/>
)}
{showExpirationModal && (
{showExpirationModal && shareId && onUpdateExpiration && (
<ShareExpirationModal
shareId={shareId}
share={share}
@@ -581,6 +676,14 @@ export function ShareDetailsModal({
onSuccess={handleExpirationUpdated}
/>
)}
{showQrCodeModal && shareLink && (
<QrCodeModal
isOpen={showQrCodeModal}
onClose={() => setShowQrCodeModal(false)}
shareLink={shareLink}
shareName={share?.name || "Share"}
/>
)}
</>
);
}

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { IconCalendar, IconCopy, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
import { IconCalendar, IconCopy, IconDownload, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -123,6 +124,18 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
toast.success(t("generateShareLink.copied"));
};
const downloadQRCode = () => {
const canvas = document.getElementById("share-file-qr-code") as HTMLCanvasElement;
if (canvas) {
const link = document.createElement("a");
link.download = "share-file-qr-code.png";
link.href = canvas.toDataURL("image/png");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const handleClose = () => {
onClose();
setTimeout(() => {
@@ -259,8 +272,26 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
</>
) : (
<>
<div className="flex flex-col items-center justify-center">
<div className="p-4 bg-white rounded-lg">
<svg style={{ display: "none" }} /> {/* For SSR safety */}
<QRCode
id="share-file-qr-code"
value={generatedLink}
size={250}
level="H"
fgColor="#000000"
bgColor="#FFFFFF"
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
<Input readOnly value={generatedLink} />
<div className="flex gap-2">
<Input readOnly value={generatedLink} className="flex-1" />
<Button size="icon" variant="outline" onClick={handleCopyLink} title={t("shareFile.copyLink")}>
<IconCopy className="h-4 w-4" />
</Button>
</div>
</>
)}
</div>
@@ -294,9 +325,9 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
<Button variant="outline" onClick={handleSuccess}>
{t("common.close")}
</Button>
<Button onClick={handleCopyLink}>
<IconCopy className="h-4 w-4" />
{t("shareFile.copyLink")}
<Button onClick={downloadQRCode}>
<IconDownload className="h-4 w-4" />
{t("qrCodeModal.download")}
</Button>
</>
)}

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { IconCalendar, IconCopy, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
import { IconCalendar, IconCopy, IconDownload, IconEye, IconLink, IconLock, IconShare } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import QRCode from "react-qr-code";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -115,6 +116,19 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
toast.success(t("generateShareLink.copied"));
};
const downloadQRCode = () => {
const qrCodeElement = document.getElementById("share-multiple-files-qr-code");
if (qrCodeElement) {
const canvas = qrCodeElement.querySelector("canvas");
if (canvas) {
const link = document.createElement("a");
link.download = "share-multiple-files-qr-code.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
}
};
const handleClose = () => {
onClose();
setTimeout(() => {
@@ -282,8 +296,26 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
</>
) : (
<>
<div className="flex flex-col items-center justify-center">
<div className="p-4 bg-white rounded-lg">
<svg style={{ display: "none" }} /> {/* For SSR safety */}
<QRCode
id="share-multiple-files-qr-code"
value={generatedLink}
size={250}
level="H"
fgColor="#000000"
bgColor="#FFFFFF"
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t("shareFile.linkReady")}</p>
<Input readOnly value={generatedLink} />
<div className="flex gap-2">
<Input readOnly value={generatedLink} className="flex-1" />
<Button variant="outline" size="icon" onClick={handleCopyLink} title={t("shareFile.copyLink")}>
<IconCopy className="h-4 w-4" />
</Button>
</div>
</>
)}
</div>
@@ -323,9 +355,9 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
<Button variant="outline" onClick={handleSuccess}>
{t("common.close")}
</Button>
<Button onClick={handleCopyLink}>
<IconCopy className="h-4 w-4" />
{t("shareFile.copyLink")}
<Button onClick={downloadQRCode}>
<IconDownload className="h-4 w-4" />
{t("qrCodeModal.download")}
</Button>
</>
)}

View File

@@ -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)));
},

View File

@@ -12,6 +12,7 @@ import {
IconLock,
IconLockOpen,
IconMail,
IconQrcode,
IconTrash,
IconUsers,
IconX,
@@ -46,6 +47,7 @@ export interface SharesTableProps {
onGenerateLink: (share: any) => void;
onCopyLink: (share: any) => void;
onNotifyRecipients: (share: any) => void;
onViewQrCode?: (share: any) => void;
onDownloadShareFiles?: (share: any) => void;
onBulkDelete?: (shares: any[]) => void;
onBulkDownload?: (shares: any[]) => void;
@@ -66,6 +68,7 @@ export function SharesTable({
onGenerateLink,
onCopyLink,
onNotifyRecipients,
onViewQrCode,
onDownloadShareFiles,
onBulkDelete,
onBulkDownload,
@@ -604,6 +607,12 @@ export function SharesTable({
{t("sharesTable.actions.copyLink")}
</DropdownMenuItem>
)}
{share.alias && onViewQrCode && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onViewQrCode(share)}>
<IconQrcode className="h-4 w-4" />
{t("sharesTable.actions.viewQrCode", { defaultValue: "View QR Code" })}
</DropdownMenuItem>
)}
{share.recipients?.length > 0 && share.alias && smtpEnabled === "true" && (
<DropdownMenuItem className="cursor-pointer py-2" onClick={() => onNotifyRecipients(share)}>
<IconMail className="h-4 w-4" />

View File

@@ -24,6 +24,7 @@ export interface ShareManagerHook {
shareToManageExpiration: Share | null;
shareToViewDetails: Share | null;
shareToGenerateLink: Share | null;
shareToViewQrCode: Share | null;
sharesToDelete: Share[] | null;
setShareToDelete: (share: Share | null) => void;
setShareToEdit: (share: Share | null) => void;
@@ -33,6 +34,7 @@ export interface ShareManagerHook {
setShareToManageExpiration: (share: Share | null) => void;
setShareToViewDetails: (share: Share | null) => void;
setShareToGenerateLink: (share: Share | null) => void;
setShareToViewQrCode: (share: Share | null) => void;
setSharesToDelete: (shares: Share[] | null) => void;
handleDelete: (shareId: string) => Promise<void>;
handleBulkDelete: (shares: Share[]) => void;
@@ -62,6 +64,7 @@ export function useShareManager(onSuccess: () => void) {
const [shareToManageExpiration, setShareToManageExpiration] = useState<Share | null>(null);
const [shareToViewDetails, setShareToViewDetails] = useState<Share | null>(null);
const [shareToGenerateLink, setShareToGenerateLink] = useState<Share | null>(null);
const [shareToViewQrCode, setShareToViewQrCode] = useState<Share | null>(null);
const [sharesToDelete, setSharesToDelete] = useState<Share[] | null>(null);
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | null>(null);
@@ -308,6 +311,7 @@ export function useShareManager(onSuccess: () => void) {
shareToManageExpiration,
shareToViewDetails,
shareToGenerateLink,
shareToViewQrCode,
sharesToDelete,
setShareToDelete,
setShareToEdit,
@@ -317,6 +321,7 @@ export function useShareManager(onSuccess: () => void) {
setShareToManageExpiration,
setShareToViewDetails,
setShareToGenerateLink,
setShareToViewQrCode,
setSharesToDelete,
handleDelete,
handleBulkDelete,

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,26 @@
import { cookies } from "next/headers";
import { getRequestConfig } from "next-intl/server";
const DEFAULT_LOCALE = "en-US";
const supportedLocales = [
"en-US",
"pt-BR",
"fr-FR",
"es-ES",
"de-DE",
"it-IT",
"nl-NL",
"pl-PL",
"tr-TR",
"ru-RU",
"hi-IN",
"ar-SA",
"zh-CN",
"ja-JP",
"ko-KR",
];
const envDefault = process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || "en-US";
const DEFAULT_LOCALE = supportedLocales.includes(envDefault) ? envDefault : "en-US";
export default getRequestConfig(async ({ locale }) => {
const cookieStore = cookies();
@@ -9,11 +28,12 @@ export default getRequestConfig(async ({ locale }) => {
const localeCookie = cookiesList.get("NEXT_LOCALE");
const resolvedLocale = localeCookie?.value || locale || DEFAULT_LOCALE;
const finalLocale = supportedLocales.includes(resolvedLocale) ? resolvedLocale : DEFAULT_LOCALE;
try {
return {
locale: resolvedLocale,
messages: (await import(`../../messages/${resolvedLocale}.json`)).default,
locale: finalLocale,
messages: (await import(`../../messages/${finalLocale}.json`)).default,
};
} catch {
return {

View File

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

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -23,20 +19,9 @@
}
],
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"exclude": [
"node_modules",
".next/types/app/api/(proxy)/**/*",
".next/types/**/*.ts"
],
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
]
}
"exclude": ["node_modules", ".next/types/app/api/(proxy)/**/*", ".next/types/**/*.ts"],
"include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", ".next/types/**/*.ts"]
}

View File

@@ -4,10 +4,12 @@ services:
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -12,9 +12,10 @@ services:
- S3_REGION=${S3_REGION:-us-east-1} # S3 region (us-east-1 is the default region) but it depends on your s3 server region
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-palmr-files} # Bucket name for the S3 storage (here we are using palmr-files as the bucket name to understand that this is the bucket for palmr)
- S3_FORCE_PATH_STYLE=true # For MinIO compatibility we have to set this to true
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -12,9 +12,10 @@ services:
- S3_REGION=${S3_REGION:-us-east-1} # S3 region (us-east-1 is the default region) but it depends on your s3 server region
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-palmr-files} # Bucket name for the S3 storage (here we are using palmr-files as the bucket name to understand that this is the bucket for palmr)
- S3_FORCE_PATH_STYLE=false # For S3 compatibility we have to set this to false
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -4,13 +4,15 @@ services:
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs to see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
volumes:
- palmr_data:/app/server # Volume for the application data (changed from /data to /app/server)
restart: unless-stopped # Restart the container unless it is stopped

View File

@@ -86,6 +86,19 @@ else
return;
}
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.log('true');
@@ -117,6 +130,19 @@ else
return;
}
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.log('true');
@@ -132,6 +158,72 @@ else
if [ "$NEEDS_SEEDING" = "true" ]; then
echo "🌱 New tables detected or missing data, running seed..."
# Check which providers are missing for better logging
MISSING_PROVIDERS=$(
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkMissingProviders() {
try {
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.log('Error checking providers');
} finally {
await prisma.\$disconnect();
}
}
checkMissingProviders();
" 2>/dev/null || echo "Error checking providers"
else
node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkMissingProviders() {
try {
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.log('Error checking providers');
} finally {
await prisma.\$disconnect();
}
}
checkMissingProviders();
" 2>/dev/null || echo "Error checking providers"
fi
)
if [ "$MISSING_PROVIDERS" != "No missing providers" ] && [ "$MISSING_PROVIDERS" != "Error checking providers" ]; then
echo "🔍 $MISSING_PROVIDERS"
fi
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
else

View File

@@ -20,7 +20,7 @@ environment=PORT=3333,HOME="/home/palmr"
priority=100
[program:web]
command=/bin/sh -c 'echo "Waiting for API to be ready..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "API not ready, waiting..."; sleep 2; done; echo "API is ready! Starting frontend..."; exec node server.js'
command=/bin/sh -c 'echo "Please wait while Palmr. is starting..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "1/2 - Palmr. starting, be patient..."; sleep 2; done; echo "2/2 - Palmr. starting, be patient..."; exec node server.js'
directory=/app/web
user=palmr
autostart=true

View File

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

View File

@@ -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",